Feat: CRM v2, Richer synthetic data, Canvas JSON Components

This commit is contained in:
Sagnik
2026-04-23 22:00:44 +05:30
parent 6cdc366718
commit f04571bd7b
54 changed files with 89916 additions and 578 deletions

View File

@@ -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]

View File

@@ -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()}