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()}
|
||||
|
||||
@@ -220,9 +220,13 @@ CREATE TABLE IF NOT EXISTS crm_property_interests (
|
||||
budget_max DECIMAL(15, 2),
|
||||
priority INTEGER DEFAULT 1, -- 1 = primary, 2 = secondary
|
||||
notes TEXT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE crm_property_interests
|
||||
ADD COLUMN IF NOT EXISTS metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crm_pi_person ON crm_property_interests (person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_crm_pi_project ON crm_property_interests (project_id);
|
||||
|
||||
@@ -706,3 +710,186 @@ COMMENT ON TABLE intel_qd_scores IS 'Latest QD summary by score_type per client.
|
||||
COMMENT ON TABLE inventory_projects IS 'Master project catalog. 14 canonical Kolkata projects seeded.';
|
||||
COMMENT ON TABLE workflow_import_batches IS 'RawImportBatch contract. Immutable after upload.';
|
||||
COMMENT ON TABLE workflow_writebacks IS 'AI-proposed canonical mutations. Never auto-execute without approval.';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Synthetic CRM v2 enrichment columns and Oracle read models
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE crm_people
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS communication_preference TEXT,
|
||||
ADD COLUMN IF NOT EXISTS best_contact_time TEXT;
|
||||
|
||||
ALTER TABLE crm_households
|
||||
ADD COLUMN IF NOT EXISTS size INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS combined_budget_band TEXT,
|
||||
ADD COLUMN IF NOT EXISTS decision_maker_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE crm_leads
|
||||
ADD COLUMN IF NOT EXISTS stage TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_team TEXT,
|
||||
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE crm_opportunities
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS deal_velocity TEXT,
|
||||
ADD COLUMN IF NOT EXISTS risk_factors JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
ALTER TABLE crm_property_interests
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS last_discussed_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE crm_stage_history
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS transition_duration_days INTEGER;
|
||||
|
||||
ALTER TABLE intel_interactions
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_team TEXT,
|
||||
ADD COLUMN IF NOT EXISTS sentiment TEXT,
|
||||
ADD COLUMN IF NOT EXISTS sentiment_score FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS intent_label TEXT,
|
||||
ADD COLUMN IF NOT EXISTS emotion_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS client_engagement_level TEXT;
|
||||
|
||||
ALTER TABLE intel_calls
|
||||
ADD COLUMN IF NOT EXISTS objection_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS outcome_summary TEXT,
|
||||
ADD COLUMN IF NOT EXISTS follow_up_actions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS call_quality_score FLOAT;
|
||||
|
||||
ALTER TABLE intel_emails
|
||||
ADD COLUMN IF NOT EXISTS sentiment TEXT,
|
||||
ADD COLUMN IF NOT EXISTS intent_label TEXT,
|
||||
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS response_expected BOOLEAN;
|
||||
|
||||
ALTER TABLE intel_reminders
|
||||
ADD COLUMN IF NOT EXISTS interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS context_snippet TEXT,
|
||||
ADD COLUMN IF NOT EXISTS completion_percentage INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS overdue_days INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS outcome_notes TEXT;
|
||||
|
||||
ALTER TABLE intel_transcripts
|
||||
ADD COLUMN IF NOT EXISTS call_outcome TEXT,
|
||||
ADD COLUMN IF NOT EXISTS follow_up_required BOOLEAN,
|
||||
ADD COLUMN IF NOT EXISTS emotion_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS call_summary TEXT;
|
||||
|
||||
ALTER TABLE intel_visits
|
||||
ADD COLUMN IF NOT EXISTS outcome_type TEXT,
|
||||
ADD COLUMN IF NOT EXISTS visit_duration_minutes INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS interest_signals JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS interest_score FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS companion_type TEXT,
|
||||
ADD COLUMN IF NOT EXISTS companion_count INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS objections_raised JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS follow_up_required BOOLEAN,
|
||||
ADD COLUMN IF NOT EXISTS next_steps TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_notes TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT;
|
||||
|
||||
ALTER TABLE intel_whatsapp_threads
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS broker_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS topic_category TEXT,
|
||||
ADD COLUMN IF NOT EXISTS sentiment_direction TEXT,
|
||||
ADD COLUMN IF NOT EXISTS resolution_status TEXT;
|
||||
|
||||
ALTER TABLE intel_qd_scores
|
||||
ADD COLUMN IF NOT EXISTS score_drivers JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS trend_direction TEXT,
|
||||
ADD COLUMN IF NOT EXISTS explanation TEXT,
|
||||
ADD COLUMN IF NOT EXISTS confidence FLOAT;
|
||||
|
||||
ALTER TABLE intel_qd_timeseries
|
||||
ADD COLUMN IF NOT EXISTS broker_id TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intel_email_threads (
|
||||
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject TEXT,
|
||||
first_email_at TIMESTAMPTZ,
|
||||
last_email_at TIMESTAMPTZ,
|
||||
email_count INTEGER DEFAULT 0,
|
||||
participants JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
status TEXT,
|
||||
broker_id TEXT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intel_call_objections (
|
||||
objection_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
call_id UUID REFERENCES intel_calls(call_id) ON DELETE CASCADE,
|
||||
objection_type TEXT,
|
||||
category TEXT,
|
||||
severity TEXT,
|
||||
status TEXT,
|
||||
client_quote TEXT,
|
||||
agent_response TEXT,
|
||||
resolution_strategy TEXT,
|
||||
extracted_at TIMESTAMPTZ,
|
||||
confidence_score FLOAT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intel_extracted_facts (
|
||||
fact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
|
||||
person_id UUID REFERENCES crm_people(person_id) ON DELETE CASCADE,
|
||||
fact_type TEXT NOT NULL,
|
||||
fact_value TEXT,
|
||||
confidence FLOAT,
|
||||
extracted_from TEXT,
|
||||
source_context TEXT,
|
||||
extracted_at TIMESTAMPTZ,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS read_last_contacted (
|
||||
person_id UUID PRIMARY KEY REFERENCES crm_people(person_id) ON DELETE CASCADE,
|
||||
last_contact_at TIMESTAMPTZ,
|
||||
last_channel TEXT,
|
||||
last_interaction_type TEXT,
|
||||
days_since_contact INTEGER,
|
||||
interactions_last_7d INTEGER,
|
||||
interactions_last_30d INTEGER,
|
||||
interactions_last_90d INTEGER,
|
||||
total_interactions INTEGER,
|
||||
current_stage TEXT,
|
||||
broker_id TEXT,
|
||||
broker_name TEXT,
|
||||
computed_at TIMESTAMPTZ,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS read_next_best_action (
|
||||
person_id UUID PRIMARY KEY REFERENCES crm_people(person_id) ON DELETE CASCADE,
|
||||
recommended_action TEXT,
|
||||
priority TEXT,
|
||||
rationale TEXT,
|
||||
suggested_channel TEXT,
|
||||
due_within_days INTEGER,
|
||||
broker_id TEXT,
|
||||
broker_name TEXT,
|
||||
opportunity_context TEXT,
|
||||
computed_at TIMESTAMPTZ,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_read_last_contacted_at ON read_last_contacted (last_contact_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_read_next_best_priority ON read_next_best_action (priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_intel_facts_person ON intel_extracted_facts (person_id, extracted_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_intel_objections_call ON intel_call_objections (call_id);
|
||||
|
||||
@@ -98,6 +98,7 @@ def _json_safe(value: Any) -> Any:
|
||||
def _normalize_component(component: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = deepcopy(component)
|
||||
normalized["componentId"] = _stringify(normalized.get("componentId"))
|
||||
normalized["dataRows"] = _json_array(normalized.get("dataRows"))
|
||||
descriptor = _json_object(normalized.get("dataSourceDescriptor"))
|
||||
if descriptor.get("descriptorId") is not None:
|
||||
descriptor["descriptorId"] = _stringify(descriptor["descriptorId"])
|
||||
@@ -126,6 +127,7 @@ def _deserialize_component_row(row: Any) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"lifecycleState": row["lifecycle_state"],
|
||||
"dataSourceDescriptor": row["data_source_descriptor"],
|
||||
"dataRows": row["data_rows"],
|
||||
"visualizationParameters": row["visualization_parameters"],
|
||||
"dataBindings": row["data_bindings"],
|
||||
"provenance": row["provenance"],
|
||||
@@ -602,13 +604,13 @@ class CanvasService:
|
||||
"""
|
||||
INSERT INTO oracle_canvas_components (
|
||||
component_id, page_id, tenant_id, type, title, description, version, lifecycle_state,
|
||||
data_source_descriptor, visualization_parameters, data_bindings, provenance,
|
||||
data_source_descriptor, data_rows, visualization_parameters, data_bindings, provenance,
|
||||
rendering_hints, layout, access_controls, style_signature, validation_state, audit_log
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8,
|
||||
$9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb,
|
||||
$13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb, $18::text[]
|
||||
$9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13::jsonb,
|
||||
$14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb, $18::jsonb, $19::text[]
|
||||
)
|
||||
ON CONFLICT (component_id)
|
||||
DO UPDATE SET
|
||||
@@ -617,6 +619,7 @@ class CanvasService:
|
||||
version = EXCLUDED.version,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
data_source_descriptor = EXCLUDED.data_source_descriptor,
|
||||
data_rows = EXCLUDED.data_rows,
|
||||
visualization_parameters = EXCLUDED.visualization_parameters,
|
||||
data_bindings = EXCLUDED.data_bindings,
|
||||
provenance = EXCLUDED.provenance,
|
||||
@@ -637,6 +640,7 @@ class CanvasService:
|
||||
int(component.get("version", 1)),
|
||||
component.get("lifecycleState", "active"),
|
||||
json.dumps(_json_safe(component.get("dataSourceDescriptor", {}))),
|
||||
json.dumps(_json_safe(component.get("dataRows", []))),
|
||||
json.dumps(_json_safe(component.get("visualizationParameters", {}))),
|
||||
json.dumps(_json_safe(component.get("dataBindings", {}))),
|
||||
json.dumps(_json_safe(component.get("provenance", {}))),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
oracle/data_access_gateway.py
|
||||
Read-only, policy-aware PostgreSQL query executor for Oracle datasets.
|
||||
|
||||
Nemoclaw is treated strictly as a planner. The gateway executes only
|
||||
whitelisted dataset queries and always injects the actor's tenant scope.
|
||||
Nemoclaw/LLM is treated strictly as a planner. The gateway executes only
|
||||
whitelisted read models and always applies policy before touching data.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,9 +21,15 @@ from .policy_service import PolicyContext, PolicyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
_ALLOW_IN_MEMORY = os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
|
||||
|
||||
_DATASET_ALIASES = {
|
||||
"crm_last_interacted_clients": "oracle_last_contacted_clients",
|
||||
"crm_top_interested_clients": "oracle_top_interested_clients",
|
||||
"crm_interaction_timeline": "oracle_client_interaction_timeline",
|
||||
"crm_property_interest_rollup": "oracle_property_interest_rollup",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryExecutionResult:
|
||||
@@ -32,7 +38,29 @@ class QueryExecutionResult:
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
|
||||
if asyncpg is None:
|
||||
return False
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||
return True
|
||||
return all(
|
||||
os.getenv(name)
|
||||
for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
|
||||
)
|
||||
|
||||
|
||||
async def _connect_db() -> Any:
|
||||
assert asyncpg is not None
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||
return await asyncpg.connect(database_url)
|
||||
return await asyncpg.connect(
|
||||
host=os.getenv("VELOCITY_DB_HOST", "localhost"),
|
||||
port=int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
||||
database=os.environ["VELOCITY_DB_NAME"],
|
||||
user=os.environ["VELOCITY_DB_USER"],
|
||||
password=os.environ["VELOCITY_DB_PASSWORD"],
|
||||
)
|
||||
|
||||
|
||||
class DataAccessGateway:
|
||||
@@ -61,7 +89,7 @@ class DataAccessGateway:
|
||||
|
||||
try:
|
||||
rows = await self._query_dataset(
|
||||
dataset=dataset,
|
||||
dataset=_DATASET_ALIASES.get(dataset, dataset),
|
||||
row_limit=validation.effective_row_limit,
|
||||
ctx=ctx,
|
||||
prompt=prompt,
|
||||
@@ -82,8 +110,7 @@ class DataAccessGateway:
|
||||
prompt: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
sql, params = self._build_whitelisted_query(dataset, row_limit, ctx, prompt)
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
conn = await _connect_db()
|
||||
try:
|
||||
records = await conn.fetch(sql, *params)
|
||||
finally:
|
||||
@@ -101,23 +128,9 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "deals":
|
||||
sql = """
|
||||
SELECT
|
||||
stage,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(value), 0)::float AS value,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', lead_id,
|
||||
'name', lead_name,
|
||||
'company', company,
|
||||
'value', value_label,
|
||||
'avatar', avatar_url
|
||||
)
|
||||
ORDER BY value DESC NULLS LAST
|
||||
) FILTER (WHERE lead_id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
SELECT stage, COUNT(*)::int AS count, COALESCE(SUM(value), 0)::float AS value,
|
||||
COALESCE(json_agg(json_build_object('id', lead_id, 'name', lead_name, 'company', company, 'value', value_label, 'avatar', avatar_url)
|
||||
ORDER BY value DESC NULLS LAST) FILTER (WHERE lead_id IS NOT NULL), '[]'::json) AS leads
|
||||
FROM deals
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY stage
|
||||
@@ -128,9 +141,7 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "lead_daily_snapshot":
|
||||
sql = """
|
||||
SELECT
|
||||
source,
|
||||
COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
|
||||
SELECT source, COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
|
||||
FROM lead_daily_snapshot
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY source
|
||||
@@ -141,14 +152,9 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "lead_geo_interest_rollup":
|
||||
sql = """
|
||||
SELECT
|
||||
district,
|
||||
lat,
|
||||
lng,
|
||||
COALESCE(lead_count, 0)::int AS lead_count,
|
||||
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
|
||||
COALESCE(x, 0)::float AS x,
|
||||
COALESCE(y, 0)::float AS y
|
||||
SELECT district, lat, lng, COALESCE(lead_count, 0)::int AS lead_count,
|
||||
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
|
||||
COALESCE(x, 0)::float AS x, COALESCE(y, 0)::float AS y
|
||||
FROM lead_geo_interest_rollup
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY lead_count DESC, district ASC
|
||||
@@ -158,14 +164,11 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "broker_performance":
|
||||
sql = """
|
||||
SELECT
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY COUNT(DISTINCT l.person_id) DESC, COALESCE(u.full_name, u.email, u.id::text) ASC
|
||||
)::int AS rank,
|
||||
COALESCE(u.full_name, u.email, u.id::text) AS name,
|
||||
COUNT(DISTINCT l.person_id)::int AS deals_closed,
|
||||
COALESCE(SUM(o.value), 0)::float AS revenue_generated,
|
||||
u.avatar_url AS avatar
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY COUNT(DISTINCT l.person_id) DESC, COALESCE(u.full_name, u.email, u.id::text) ASC)::int AS rank,
|
||||
COALESCE(u.full_name, u.email, u.id::text) AS name,
|
||||
COUNT(DISTINCT l.person_id)::int AS deals_closed,
|
||||
COALESCE(SUM(o.value), 0)::float AS revenue_generated,
|
||||
u.avatar_url AS avatar
|
||||
FROM users_and_roles u
|
||||
LEFT JOIN crm_leads l ON l.assigned_user_id = u.id
|
||||
LEFT JOIN crm_opportunities o ON o.lead_id = l.lead_id
|
||||
@@ -173,16 +176,14 @@ class DataAccessGateway:
|
||||
GROUP BY u.id, u.full_name, u.email, u.avatar_url
|
||||
HAVING COUNT(DISTINCT l.person_id) > 0 OR COALESCE(SUM(o.value), 0) > 0
|
||||
ORDER BY revenue_generated DESC, name ASC
|
||||
LIMIT $2
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "inventory_absorption":
|
||||
sql = """
|
||||
SELECT
|
||||
period_label AS period,
|
||||
COALESCE(absorption_rate, 0)::float AS absorption_rate,
|
||||
COALESCE(target_rate, 0)::float AS target_rate
|
||||
SELECT period_label AS period, COALESCE(absorption_rate, 0)::float AS absorption_rate,
|
||||
COALESCE(target_rate, 0)::float AS target_rate
|
||||
FROM inventory_absorption
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY period_start ASC
|
||||
@@ -196,16 +197,10 @@ class DataAccessGateway:
|
||||
metric_name = "total_pipeline_value"
|
||||
elif "quota" in lower_prompt or "attainment" in lower_prompt:
|
||||
metric_name = "quota_attainment"
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
metric_value,
|
||||
metric_label,
|
||||
trend_value,
|
||||
comparison_label
|
||||
SELECT metric_value, metric_label, trend_value, comparison_label
|
||||
FROM oracle_aggregated_metric
|
||||
WHERE tenant_id = $1
|
||||
AND metric_name = $2
|
||||
WHERE tenant_id = $1 AND metric_name = $2
|
||||
ORDER BY observed_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
@@ -214,27 +209,19 @@ class DataAccessGateway:
|
||||
if dataset == "lead_activity_log":
|
||||
if "follow-up" in lower_prompt or "queue" in lower_prompt:
|
||||
sql = """
|
||||
SELECT
|
||||
lead_name AS name,
|
||||
assigned_broker,
|
||||
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
|
||||
COALESCE(qd_score, 0)::float AS qd_score,
|
||||
urgency,
|
||||
avatar_url AS avatar
|
||||
SELECT lead_name AS name, assigned_broker,
|
||||
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
|
||||
COALESCE(qd_score, 0)::float AS qd_score, urgency, avatar_url AS avatar
|
||||
FROM lead_activity_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY last_contact_hours_ago DESC, qd_score DESC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
activity_type AS type,
|
||||
COALESCE(activity_title, activity_summary, activity_type) AS title,
|
||||
activity_summary AS summary,
|
||||
actor_name AS actor,
|
||||
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
SELECT activity_type AS type, COALESCE(activity_title, activity_summary, activity_type) AS title,
|
||||
activity_summary AS summary, actor_name AS actor,
|
||||
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
FROM lead_activity_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY activity_at DESC
|
||||
@@ -244,27 +231,19 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "crm_contacts_overview":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(p.city, '') AS city,
|
||||
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(p.city, '') AS city,
|
||||
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.score_type = 'engagement_score' THEN 0
|
||||
WHEN q.score_type = 'intent_score' THEN 1
|
||||
WHEN q.score_type = 'urgency_score' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.computed_at DESC
|
||||
ORDER BY CASE WHEN q.score_type = 'engagement_score' THEN 0 WHEN q.score_type = 'intent_score' THEN 1 WHEN q.score_type = 'urgency_score' THEN 2 ELSE 3 END,
|
||||
q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
ORDER BY qd_score DESC, p.full_name ASC
|
||||
@@ -274,23 +253,10 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "crm_opportunity_pipeline":
|
||||
sql = """
|
||||
SELECT
|
||||
o.stage::text AS stage,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(o.value), 0)::float AS value,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', o.opportunity_id,
|
||||
'name', p.full_name,
|
||||
'company', COALESCE(a.account_name, ''),
|
||||
'value', COALESCE(o.value, 0),
|
||||
'nextAction', COALESCE(o.next_action, '')
|
||||
)
|
||||
ORDER BY o.value DESC NULLS LAST
|
||||
) FILTER (WHERE o.opportunity_id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
SELECT o.stage::text AS stage, COUNT(*)::int AS count, COALESCE(SUM(o.value), 0)::float AS value,
|
||||
COALESCE(json_agg(json_build_object('id', o.opportunity_id, 'name', p.full_name, 'company', COALESCE(a.account_name, ''),
|
||||
'value', COALESCE(o.value, 0), 'nextAction', COALESCE(o.next_action, ''))
|
||||
ORDER BY o.value DESC NULLS LAST) FILTER (WHERE o.opportunity_id IS NOT NULL), '[]'::json) AS leads
|
||||
FROM crm_opportunities o
|
||||
JOIN crm_leads l ON l.lead_id = o.lead_id
|
||||
JOIN crm_people p ON p.person_id = l.person_id
|
||||
@@ -301,95 +267,213 @@ class DataAccessGateway:
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_property_interest_rollup":
|
||||
if dataset == "oracle_property_interest_rollup":
|
||||
sql = """
|
||||
SELECT
|
||||
project_name AS category,
|
||||
COUNT(*)::int AS value,
|
||||
ROUND(AVG(COALESCE((budget_min + budget_max) / 2.0, budget_max, budget_min, 0)), 2)::float AS average_budget
|
||||
FROM crm_property_interests
|
||||
GROUP BY project_name
|
||||
ORDER BY value DESC, project_name ASC
|
||||
SELECT COALESCE(pi.project_name, ip.project_name, 'Unknown Project') AS category,
|
||||
COUNT(*)::int AS value,
|
||||
ROUND(AVG(COALESCE((pi.budget_min + pi.budget_max) / 2.0, pi.budget_max, pi.budget_min, 0)), 2)::float AS average_budget,
|
||||
MAX(pi.created_at) AS latest_interest_at
|
||||
FROM crm_property_interests pi
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
|
||||
GROUP BY COALESCE(pi.project_name, ip.project_name, 'Unknown Project')
|
||||
ORDER BY value DESC, category ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_last_interacted_clients":
|
||||
if dataset == "oracle_last_contacted_clients":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(MAX(i.happened_at), p.updated_at, p.created_at) AS last_interaction_at,
|
||||
COUNT(i.interaction_id)::int AS interaction_count,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
WITH message_contacts AS (
|
||||
SELECT i.person_id, MAX(m.delivered_at) AS contacted_at
|
||||
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
||||
GROUP BY i.person_id
|
||||
), email_contacts AS (
|
||||
SELECT i.person_id, MAX(e.sent_at) AS contacted_at
|
||||
FROM intel_emails e JOIN intel_interactions i ON i.interaction_id = e.interaction_id
|
||||
GROUP BY i.person_id
|
||||
), call_contacts AS (
|
||||
SELECT i.person_id, MAX(i.happened_at) AS contacted_at
|
||||
FROM intel_calls c JOIN intel_interactions i ON i.interaction_id = c.interaction_id
|
||||
GROUP BY i.person_id
|
||||
), visit_contacts AS (
|
||||
SELECT person_id, MAX(visited_at) AS contacted_at FROM intel_visits GROUP BY person_id
|
||||
), thread_contacts AS (
|
||||
SELECT person_id, MAX(last_message_at) AS contacted_at FROM intel_whatsapp_threads GROUP BY person_id
|
||||
), interaction_contacts AS (
|
||||
SELECT person_id, MAX(happened_at) AS contacted_at FROM intel_interactions GROUP BY person_id
|
||||
), next_reminders AS (
|
||||
SELECT DISTINCT ON (person_id) person_id, title AS next_action, due_at AS next_action_at
|
||||
FROM intel_reminders
|
||||
WHERE status IN ('pending', 'open', 'scheduled')
|
||||
ORDER BY person_id, due_at ASC NULLS LAST
|
||||
), contact_rollup AS (
|
||||
SELECT p.person_id,
|
||||
GREATEST(
|
||||
COALESCE(mc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(ec.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(cc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(vc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(tc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(ic.contacted_at, '-infinity'::timestamptz)
|
||||
) AS last_contacted_at,
|
||||
mc.contacted_at AS last_message_at, ec.contacted_at AS last_email_at,
|
||||
cc.contacted_at AS last_call_at, vc.contacted_at AS last_visit_at,
|
||||
tc.contacted_at AS last_whatsapp_at, ic.contacted_at AS last_interaction_at
|
||||
FROM crm_people p
|
||||
LEFT JOIN message_contacts mc ON mc.person_id = p.person_id
|
||||
LEFT JOIN email_contacts ec ON ec.person_id = p.person_id
|
||||
LEFT JOIN call_contacts cc ON cc.person_id = p.person_id
|
||||
LEFT JOIN visit_contacts vc ON vc.person_id = p.person_id
|
||||
LEFT JOIN thread_contacts tc ON tc.person_id = p.person_id
|
||||
LEFT JOIN interaction_contacts ic ON ic.person_id = p.person_id
|
||||
)
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
|
||||
NULLIF(cr.last_contacted_at, '-infinity'::timestamptz) AS last_contacted_at,
|
||||
CASE
|
||||
WHEN cr.last_contacted_at = cr.last_call_at THEN 'phone'
|
||||
WHEN cr.last_contacted_at = cr.last_email_at THEN 'email'
|
||||
WHEN cr.last_contacted_at = cr.last_visit_at THEN 'site_visit'
|
||||
WHEN cr.last_contacted_at = cr.last_whatsapp_at THEN 'whatsapp'
|
||||
WHEN cr.last_contacted_at = cr.last_message_at THEN 'message'
|
||||
WHEN cr.last_contacted_at = cr.last_interaction_at THEN 'interaction'
|
||||
ELSE 'unknown'
|
||||
END AS last_contact_channel,
|
||||
COALESCE(li.summary, nr.next_action, '') AS last_contact_summary,
|
||||
COUNT(DISTINCT i.interaction_id)::int AS interaction_count,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score,
|
||||
COALESCE(nr.next_action, '') AS next_action,
|
||||
nr.next_action_at
|
||||
FROM crm_people p
|
||||
JOIN contact_rollup cr ON cr.person_id = p.person_id
|
||||
LEFT JOIN intel_interactions i ON i.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
SELECT summary
|
||||
FROM intel_interactions li
|
||||
WHERE li.person_id = p.person_id
|
||||
ORDER BY li.happened_at DESC
|
||||
LIMIT 1
|
||||
) li ON TRUE
|
||||
LEFT JOIN next_reminders nr ON nr.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.score_type = 'engagement_score' THEN 0
|
||||
WHEN q.score_type = 'intent_score' THEN 1
|
||||
WHEN q.score_type = 'urgency_score' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.computed_at DESC
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, p.updated_at, p.created_at, q.current_value
|
||||
ORDER BY last_interaction_at DESC NULLS LAST, interaction_count DESC, p.full_name ASC
|
||||
WHERE cr.last_contacted_at <> '-infinity'::timestamptz
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, cr.last_contacted_at,
|
||||
cr.last_message_at, cr.last_email_at, cr.last_call_at, cr.last_visit_at,
|
||||
cr.last_whatsapp_at, cr.last_interaction_at, li.summary, nr.next_action,
|
||||
nr.next_action_at, q.current_value
|
||||
ORDER BY last_contacted_at DESC NULLS LAST, interaction_count DESC, p.full_name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_top_interested_clients":
|
||||
if dataset == "oracle_top_interested_clients":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COUNT(pi.interest_id)::int AS interest_count,
|
||||
STRING_AGG(DISTINCT pi.project_name, ', ' ORDER BY pi.project_name) AS projects,
|
||||
COALESCE(MAX(pi.created_at), p.updated_at, p.created_at) AS last_interest_at,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
WITH interest_mentions AS (
|
||||
SELECT i.person_id, COUNT(*)::int AS mention_count, MAX(COALESCE(m.delivered_at, i.happened_at)) AS last_mention_at
|
||||
FROM intel_interactions i
|
||||
LEFT JOIN intel_messages m ON m.interaction_id = i.interaction_id
|
||||
WHERE LOWER(COALESCE(i.summary, '') || ' ' || COALESCE(m.message_text, '')) ~
|
||||
'(interested|interest|shortlist|visit|book|budget|configuration|bhk|project|property)'
|
||||
GROUP BY i.person_id
|
||||
)
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
|
||||
COUNT(DISTINCT pi.interest_id)::int AS explicit_interest_count,
|
||||
COALESCE(MAX(im.mention_count), 0)::int AS inferred_interest_count,
|
||||
(COUNT(DISTINCT pi.interest_id) + COALESCE(MAX(im.mention_count), 0))::int AS interest_count,
|
||||
STRING_AGG(DISTINCT COALESCE(pi.project_name, ip.project_name), ', ' ORDER BY COALESCE(pi.project_name, ip.project_name)) AS projects,
|
||||
GREATEST(COALESCE(MAX(pi.created_at), '-infinity'::timestamptz),
|
||||
COALESCE(MAX(im.last_mention_at), '-infinity'::timestamptz),
|
||||
COALESCE(p.updated_at, p.created_at)) AS last_interest_at,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score,
|
||||
COALESCE(MAX(pi.notes), '') AS latest_interest_note
|
||||
FROM crm_people p
|
||||
INNER JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
|
||||
LEFT JOIN interest_mentions im ON im.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.score_type = 'engagement_score' THEN 0
|
||||
WHEN q.score_type = 'intent_score' THEN 1
|
||||
WHEN q.score_type = 'urgency_score' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.computed_at DESC
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, p.updated_at, p.created_at, q.current_value
|
||||
HAVING COUNT(DISTINCT pi.interest_id) > 0 OR COALESCE(MAX(im.mention_count), 0) > 0
|
||||
ORDER BY interest_count DESC, qd_score DESC, last_interest_at DESC NULLS LAST, p.full_name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_interaction_timeline":
|
||||
if dataset == "oracle_client_interaction_timeline":
|
||||
sql = """
|
||||
SELECT
|
||||
i.interaction_type AS type,
|
||||
COALESCE(i.summary, i.interaction_type) AS title,
|
||||
CONCAT(p.full_name, ' · ', i.channel::text) AS summary,
|
||||
p.full_name AS actor,
|
||||
TO_CHAR(i.happened_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
FROM intel_interactions i
|
||||
JOIN crm_people p ON p.person_id = i.person_id
|
||||
ORDER BY i.happened_at DESC
|
||||
WITH timeline AS (
|
||||
SELECT i.person_id, i.channel::text AS type, COALESCE(i.interaction_type, i.channel::text) AS title,
|
||||
COALESCE(i.summary, '') AS detail, i.happened_at AS event_at, 'interaction' AS source_type
|
||||
FROM intel_interactions i
|
||||
UNION ALL
|
||||
SELECT i.person_id, 'message', COALESCE(m.sender_role, 'message'), m.message_text, m.delivered_at, 'message'
|
||||
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
||||
UNION ALL
|
||||
SELECT i.person_id, 'call', c.call_direction::text, COALESCE(t.full_text, c.call_outcome, 'Call record'), i.happened_at, 'call'
|
||||
FROM intel_calls c
|
||||
JOIN intel_interactions i ON i.interaction_id = c.interaction_id
|
||||
LEFT JOIN intel_transcripts t ON t.call_id = c.call_id OR t.interaction_id = i.interaction_id
|
||||
UNION ALL
|
||||
SELECT i.person_id, 'email', COALESCE(e.subject, 'Email'), COALESCE(e.body_text, ''), e.sent_at, 'email'
|
||||
FROM intel_emails e JOIN intel_interactions i ON i.interaction_id = e.interaction_id
|
||||
UNION ALL
|
||||
SELECT v.person_id, 'site_visit', COALESCE(v.project_name, 'Site visit'), COALESCE(v.visit_notes, ''), v.visited_at, 'visit'
|
||||
FROM intel_visits v
|
||||
UNION ALL
|
||||
SELECT r.person_id, 'reminder', r.title, COALESCE(r.notes, r.status), COALESCE(r.due_at, r.created_at), 'reminder'
|
||||
FROM intel_reminders r
|
||||
UNION ALL
|
||||
SELECT q.person_id, 'qd_score', q.score_type, COALESCE(q.reasoning, q.current_value::text), q.computed_at, 'qd_score'
|
||||
FROM intel_qd_scores q
|
||||
UNION ALL
|
||||
SELECT qt.person_id, 'qd_timeseries', COALESCE(qt.signal_source, qt.score_type), qt.value::text, qt.timestamp, 'qd_timeseries'
|
||||
FROM intel_qd_timeseries qt
|
||||
)
|
||||
SELECT t.type, t.title, CONCAT(p.full_name, ' - ', t.detail) AS summary,
|
||||
p.full_name AS actor, TO_CHAR(t.event_at, 'YYYY-MM-DD HH24:MI') AS date,
|
||||
t.source_type, t.event_at
|
||||
FROM timeline t
|
||||
JOIN crm_people p ON p.person_id = t.person_id
|
||||
ORDER BY t.event_at DESC NULLS LAST
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "oracle_client_360_summary":
|
||||
sql = """
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(l.status::text, 'unknown') AS lead_status,
|
||||
COALESCE(l.budget_band, '') AS budget_band,
|
||||
COALESCE(l.urgency, '') AS urgency,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score,
|
||||
COUNT(DISTINCT pi.interest_id)::int AS interest_count,
|
||||
COUNT(DISTINCT i.interaction_id)::int AS interaction_count,
|
||||
MAX(i.happened_at) AS last_interaction_at,
|
||||
STRING_AGG(DISTINCT COALESCE(pi.project_name, ip.project_name), ', ' ORDER BY COALESCE(pi.project_name, ip.project_name)) AS projects
|
||||
FROM crm_people p
|
||||
LEFT JOIN crm_leads l ON l.person_id = p.person_id
|
||||
LEFT JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
|
||||
LEFT JOIN intel_interactions i ON i.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, l.status, l.budget_band, l.urgency, q.current_value
|
||||
ORDER BY qd_score DESC, interaction_count DESC, interest_count DESC, name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
468
backend/oracle/natural_db_agent.py
Normal file
468
backend/oracle/natural_db_agent.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
Natural DB-first Oracle agent.
|
||||
|
||||
The LLM can plan arbitrary analytical SELECT statements over the Velocity CRM,
|
||||
intel, inventory, and read-model tables. The executor enforces a read-only SQL
|
||||
contract and a UI row cap; write paths stay behind typed API endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from backend.services.runtime_llm_service import runtime_llm_service
|
||||
|
||||
try:
|
||||
import asyncpg # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
asyncpg = None # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ROW_CAP = 500
|
||||
|
||||
ALLOWED_TABLES = {
|
||||
"crm_people", "crm_leads", "crm_accounts", "crm_households", "crm_relationships",
|
||||
"crm_opportunities", "crm_property_interests", "crm_stage_history",
|
||||
"intel_interactions", "intel_messages", "intel_calls", "intel_transcripts",
|
||||
"intel_emails", "intel_email_threads", "intel_whatsapp_threads", "intel_visits",
|
||||
"intel_reminders", "intel_qd_scores", "intel_qd_timeseries",
|
||||
"intel_extracted_facts", "intel_call_objections", "intel_cctv_links",
|
||||
"intel_perception_events", "intel_vehicle_events",
|
||||
"inventory_projects", "inventory_units",
|
||||
"read_last_contacted", "read_next_best_action",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_SQL = re.compile(
|
||||
r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def db_ready() -> bool:
|
||||
if asyncpg is None:
|
||||
return False
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
return bool(database_url and not database_url.startswith("PLACEHOLDER")) or all(
|
||||
os.getenv(name) for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
|
||||
)
|
||||
|
||||
|
||||
async def connect_db() -> Any:
|
||||
if asyncpg is None:
|
||||
raise RuntimeError("asyncpg is not installed.")
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||
return await asyncpg.connect(database_url)
|
||||
return await asyncpg.connect(
|
||||
host=os.getenv("VELOCITY_DB_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
||||
database=os.environ["VELOCITY_DB_NAME"],
|
||||
user=os.environ["VELOCITY_DB_USER"],
|
||||
password=os.environ["VELOCITY_DB_PASSWORD"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NaturalQueryResult:
|
||||
prompt: str
|
||||
sql: str
|
||||
title: str
|
||||
summary: str
|
||||
columns: list[str]
|
||||
rows: list[dict[str, Any]]
|
||||
row_count: int
|
||||
source_tables: list[str]
|
||||
component_type: str
|
||||
warnings: list[str]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"prompt": self.prompt,
|
||||
"sql": self.sql,
|
||||
"title": self.title,
|
||||
"summary": self.summary,
|
||||
"columns": self.columns,
|
||||
"rows": self.rows,
|
||||
"rowCount": self.row_count,
|
||||
"sourceTables": self.source_tables,
|
||||
"componentType": self.component_type,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
def sanitize_sql(sql: str, row_limit: int) -> tuple[str, list[str], list[str]]:
|
||||
warnings: list[str] = []
|
||||
clean = re.sub(r"--.*?$|/\*.*?\*/", "", sql.strip(), flags=re.MULTILINE | re.DOTALL).strip().rstrip(";")
|
||||
if not re.match(r"^(select|with)\b", clean, re.IGNORECASE):
|
||||
raise ValueError("Oracle SQL agent only accepts SELECT or WITH queries.")
|
||||
if DESTRUCTIVE_SQL.search(clean):
|
||||
raise ValueError("Oracle SQL agent blocked non-read SQL.")
|
||||
tables = []
|
||||
for match in TABLE_REF_RE.finditer(clean):
|
||||
table = match.group(1).split(".")[-1].strip('"').lower()
|
||||
if table in {"lateral", "select"}:
|
||||
continue
|
||||
if table and table not in tables:
|
||||
tables.append(table)
|
||||
blocked = [table for table in tables if table not in ALLOWED_TABLES]
|
||||
if blocked:
|
||||
raise ValueError(f"Oracle SQL agent blocked unknown tables: {', '.join(blocked)}")
|
||||
capped = max(1, min(int(row_limit or 100), MAX_ROW_CAP))
|
||||
if not re.search(r"\blimit\s+\d+\b", clean, re.IGNORECASE):
|
||||
clean = f"SELECT * FROM ({clean}) oracle_limited_rows LIMIT {capped}"
|
||||
warnings.append(f"Applied UI row cap LIMIT {capped}.")
|
||||
return clean, tables, warnings
|
||||
|
||||
|
||||
def infer_component_type(prompt: str, columns: list[str], rows: list[dict[str, Any]]) -> str:
|
||||
lower = prompt.lower()
|
||||
if any(term in lower for term in ("timeline", "conversation", "whatsapp", "message", "call", "email", "history")):
|
||||
return "activity_stream"
|
||||
if len(rows) == 1 and len(columns) <= 5 and any(isinstance(rows[0].get(c), (int, float)) for c in columns):
|
||||
return "kpi_tile"
|
||||
if any(c.endswith("_at") or c in {"date", "when", "timestamp", "happened_at"} for c in columns):
|
||||
if len(rows) > 1 and any(term in lower for term in ("trend", "over time", "timeseries")):
|
||||
return "line_chart"
|
||||
if any(term in lower for term in ("timeline", "activity", "last", "recent")):
|
||||
return "activity_stream"
|
||||
numeric_cols = [c for c in columns if rows and isinstance(rows[0].get(c), (int, float))]
|
||||
if numeric_cols and any(term in lower for term in ("count", "compare", "distribution", "most", "top", "by ")):
|
||||
return "bar_chart"
|
||||
return "table"
|
||||
|
||||
|
||||
def title_from_prompt(prompt: str) -> str:
|
||||
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")
|
||||
return words[:1].upper() + words[1:80] if words else "Oracle Query Result"
|
||||
|
||||
|
||||
class NaturalDbAgent:
|
||||
async def schema_catalog(self, conn: Any | None = None) -> dict[str, Any]:
|
||||
own_conn = conn is None
|
||||
if conn is None:
|
||||
if not db_ready():
|
||||
return {"tables": [], "available": False}
|
||||
conn = await connect_db()
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = 'public' AND c.table_name = ANY($1::text[])
|
||||
ORDER BY c.table_name, c.ordinal_position
|
||||
""",
|
||||
sorted(ALLOWED_TABLES),
|
||||
)
|
||||
counts = {}
|
||||
for table in sorted(ALLOWED_TABLES):
|
||||
exists = await conn.fetchval("SELECT to_regclass($1)", f"public.{table}")
|
||||
counts[table] = None if not exists else int(await conn.fetchval(f"SELECT COUNT(*) FROM {table}"))
|
||||
tables: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])})
|
||||
entry["columns"].append({
|
||||
"name": row["column_name"],
|
||||
"dataType": row["data_type"],
|
||||
"udtName": row["udt_name"],
|
||||
"nullable": row["is_nullable"] == "YES",
|
||||
})
|
||||
return {"available": True, "tables": tables, "allowedTables": sorted(ALLOWED_TABLES)}
|
||||
finally:
|
||||
if own_conn:
|
||||
await conn.close()
|
||||
|
||||
async def data_health(self, conn: Any | None = None) -> dict[str, Any]:
|
||||
catalog = await self.schema_catalog(conn)
|
||||
expected = {
|
||||
"crm_people": 341,
|
||||
"crm_leads": 250,
|
||||
"crm_opportunities": 400,
|
||||
"crm_property_interests": 400,
|
||||
"intel_interactions": 1897,
|
||||
"intel_messages": 6944,
|
||||
"intel_calls": 478,
|
||||
"intel_transcripts": 231,
|
||||
"intel_emails": 149,
|
||||
"intel_visits": 305,
|
||||
"intel_reminders": 759,
|
||||
"intel_extracted_facts": 1686,
|
||||
"read_last_contacted": 250,
|
||||
"read_next_best_action": 250,
|
||||
}
|
||||
tables = catalog.get("tables", {})
|
||||
counts = {table: (tables.get(table) or {}).get("rowCount") for table in sorted(ALLOWED_TABLES)}
|
||||
return {
|
||||
"counts": counts,
|
||||
"expectedSyntheticV2Counts": expected,
|
||||
"missingTables": [t for t, count in counts.items() if count is None],
|
||||
"emptyTables": [t for t, count in counts.items() if count == 0],
|
||||
"belowExpected": {t: {"expected": e, "actual": counts.get(t)} for t, e in expected.items() if (counts.get(t) or 0) < e},
|
||||
}
|
||||
|
||||
async def execute_prompt(self, prompt: str, *, row_limit: int = 100, conn: Any | None = None) -> NaturalQueryResult:
|
||||
if not prompt.strip():
|
||||
raise ValueError("Prompt is required.")
|
||||
own_conn = conn is None
|
||||
if conn is None:
|
||||
if not db_ready():
|
||||
raise RuntimeError("Database unavailable for Oracle natural query.")
|
||||
conn = await connect_db()
|
||||
try:
|
||||
catalog = await self.schema_catalog(conn)
|
||||
plan = await self._plan_sql(prompt, catalog, row_limit)
|
||||
return await self._run_plan(conn, prompt, plan, row_limit)
|
||||
finally:
|
||||
if own_conn:
|
||||
await conn.close()
|
||||
|
||||
async def _run_plan(self, conn: Any, prompt: str, plan: dict[str, Any], row_limit: int) -> NaturalQueryResult:
|
||||
raw_sql = str(plan.get("sql") or "").strip()
|
||||
if not raw_sql:
|
||||
raw_sql = self._fallback_sql(prompt, row_limit)
|
||||
sql, tables, warnings = sanitize_sql(raw_sql, row_limit)
|
||||
try:
|
||||
records = await conn.fetch(sql)
|
||||
except Exception as exc:
|
||||
retry = await self._repair_sql(prompt, raw_sql, str(exc), row_limit)
|
||||
sql, tables, retry_warnings = sanitize_sql(retry, row_limit)
|
||||
warnings.extend(retry_warnings)
|
||||
warnings.append(f"Initial SQL repaired after database error: {exc}")
|
||||
records = await conn.fetch(sql)
|
||||
if not records:
|
||||
retry_sql = self._zero_row_retry_sql(prompt, row_limit, raw_sql)
|
||||
if retry_sql and retry_sql.strip() != raw_sql.strip():
|
||||
retry_clean, retry_tables, retry_warnings = sanitize_sql(retry_sql, row_limit)
|
||||
retry_records = await conn.fetch(retry_clean)
|
||||
if retry_records:
|
||||
sql = retry_clean
|
||||
tables = retry_tables
|
||||
records = retry_records
|
||||
warnings.extend(retry_warnings)
|
||||
warnings.append("Initial SQL returned zero rows; Oracle retried with a broader CRM read query.")
|
||||
rows = [_json_safe(dict(record)) for record in records]
|
||||
columns = list(rows[0].keys()) if rows else []
|
||||
component_type = infer_component_type(prompt, columns, rows)
|
||||
return NaturalQueryResult(
|
||||
prompt=prompt,
|
||||
sql=sql,
|
||||
title=str(plan.get("title") or title_from_prompt(prompt)),
|
||||
summary=str(plan.get("rationale") or f"SQL-backed Oracle result from {', '.join(tables) or 'Velocity CRM'}."),
|
||||
columns=columns,
|
||||
rows=rows,
|
||||
row_count=len(rows),
|
||||
source_tables=tables,
|
||||
component_type=component_type,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
async def _plan_sql(self, prompt: str, catalog: dict[str, Any], row_limit: int) -> dict[str, Any]:
|
||||
fallback = {"sql": self._fallback_sql(prompt, row_limit), "title": title_from_prompt(prompt), "rationale": "Deterministic SQL planner fallback."}
|
||||
try:
|
||||
providers = runtime_llm_service._provider_catalog()
|
||||
except Exception:
|
||||
providers = {}
|
||||
if not providers:
|
||||
return fallback
|
||||
schema_brief = json.dumps(catalog.get("tables", {}), default=str)[:16000]
|
||||
system = (
|
||||
"You are Oracle's read-only PostgreSQL planner. Generate one useful SELECT or WITH query "
|
||||
"for the user's CRM question. Use only the provided schema. Return JSON with sql, title, rationale. "
|
||||
"Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements."
|
||||
)
|
||||
try:
|
||||
response = await runtime_llm_service.chat(
|
||||
provider_id="sglang",
|
||||
model=None,
|
||||
system_prompt=system,
|
||||
messages=[{"role": "user", "content": f"Schema:\n{schema_brief}\n\nQuestion:\n{prompt}\n\nRow cap: {row_limit}"}],
|
||||
temperature=0.05,
|
||||
response_format="json",
|
||||
metadata={"agent": "oracle_natural_db_agent"},
|
||||
)
|
||||
message = response.get("message") or {}
|
||||
parsed = message.get("parsedJson")
|
||||
content = message.get("content") or "{}"
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = json.loads(content) if isinstance(content, str) else content
|
||||
if isinstance(parsed, dict) and parsed.get("sql"):
|
||||
return parsed
|
||||
except Exception as exc:
|
||||
logger.warning("Natural DB planner LLM failed, using fallback: %s", exc)
|
||||
return fallback
|
||||
|
||||
async def _repair_sql(self, prompt: str, failed_sql: str, error: str, row_limit: int) -> str:
|
||||
# Keep retry operationally deterministic if model is unavailable.
|
||||
if "read_last_contacted" in failed_sql and "does not exist" in error.lower():
|
||||
return self._base_last_contacted_sql(row_limit)
|
||||
if "read_next_best_action" in failed_sql and "does not exist" in error.lower():
|
||||
return self._base_last_contacted_sql(row_limit)
|
||||
return self._fallback_sql(prompt, row_limit)
|
||||
|
||||
def _zero_row_retry_sql(self, prompt: str, row_limit: int, previous_sql: str) -> str | None:
|
||||
lower = prompt.lower()
|
||||
if any(term in lower for term in ("contact", "recent", "last", "call", "message", "email", "whatsapp", "follow")):
|
||||
return self._base_last_contacted_sql(row_limit)
|
||||
if any(term in lower for term in ("interest", "interested", "property", "project", "unit", "budget", "bhk")):
|
||||
return self._base_property_interest_sql(row_limit)
|
||||
if "from crm_people" not in previous_sql.lower():
|
||||
return self._generic_clients_sql(row_limit)
|
||||
return None
|
||||
|
||||
def _base_last_contacted_sql(self, row_limit: int) -> str:
|
||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
||||
return f"""
|
||||
WITH contact_events AS (
|
||||
SELECT i.person_id, i.happened_at AS event_at, i.channel::text AS channel,
|
||||
i.interaction_type AS event_type, i.summary AS summary, i.broker_name AS actor
|
||||
FROM intel_interactions i
|
||||
WHERE i.happened_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT i.person_id, m.delivered_at, 'message', COALESCE(m.sender_role, 'message'), m.message_text, m.sender_name
|
||||
FROM intel_messages m
|
||||
JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
||||
WHERE m.delivered_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT i.person_id, e.sent_at, 'email', COALESCE(e.direction::text, 'email'), e.subject, e.from_address
|
||||
FROM intel_emails e
|
||||
JOIN intel_interactions i ON i.interaction_id = e.interaction_id
|
||||
WHERE e.sent_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT v.person_id, v.visited_at, 'site_visit', 'visit', v.outcome, v.hosted_by
|
||||
FROM intel_visits v
|
||||
WHERE v.visited_at IS NOT NULL
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *, row_number() OVER (PARTITION BY person_id ORDER BY event_at DESC) AS rn,
|
||||
count(*) OVER (PARTITION BY person_id) AS interaction_count
|
||||
FROM contact_events
|
||||
)
|
||||
SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone,
|
||||
p.primary_email AS email, r.event_at AS last_contacted_at,
|
||||
r.channel AS last_contact_channel, r.event_type AS last_interaction_type,
|
||||
r.summary AS last_contact_summary, r.actor AS last_contact_actor,
|
||||
r.interaction_count::int,
|
||||
q.current_value AS qd_score
|
||||
FROM ranked r
|
||||
JOIN crm_people p ON p.person_id = r.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.current_value DESC, q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
WHERE r.rn = 1
|
||||
ORDER BY r.event_at DESC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
|
||||
def _base_property_interest_sql(self, row_limit: int) -> str:
|
||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
||||
return f"""
|
||||
SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone, p.primary_email AS email,
|
||||
COUNT(pi.interest_id)::int AS interest_count,
|
||||
string_agg(DISTINCT COALESCE(pi.project_name, pr.project_name), ', ') AS projects,
|
||||
string_agg(DISTINCT pi.configuration, ', ') AS configurations,
|
||||
MIN(pi.budget_min) AS budget_min, MAX(pi.budget_max) AS budget_max,
|
||||
MAX(pi.last_discussed_at) AS last_interest_at,
|
||||
MAX(q.current_value) AS qd_score
|
||||
FROM crm_people p
|
||||
JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN inventory_projects pr ON pr.project_id = pi.project_id
|
||||
LEFT JOIN intel_qd_scores q ON q.person_id = p.person_id
|
||||
GROUP BY p.person_id, p.full_name, p.primary_phone, p.primary_email
|
||||
HAVING COUNT(pi.interest_id) > 0
|
||||
ORDER BY interest_count DESC, qd_score DESC NULLS LAST, last_interest_at DESC NULLS LAST
|
||||
LIMIT {limit}
|
||||
"""
|
||||
|
||||
def _generic_clients_sql(self, row_limit: int) -> str:
|
||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
||||
return f"""
|
||||
SELECT p.person_id::text, p.full_name AS name, p.primary_email AS email, p.primary_phone AS phone,
|
||||
p.buyer_type, l.status::text AS lead_status, l.budget_band, l.urgency,
|
||||
q.current_value AS qd_score
|
||||
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 LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.current_value DESC, q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
ORDER BY qd_score DESC NULLS LAST, p.full_name ASC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
|
||||
def _fallback_sql(self, prompt: str, row_limit: int) -> str:
|
||||
lower = prompt.lower()
|
||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
||||
if "objection" in lower:
|
||||
return f"""
|
||||
SELECT p.person_id::text, p.full_name AS name, co.objection_type, co.category, co.severity,
|
||||
co.client_quote, co.agent_response, co.extracted_at
|
||||
FROM intel_call_objections co
|
||||
JOIN intel_calls c ON c.call_id = co.call_id
|
||||
JOIN intel_interactions i ON i.interaction_id = c.interaction_id
|
||||
JOIN crm_people p ON p.person_id = i.person_id
|
||||
ORDER BY co.extracted_at DESC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
if "whatsapp" in lower or "message" in lower or "conversation" in lower:
|
||||
return f"""
|
||||
SELECT p.person_id::text, p.full_name AS name, 'whatsapp' AS type,
|
||||
m.message_text AS summary, m.sender_role AS actor, m.delivered_at AS date
|
||||
FROM intel_messages m
|
||||
JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
||||
JOIN crm_people p ON p.person_id = i.person_id
|
||||
WHERE lower(m.message_text) LIKE '%' || lower(split_part($${prompt}$$, ' ', 1)) || '%'
|
||||
OR i.channel = 'whatsapp'
|
||||
ORDER BY m.delivered_at DESC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
if "contact" in lower or "recent" in lower or "last" in lower:
|
||||
return f"""
|
||||
SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone,
|
||||
lc.last_contact_at AS last_contacted_at, lc.last_channel AS last_contact_channel,
|
||||
lc.last_interaction_type, lc.days_since_contact, lc.total_interactions AS interaction_count,
|
||||
nba.recommended_action AS next_action, q.current_value AS qd_score
|
||||
FROM crm_people p
|
||||
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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.current_value DESC, q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
WHERE lc.last_contact_at IS NOT NULL
|
||||
ORDER BY lc.last_contact_at DESC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
if "4 bhk" in lower or "budget" in lower or "interest" in lower or "property" in lower or "client" in lower:
|
||||
return self._base_property_interest_sql(limit)
|
||||
return self._generic_clients_sql(limit)
|
||||
|
||||
|
||||
natural_db_agent = NaturalDbAgent()
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -18,6 +19,7 @@ from .canvas_service import canvas_service
|
||||
from .data_access_gateway import data_access_gateway
|
||||
from .persona_service import persona_service
|
||||
from .codebook_service import codebook_service, CodebookExample
|
||||
from .natural_db_agent import natural_db_agent
|
||||
from backend.services.runtime_llm_service import runtime_llm_service
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
|
||||
@@ -107,7 +109,7 @@ def _build_demo_retrieval_plan(
|
||||
Produces a valid retrieval plan that passes policy validation.
|
||||
"""
|
||||
component_types = _detect_component_types(prompt)
|
||||
row_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
||||
row_limit = _parse_prompt_row_limit(prompt, actor_role)
|
||||
|
||||
return {
|
||||
"planId": str(uuid.uuid4()),
|
||||
@@ -130,12 +132,12 @@ def _build_demo_retrieval_plan(
|
||||
|
||||
_DATASET_MAP: dict[str, str] = {
|
||||
"pipeline_board": "crm_opportunity_pipeline",
|
||||
"bar_chart": "crm_property_interest_rollup",
|
||||
"bar_chart": "oracle_property_interest_rollup",
|
||||
"geo_map": "lead_geo_interest_rollup",
|
||||
"table": "crm_contacts_overview",
|
||||
"line_chart": "crm_property_interest_rollup",
|
||||
"line_chart": "oracle_property_interest_rollup",
|
||||
"kpi_tile": "oracle_aggregated_metric",
|
||||
"activity_stream": "crm_interaction_timeline",
|
||||
"activity_stream": "oracle_client_interaction_timeline",
|
||||
}
|
||||
|
||||
_CODEBOOK_COMPONENT_MAP: dict[str, str] = {
|
||||
@@ -164,34 +166,85 @@ def _component_plan_type_from_codebook(example: CodebookExample) -> str:
|
||||
return _CODEBOOK_COMPONENT_MAP.get(example.component_type, "table")
|
||||
|
||||
|
||||
def _parse_prompt_row_limit(prompt: str, actor_role: str) -> int:
|
||||
default_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
||||
match = re.search(r"\b(?:top|last|latest|recent|first|show|name of the last)\s+(\d{1,4})\b", prompt.lower())
|
||||
if not match:
|
||||
return default_limit
|
||||
requested = max(1, int(match.group(1)))
|
||||
return min(requested, default_limit)
|
||||
|
||||
|
||||
def _prompt_data_intent(prompt: str) -> str | None:
|
||||
lowered = prompt.lower()
|
||||
contact_terms = (
|
||||
"last contacted", "last contact", "last contacted us", "recently contacted",
|
||||
"recent contacts", "last call", "last called", "last message", "last messaged",
|
||||
"last whatsapp", "who contacted us", "contacted us", "contacted clients",
|
||||
"client contacted", "clients contacted", "follow-up", "follow up",
|
||||
)
|
||||
interest_terms = (
|
||||
"shown interest", "showed interest", "interested clients", "interested client",
|
||||
"property interest", "project interest", "interested in any", "interest in any",
|
||||
"interested in our properties", "interested in properties",
|
||||
)
|
||||
timeline_terms = (
|
||||
"conversation", "timeline", "whatsapp", "messages", "message history",
|
||||
"call history", "transcript", "email", "visit history", "interaction history",
|
||||
)
|
||||
client_360_terms = ("client 360", "client dossier", "highest intent buyer", "client profile")
|
||||
if any(term in lowered for term in contact_terms) or re.search(r"\blast\s+\d+\s+contacted\b", lowered):
|
||||
return "last_contacted"
|
||||
if any(term in lowered for term in interest_terms) or (
|
||||
any(term in lowered for term in ("interest", "interested", "project", "property", "properties"))
|
||||
and any(term in lowered for term in ("client", "clients", "contact", "contacts"))
|
||||
):
|
||||
return "interested_clients"
|
||||
if any(term in lowered for term in client_360_terms):
|
||||
return "client_360"
|
||||
if any(term in lowered for term in timeline_terms):
|
||||
return "timeline"
|
||||
return None
|
||||
|
||||
|
||||
def _dataset_for_codebook(example: CodebookExample, prompt: str, component_plan_type: str | None = None) -> str:
|
||||
chapter = example.chapter_name.lower()
|
||||
subchapter = example.subchapter_name.lower()
|
||||
component_plan_type = component_plan_type or _component_plan_type_from_codebook(example)
|
||||
lowered_prompt = prompt.lower()
|
||||
data_intent = _prompt_data_intent(prompt)
|
||||
|
||||
if data_intent == "last_contacted":
|
||||
return "oracle_last_contacted_clients" if component_plan_type != "activity_stream" else "oracle_client_interaction_timeline"
|
||||
if data_intent == "interested_clients":
|
||||
return "oracle_top_interested_clients" if component_plan_type == "table" else "oracle_property_interest_rollup"
|
||||
if data_intent == "client_360":
|
||||
return "oracle_client_360_summary"
|
||||
if data_intent == "timeline":
|
||||
return "oracle_client_interaction_timeline"
|
||||
|
||||
if component_plan_type == "activity_stream":
|
||||
return "crm_interaction_timeline"
|
||||
return "oracle_client_interaction_timeline"
|
||||
if component_plan_type == "pipeline_board":
|
||||
return "crm_opportunity_pipeline"
|
||||
if component_plan_type == "table" and any(term in lowered_prompt for term in ("last interacted", "last interaction", "recently contacted", "recent interaction")):
|
||||
return "crm_last_interacted_clients"
|
||||
return "oracle_last_contacted_clients"
|
||||
if component_plan_type == "table" and any(term in lowered_prompt for term in ("interest", "interested", "project", "property", "properties")) and any(term in lowered_prompt for term in ("client", "clients", "contact", "contacts")):
|
||||
return "crm_top_interested_clients"
|
||||
return "oracle_top_interested_clients"
|
||||
if component_plan_type == "line_chart" and any(term in lowered_prompt for term in ("trend", "time", "history", "growth")):
|
||||
return "crm_property_interest_rollup"
|
||||
return "oracle_property_interest_rollup"
|
||||
|
||||
if any(term in lowered_prompt for term in ("contact", "client 360", "crm", "account", "lead")):
|
||||
if "timeline" in lowered_prompt or "message" in lowered_prompt or "call" in lowered_prompt or "email" in lowered_prompt:
|
||||
return "crm_interaction_timeline"
|
||||
return "oracle_client_interaction_timeline"
|
||||
if "pipeline" in lowered_prompt or "opportunit" in lowered_prompt:
|
||||
return "crm_opportunity_pipeline"
|
||||
if ("interest" in lowered_prompt or "project" in lowered_prompt or "property" in lowered_prompt) and ("client" in lowered_prompt or "contact" in lowered_prompt):
|
||||
return "crm_top_interested_clients"
|
||||
return "oracle_top_interested_clients"
|
||||
if "interest" in lowered_prompt or "project" in lowered_prompt or "property" in lowered_prompt:
|
||||
return "crm_property_interest_rollup"
|
||||
return "oracle_property_interest_rollup"
|
||||
if "last interacted" in lowered_prompt or "recently contacted" in lowered_prompt or "recent interaction" in lowered_prompt:
|
||||
return "crm_last_interacted_clients"
|
||||
return "oracle_last_contacted_clients"
|
||||
return "crm_contacts_overview"
|
||||
|
||||
if "client" in chapter or "client" in subchapter or "contact" in subchapter:
|
||||
@@ -199,9 +252,9 @@ def _dataset_for_codebook(example: CodebookExample, prompt: str, component_plan_
|
||||
if "opportun" in chapter or "pipeline" in subchapter:
|
||||
return "crm_opportunity_pipeline"
|
||||
if "interaction" in chapter or "communication" in chapter or "timeline" in subchapter:
|
||||
return "crm_interaction_timeline"
|
||||
return "oracle_client_interaction_timeline"
|
||||
if "property" in chapter or "inventory" in chapter or "interest" in subchapter:
|
||||
return "crm_property_interest_rollup"
|
||||
return "oracle_property_interest_rollup"
|
||||
return _DATASET_MAP.get(component_plan_type, "oracle_aggregated_metric")
|
||||
|
||||
|
||||
@@ -211,7 +264,7 @@ def _build_codebook_retrieval_plan(
|
||||
actor_role: str,
|
||||
matches: list[CodebookExample],
|
||||
) -> dict[str, Any]:
|
||||
row_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
||||
row_limit = _parse_prompt_row_limit(prompt, actor_role)
|
||||
desired_types = _detect_component_types(prompt)
|
||||
if not desired_types:
|
||||
desired_types = [_component_plan_type_from_codebook(matches[0])] if matches else ["table"]
|
||||
@@ -265,12 +318,17 @@ def _title_for_dataset(dataset: str, component_plan_type: str, prompt: str) -> s
|
||||
"crm_interaction_timeline": "Client Interaction Timeline",
|
||||
"crm_last_interacted_clients": "Last Interacted Clients",
|
||||
"crm_top_interested_clients": "Top Interested Clients",
|
||||
"oracle_property_interest_rollup": "Property Interest Rollup",
|
||||
"oracle_client_interaction_timeline": "Client Interaction Timeline",
|
||||
"oracle_last_contacted_clients": "Last Contacted Clients",
|
||||
"oracle_top_interested_clients": "Top Interested Clients",
|
||||
"oracle_client_360_summary": "Client 360 Summary",
|
||||
"broker_performance": "Broker Performance",
|
||||
}
|
||||
if dataset == "crm_top_interested_clients" and "top" in lowered_prompt:
|
||||
if dataset in {"crm_top_interested_clients", "oracle_top_interested_clients"} and "top" in lowered_prompt:
|
||||
return "Top Interested Clients"
|
||||
if dataset == "crm_last_interacted_clients" and ("top" in lowered_prompt or "last" in lowered_prompt):
|
||||
return "Last Interacted Clients"
|
||||
if dataset in {"crm_last_interacted_clients", "oracle_last_contacted_clients"} and ("top" in lowered_prompt or "last" in lowered_prompt):
|
||||
return "Last Contacted Clients"
|
||||
return dataset_titles.get(dataset)
|
||||
|
||||
|
||||
@@ -288,6 +346,11 @@ _RUNTIME_ALLOWED_DATASETS = {
|
||||
"crm_interaction_timeline",
|
||||
"crm_last_interacted_clients",
|
||||
"crm_top_interested_clients",
|
||||
"oracle_property_interest_rollup",
|
||||
"oracle_client_interaction_timeline",
|
||||
"oracle_last_contacted_clients",
|
||||
"oracle_top_interested_clients",
|
||||
"oracle_client_360_summary",
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +411,64 @@ class PromptOrchestrator:
|
||||
await self._persist_execution(execution)
|
||||
|
||||
# ── Step 1: Build retrieval plan ──────────────────────────────────────
|
||||
page = await canvas_service.get_page(page_id, tenant_id)
|
||||
existing_comps = page.get("components", []) if page else []
|
||||
next_order_base = self._next_order_base(existing_comps)
|
||||
section_id = f"sec_prompt_generated_{execution_id.replace('-', '')[:12]}"
|
||||
|
||||
natural_result = None
|
||||
try:
|
||||
natural_result = await natural_db_agent.execute_prompt(
|
||||
prompt,
|
||||
row_limit=_parse_prompt_row_limit(prompt, actor_role),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("ORCH natural DB agent unavailable, falling back to component planner: %s", exc)
|
||||
warnings.append(f"Natural DB agent unavailable ({exc}); using component planner fallback.")
|
||||
|
||||
if natural_result is not None:
|
||||
execution["status"] = "executing"
|
||||
execution["retrievalPlan"] = {
|
||||
"planId": str(uuid.uuid4()),
|
||||
"planner": "oracle_natural_db_agent",
|
||||
"sql": natural_result.sql,
|
||||
"sourceTables": natural_result.source_tables,
|
||||
"rowCount": natural_result.row_count,
|
||||
}
|
||||
viz_plan = self._build_natural_visualization_plan(
|
||||
result=natural_result.as_dict(),
|
||||
prompt=prompt,
|
||||
execution_id=execution_id,
|
||||
actor_id=actor_id,
|
||||
branch_id=branch_id,
|
||||
base_order=next_order_base,
|
||||
section_id=section_id,
|
||||
)
|
||||
execution["visualizationPlan"] = viz_plan
|
||||
execution["componentsCreated"] = [c["componentId"] for c in viz_plan.get("components", [])]
|
||||
try:
|
||||
if page:
|
||||
revision = await canvas_service.commit_revision(
|
||||
page_id=page_id,
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
commit_kind="prompt",
|
||||
commit_summary=f"Oracle: {prompt[:80]}",
|
||||
components=existing_comps + viz_plan.get("components", []),
|
||||
execution_id=execution_id,
|
||||
idempotency_key=client_request_id,
|
||||
)
|
||||
execution["headRevision"] = revision["revisionNumber"]
|
||||
except Exception as exc:
|
||||
logger.warning("ORCH natural revision_commit failed (non-fatal): %s", exc)
|
||||
warnings.append("Revision commit deferred; will retry on next sync.")
|
||||
execution["status"] = "completed"
|
||||
execution["summary"] = self._generate_summary(prompt, viz_plan)
|
||||
execution["completedAt"] = _now()
|
||||
execution["warnings"] = warnings + natural_result.warnings
|
||||
await self._persist_execution(execution)
|
||||
return execution
|
||||
|
||||
codebook_matches = codebook_service.search_examples(prompt, limit=4)
|
||||
execution["codebookMatches"] = [
|
||||
{
|
||||
@@ -580,6 +701,92 @@ class PromptOrchestrator:
|
||||
|
||||
return {"components": components}
|
||||
|
||||
def _build_natural_visualization_plan(
|
||||
self,
|
||||
*,
|
||||
result: dict[str, Any],
|
||||
prompt: str,
|
||||
execution_id: str,
|
||||
actor_id: str,
|
||||
branch_id: str,
|
||||
base_order: int,
|
||||
section_id: str,
|
||||
) -> dict[str, Any]:
|
||||
rows = result.get("rows") or []
|
||||
columns = result.get("columns") or (list(rows[0].keys()) if rows else [])
|
||||
ctype = str(result.get("componentType") or "table")
|
||||
mapped_type = self._map_type(ctype)
|
||||
dataset = "oracle_natural_sql"
|
||||
component_id = str(uuid.uuid4())
|
||||
comp: dict[str, Any] = {
|
||||
"componentId": component_id,
|
||||
"type": mapped_type,
|
||||
"title": result.get("title") or self._generate_title(prompt, ctype),
|
||||
"description": f"SQL-backed Oracle result from: \"{prompt[:96]}\"",
|
||||
"dataSourceDescriptor": {
|
||||
"descriptorId": str(uuid.uuid4()),
|
||||
"sourceType": "postgres",
|
||||
"connectorId": "velocity-core-postgres",
|
||||
"dataset": dataset,
|
||||
"authContextRef": f"authctx_{actor_id}_scope",
|
||||
"queryTemplate": result.get("sql", ""),
|
||||
"queryParameters": {},
|
||||
"rowLimit": len(rows),
|
||||
"privacyTier": "standard",
|
||||
"cachePolicy": {"mode": "revision_scoped"},
|
||||
},
|
||||
"visualizationParameters": {
|
||||
**self._default_viz_params(ctype, dataset, rows),
|
||||
"columns": columns,
|
||||
"sqlSummary": result.get("summary"),
|
||||
"sourceTables": result.get("sourceTables", []),
|
||||
"rowCount": result.get("rowCount", len(rows)),
|
||||
},
|
||||
"dataBindings": self._default_bindings(ctype),
|
||||
"version": 1,
|
||||
"lifecycleState": "active",
|
||||
"provenance": {
|
||||
"originType": "prompt_generated",
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _iso(_now()),
|
||||
"sourceTables": result.get("sourceTables", []),
|
||||
"sqlSummary": result.get("summary"),
|
||||
},
|
||||
"renderingHints": self._rendering_hints(ctype),
|
||||
"layout": {
|
||||
"orderIndex": base_order + 100,
|
||||
"sectionId": section_id,
|
||||
"widthMode": "full" if mapped_type in ("table", "pipelineBoard", "timeline", "activityStream") else "half",
|
||||
"minHeightPx": 320,
|
||||
"stickyHeader": False,
|
||||
},
|
||||
"accessControls": {
|
||||
"visibilityScope": "private",
|
||||
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
|
||||
"redactionPolicy": "none",
|
||||
},
|
||||
"styleSignature": {
|
||||
"theme": "velocity_glass",
|
||||
"paletteToken": "ocean_signal",
|
||||
"motionProfile": "calm_reveal",
|
||||
"density": "comfortable",
|
||||
"radiusScale": "lg",
|
||||
"typographyScale": "balanced",
|
||||
},
|
||||
"validationState": {
|
||||
"schema": "pass",
|
||||
"policy": "pass",
|
||||
"a11y": "pass",
|
||||
"performance": "pass",
|
||||
"status": "validated",
|
||||
},
|
||||
"auditLog": [f"aud_{execution_id}_natural_sql"],
|
||||
"dataRows": rows,
|
||||
}
|
||||
return {"components": [comp]}
|
||||
|
||||
@staticmethod
|
||||
def _next_order_base(existing_components: list[dict[str, Any]]) -> int:
|
||||
max_existing = 0
|
||||
@@ -706,6 +913,9 @@ class PromptOrchestrator:
|
||||
"crm_contacts_overview": ["name", "email", "phone", "city", "buyer_type", "qd_score"],
|
||||
"crm_last_interacted_clients": ["name", "email", "phone", "last_interaction_at", "interaction_count", "qd_score"],
|
||||
"crm_top_interested_clients": ["name", "email", "phone", "interest_count", "projects", "qd_score"],
|
||||
"oracle_last_contacted_clients": ["name", "phone", "last_contacted_at", "last_contact_channel", "last_contact_summary", "interaction_count", "qd_score", "next_action"],
|
||||
"oracle_top_interested_clients": ["name", "phone", "interest_count", "projects", "last_interest_at", "qd_score"],
|
||||
"oracle_client_360_summary": ["name", "phone", "lead_status", "budget_band", "urgency", "qd_score", "interest_count", "interaction_count", "projects"],
|
||||
}
|
||||
defaults: dict[str, dict[str, Any]] = {
|
||||
"bar_chart": {"xAxis": "category", "yAxis": "value", "sort": "desc", "showLabels": True, "legend": False},
|
||||
@@ -847,7 +1057,7 @@ class PromptOrchestrator:
|
||||
Uses the shared runtime LLM service to propose a retrieval plan.
|
||||
Raises on malformed output so the orchestrator can fall back safely.
|
||||
"""
|
||||
row_limit = 50 if ctx.actor_role in ("senior_broker", "junior_broker") else 200
|
||||
row_limit = _parse_prompt_row_limit(prompt, ctx.actor_role)
|
||||
system_prompt = (
|
||||
"You are the Oracle planner for Project Velocity. "
|
||||
"Return JSON only. "
|
||||
@@ -855,7 +1065,9 @@ class PromptOrchestrator:
|
||||
"Allowed component types: pipeline_board, bar_chart, geo_map, table, line_chart, kpi_tile, activity_stream. "
|
||||
"Allowed datasets: deals, lead_daily_snapshot, lead_geo_interest_rollup, broker_performance, inventory_absorption, "
|
||||
"oracle_aggregated_metric, lead_activity_log, crm_contacts_overview, crm_opportunity_pipeline, "
|
||||
"crm_property_interest_rollup, crm_interaction_timeline. "
|
||||
"crm_property_interest_rollup, crm_interaction_timeline, crm_last_interacted_clients, crm_top_interested_clients, "
|
||||
"oracle_property_interest_rollup, oracle_client_interaction_timeline, oracle_last_contacted_clients, "
|
||||
"oracle_top_interested_clients, oracle_client_360_summary. "
|
||||
"Return an object with keys semanticModelVersion, intentClass, components. "
|
||||
"Each component must include suggestedType, dataset, and titleHint. "
|
||||
"Do not emit SQL. Do not invent datasets outside the allowlist."
|
||||
|
||||
@@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS oracle_canvas_components (
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
lifecycle_state TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle_state IN ('draft','active','superseded','archived','revoked')),
|
||||
data_source_descriptor JSONB NOT NULL,
|
||||
data_rows JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
visualization_parameters JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
data_bindings JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
provenance JSONB NOT NULL,
|
||||
@@ -63,6 +64,35 @@ CREATE TABLE IF NOT EXISTS oracle_canvas_components (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE oracle_canvas_components
|
||||
ADD COLUMN IF NOT EXISTS data_rows JSONB NOT NULL DEFAULT '[]'::JSONB;
|
||||
|
||||
WITH latest_revisions AS (
|
||||
SELECT DISTINCT ON (page_id, tenant_id)
|
||||
page_id,
|
||||
tenant_id,
|
||||
components_snapshot
|
||||
FROM oracle_canvas_page_revisions
|
||||
ORDER BY page_id, tenant_id, revision_number DESC
|
||||
),
|
||||
snapshot_components AS (
|
||||
SELECT
|
||||
latest_revisions.page_id,
|
||||
latest_revisions.tenant_id,
|
||||
component->>'componentId' AS component_id,
|
||||
COALESCE(component->'dataRows', '[]'::jsonb) AS data_rows
|
||||
FROM latest_revisions,
|
||||
jsonb_array_elements(latest_revisions.components_snapshot) AS component
|
||||
)
|
||||
UPDATE oracle_canvas_components occ
|
||||
SET data_rows = snapshot_components.data_rows
|
||||
FROM snapshot_components
|
||||
WHERE occ.page_id = snapshot_components.page_id
|
||||
AND occ.tenant_id = snapshot_components.tenant_id
|
||||
AND occ.component_id::text = snapshot_components.component_id
|
||||
AND occ.data_rows = '[]'::jsonb
|
||||
AND snapshot_components.data_rows <> '[]'::jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_prompt_executions (
|
||||
execution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user