Feat: CRM v2, Richer synthetic data, Canvas JSON Components
This commit is contained in:
@@ -97,6 +97,18 @@ class CreateReminderRequest(BaseModel):
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
class ClientDataPatchRequest(BaseModel):
|
||||
full_name: str | None = Field(default=None, max_length=256)
|
||||
primary_email: str | None = Field(default=None, max_length=256)
|
||||
primary_phone: str | None = Field(default=None, max_length=64)
|
||||
buyer_type: str | None = Field(default=None, max_length=128)
|
||||
communication_preference: str | None = Field(default=None, max_length=64)
|
||||
best_contact_time: str | None = Field(default=None, max_length=128)
|
||||
lead_status: str | None = Field(default=None, max_length=64)
|
||||
budget_band: str | None = Field(default=None, max_length=128)
|
||||
urgency: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
# ── Import Endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/crm/imports", status_code=201, tags=["CRM Imports"])
|
||||
@@ -796,3 +808,172 @@ async def get_qd_score(request: Request, person_id: str) -> dict[str, Any]:
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Oracle Client Data Lens ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/crm/client-data", tags=["CRM Client Data"])
|
||||
async def list_client_data(
|
||||
request: Request,
|
||||
search: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
params: list[Any] = []
|
||||
where = "1=1"
|
||||
if search:
|
||||
params.append(f"%{search.lower()}%")
|
||||
where = f"(lower(p.full_name) LIKE ${len(params)} OR lower(COALESCE(p.primary_phone,'')) LIKE ${len(params)} OR lower(COALESCE(p.primary_email,'')) LIKE ${len(params)} OR lower(COALESCE(pi.projects,'')) LIKE ${len(params)})"
|
||||
params.extend([limit, offset])
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH interests AS (
|
||||
SELECT person_id, string_agg(DISTINCT project_name, ', ') AS projects, COUNT(*)::int AS interest_count
|
||||
FROM crm_property_interests GROUP BY person_id
|
||||
),
|
||||
qd AS (
|
||||
SELECT DISTINCT ON (person_id) person_id, current_value
|
||||
FROM intel_qd_scores
|
||||
ORDER BY person_id, current_value DESC, computed_at DESC
|
||||
)
|
||||
SELECT p.person_id::text, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
|
||||
p.broker_name, p.communication_preference, p.best_contact_time,
|
||||
l.lead_id::text, l.status::text AS lead_status, l.budget_band, l.urgency,
|
||||
COALESCE(qd.current_value, p.engagement_score, 0)::float AS qd_score,
|
||||
lc.last_contact_at, lc.last_channel, lc.days_since_contact,
|
||||
nba.recommended_action AS next_best_action, nba.priority AS next_action_priority,
|
||||
COALESCE(pi.projects, '') AS projects, COALESCE(pi.interest_count, 0)::int AS interest_count
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
|
||||
) l ON TRUE
|
||||
LEFT JOIN interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN qd ON qd.person_id = p.person_id
|
||||
LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
|
||||
LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
|
||||
WHERE {where}
|
||||
ORDER BY lc.last_contact_at DESC NULLS LAST, qd_score DESC, p.full_name ASC
|
||||
LIMIT ${len(params)-1} OFFSET ${len(params)}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return {"status": "ok", "data": [dict(r) for r in rows], "meta": {"count": len(rows), "limit": limit, "offset": offset}}
|
||||
|
||||
|
||||
@router.get("/crm/client-data/{person_id}", tags=["CRM Client Data"])
|
||||
async def get_client_data(request: Request, person_id: str) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
base = await conn.fetchrow(
|
||||
"""
|
||||
SELECT p.*, l.lead_id::text, l.status::text AS lead_status, l.budget_band, l.urgency,
|
||||
l.financing_posture, l.timeline_to_decision, l.broker_team,
|
||||
lc.last_contact_at, lc.last_channel, lc.days_since_contact,
|
||||
nba.recommended_action, nba.priority AS next_action_priority, nba.rationale,
|
||||
nba.suggested_channel, nba.due_within_days
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
|
||||
) l ON TRUE
|
||||
LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
|
||||
LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
|
||||
WHERE p.person_id = $1::uuid
|
||||
""",
|
||||
person_id,
|
||||
)
|
||||
if not base:
|
||||
raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
|
||||
interests = await conn.fetch("SELECT * FROM crm_property_interests WHERE person_id = $1::uuid ORDER BY priority ASC, created_at DESC", person_id)
|
||||
opportunities = await conn.fetch(
|
||||
"""
|
||||
SELECT o.*, ip.project_name FROM crm_opportunities o
|
||||
JOIN crm_leads l ON l.lead_id = o.lead_id
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = o.project_id
|
||||
WHERE l.person_id = $1::uuid ORDER BY o.updated_at DESC
|
||||
""",
|
||||
person_id,
|
||||
)
|
||||
facts = await conn.fetch("SELECT * FROM intel_extracted_facts WHERE person_id = $1::uuid ORDER BY extracted_at DESC LIMIT 50", person_id)
|
||||
qd = await conn.fetch("SELECT * FROM intel_qd_scores WHERE person_id = $1::uuid ORDER BY current_value DESC", person_id)
|
||||
timeline = await _client_timeline(conn, person_id, 60)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"profile": dict(base),
|
||||
"property_interests": [dict(r) for r in interests],
|
||||
"opportunities": [dict(r) for r in opportunities],
|
||||
"extracted_facts": [dict(r) for r in facts],
|
||||
"qd_scores": [dict(r) for r in qd],
|
||||
"timeline": timeline,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/crm/client-data/{person_id}", tags=["CRM Client Data"])
|
||||
async def patch_client_data(request: Request, person_id: str, body: ClientDataPatchRequest) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
person_fields = {k: payload[k] for k in ("full_name", "primary_email", "primary_phone", "buyer_type", "communication_preference", "best_contact_time") if k in payload}
|
||||
lead_fields = {k: payload[k] for k in ("budget_band", "urgency") if k in payload}
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
if person_fields:
|
||||
sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(person_fields, start=1))
|
||||
await conn.execute(f"UPDATE crm_people SET {sets}, updated_at = NOW() WHERE person_id = ${len(person_fields)+1}::uuid", *person_fields.values(), person_id)
|
||||
if lead_fields:
|
||||
lead_id = await conn.fetchval("SELECT lead_id FROM crm_leads WHERE person_id = $1::uuid ORDER BY updated_at DESC LIMIT 1", person_id)
|
||||
if lead_id:
|
||||
sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(lead_fields, start=1))
|
||||
await conn.execute(f"UPDATE crm_leads SET {sets}, updated_at = NOW() WHERE lead_id = ${len(lead_fields)+1}::uuid", *lead_fields.values(), str(lead_id))
|
||||
if "lead_status" in payload:
|
||||
await conn.execute(
|
||||
"UPDATE crm_leads SET status = $1::crm_lead_status, updated_at = NOW() WHERE person_id = $2::uuid",
|
||||
payload["lead_status"],
|
||||
person_id,
|
||||
)
|
||||
return {"status": "ok", "data": {"person_id": person_id, "updated": sorted(payload.keys())}}
|
||||
|
||||
|
||||
@router.get("/crm/client-data/{person_id}/timeline", tags=["CRM Client Data"])
|
||||
async def get_client_data_timeline(request: Request, person_id: str, limit: int = Query(default=80, ge=1, le=200)) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await _client_timeline(conn, person_id, limit)
|
||||
return {"status": "ok", "data": rows}
|
||||
|
||||
|
||||
@router.post("/crm/client-data/{person_id}/tasks", status_code=201, tags=["CRM Client Data"])
|
||||
async def create_client_data_task(request: Request, person_id: str, body: CreateReminderRequest) -> dict[str, Any]:
|
||||
patched = body.model_copy(update={"person_id": person_id})
|
||||
return await create_task(request, patched)
|
||||
|
||||
|
||||
async def _client_timeline(conn: Any, person_id: str, limit: int) -> list[dict[str, Any]]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM (
|
||||
SELECT i.interaction_id::text AS id, i.channel::text AS type, i.interaction_type AS title,
|
||||
i.summary, i.happened_at AS date, i.broker_name AS actor
|
||||
FROM intel_interactions i WHERE i.person_id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT m.message_id::text, 'message', m.sender_role, m.message_text, m.delivered_at, m.sender_name
|
||||
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id WHERE i.person_id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT r.reminder_id::text, 'reminder', r.title, r.notes, r.due_at, r.priority
|
||||
FROM intel_reminders r WHERE r.person_id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT v.visit_id::text, 'visit', COALESCE(v.outcome_type, 'site_visit'), COALESCE(v.visit_notes, v.broker_notes), v.visited_at, v.broker_name
|
||||
FROM intel_visits v WHERE v.person_id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT q.timeseries_id::text, 'qd', q.score_type, q.signal_source, q.timestamp, q.value::text
|
||||
FROM intel_qd_timeseries q WHERE q.person_id = $1::uuid
|
||||
) events
|
||||
ORDER BY date DESC NULLS LAST
|
||||
LIMIT $2
|
||||
""",
|
||||
person_id,
|
||||
limit,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.oracle.action_service import oracle_action_service
|
||||
from backend.oracle.natural_db_agent import natural_db_agent
|
||||
from backend.oracle.persona_service import persona_service
|
||||
from backend.services.mcp_registry import mcp_registry
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
@@ -33,6 +34,12 @@ class OracleWritebackRequest(BaseModel):
|
||||
writeback_payload: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OracleQueryRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
row_limit: int = Field(default=100, ge=1, le=500)
|
||||
context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def oracle_health() -> dict:
|
||||
return {
|
||||
@@ -43,6 +50,39 @@ async def oracle_health() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/data-health")
|
||||
async def oracle_data_health(request: Request) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
data = await natural_db_agent.data_health(conn)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/schema-catalog")
|
||||
async def oracle_schema_catalog(request: Request) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
catalog = await natural_db_agent.schema_catalog(conn)
|
||||
return {"status": "ok", "data": catalog}
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
async def oracle_query(request: Request, payload: OracleQueryRequest) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
result = await natural_db_agent.execute_prompt(payload.prompt, row_limit=payload.row_limit, conn=conn)
|
||||
return {"status": "ok", "data": result.as_dict()}
|
||||
|
||||
|
||||
@router.get("/mcp/tools")
|
||||
async def oracle_mcp_tools() -> dict:
|
||||
return {"status": "ok", "data": mcp_registry.list_tools()}
|
||||
|
||||
Reference in New Issue
Block a user