feat: New Chat, Search Chat and Master Slave DB Architecture for CRM and Oracle Canvas

This commit is contained in:
Sagnik
2026-04-24 04:18:33 +05:30
parent f04571bd7b
commit eabecf7a25
7 changed files with 1117 additions and 520 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ export interface OracleExecutionState {
}) => void; }) => void;
}) => Promise<void>; }) => Promise<void>;
clearError: () => void; clearError: () => void;
resetHistory: () => void;
} }
export function useOracleExecution(): OracleExecutionState { export function useOracleExecution(): OracleExecutionState {
@@ -126,5 +127,12 @@ export function useOracleExecution(): OracleExecutionState {
[], [],
); );
return { history, inFlight, lastError, submit, clearError: () => setLastError(null) }; return {
history,
inFlight,
lastError,
submit,
clearError: () => setLastError(null),
resetHistory: () => setHistory([]),
};
} }

View File

@@ -81,6 +81,32 @@ export async function fetchCanvasPage(pageId: string): Promise<CanvasPage> {
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`); return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`);
} }
export async function listCanvasPages(search?: string): Promise<CanvasPage[]> {
const qs = new URLSearchParams();
if (search?.trim()) qs.set('search', search.trim());
return apiFetch<CanvasPage[]>(`/canvas-pages${qs.toString() ? `?${qs.toString()}` : ''}`);
}
export async function createCanvasPage(title = 'Untitled Canvas'): Promise<CanvasPage> {
return apiFetch<CanvasPage>('/canvas-pages', {
method: 'POST',
body: JSON.stringify({ title }),
});
}
export async function renameCanvasPage(pageId: string, title: string): Promise<CanvasPage> {
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
});
}
export async function deleteCanvasPage(pageId: string): Promise<{ pageId: string; deleted: boolean }> {
return apiFetch<{ pageId: string; deleted: boolean }>(`/canvas-pages/${pageId}`, {
method: 'DELETE',
});
}
export async function submitPrompt( export async function submitPrompt(
pageId: string, pageId: string,
payload: PromptSubmitRequest, payload: PromptSubmitRequest,

View File

@@ -86,6 +86,8 @@ def _json_array(value: Any) -> list[Any]:
def _json_safe(value: Any) -> Any: def _json_safe(value: Any) -> Any:
if isinstance(value, datetime): if isinstance(value, datetime):
return value.isoformat() return value.isoformat()
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, dict): if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()} return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, list): if isinstance(value, list):
@@ -173,6 +175,54 @@ def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[st
class CanvasService: class CanvasService:
async def list_pages(
self,
*,
tenant_id: str,
owner_id: str,
search: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
_ensure_ready()
safe_limit = max(1, min(limit, 100))
search_term = (search or "").strip().lower()
if _is_demo():
candidates = [
page
for page in _DEMO_PAGES.values()
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id
]
if search_term:
candidates = [page for page in candidates if search_term in page.get("title", "").lower()]
candidates.sort(key=lambda page: page.get("updatedAt", ""), reverse=True)
return [{**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))} for page in candidates[:safe_limit]]
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
rows = await conn.fetch(
"""
SELECT *
FROM oracle_canvas_pages
WHERE tenant_id = $1
AND owner_id = $2
AND ($3 = '' OR lower(title) LIKE '%' || $3 || '%')
ORDER BY updated_at DESC, created_at DESC
LIMIT $4
""",
tenant_id,
owner_id,
search_term,
safe_limit,
)
pages: list[dict[str, Any]] = []
for row in rows:
components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id)
pages.append(_deserialize_page_row(row, components))
return pages
finally:
await conn.close()
async def create_page( async def create_page(
self, self,
*, *,
@@ -310,6 +360,80 @@ class CanvasService:
finally: finally:
await conn.close() await conn.close()
async def update_page_title(
self,
*,
page_id: str,
tenant_id: str,
owner_id: str,
title: str,
) -> dict[str, Any]:
_ensure_ready()
clean_title = (title or "").strip() or "Untitled Canvas"
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
page["title"] = clean_title
page["updatedAt"] = _now()
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))}
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
UPDATE oracle_canvas_pages
SET title = $4, updated_at = NOW()
WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3
RETURNING *
""",
page_id,
tenant_id,
owner_id,
clean_title,
)
if not row:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
components = await self._pg_fetch_components(conn, page_id, tenant_id)
return _deserialize_page_row(row, components)
finally:
await conn.close()
async def delete_page(
self,
*,
page_id: str,
tenant_id: str,
owner_id: str,
) -> None:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
del _DEMO_PAGES[page_id]
_DEMO_COMPONENTS.pop(page_id, None)
_DEMO_REVISIONS.pop(page_id, None)
return
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
result = await conn.execute(
"""
DELETE FROM oracle_canvas_pages
WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3
""",
page_id,
tenant_id,
owner_id,
)
if result.endswith("0"):
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
finally:
await conn.close()
async def commit_revision( async def commit_revision(
self, self,
*, *,

View File

@@ -1,9 +1,9 @@
""" """
Natural DB-first Oracle agent. Natural DB-first Oracle agent.
The LLM can plan arbitrary analytical SELECT statements over the Velocity CRM, The LLM can plan arbitrary analytical SELECT statements over the full public
intel, inventory, and read-model tables. The executor enforces a read-only SQL Velocity app schema. The executor enforces only a read-only SQL contract and a
contract and a UI row cap; write paths stay behind typed API endpoints. UI row cap; write paths stay behind typed API endpoints.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,25 +25,12 @@ except Exception: # pragma: no cover
logger = logging.getLogger(__name__) 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( DESTRUCTIVE_SQL = re.compile(
r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b", r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b",
re.IGNORECASE, re.IGNORECASE,
) )
TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE) TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE)
CTE_NAME_RE = re.compile(r"\b(?:with|,)\s*([a-zA-Z_][\w]*)\s+as\s*\(", re.IGNORECASE)
def _json_safe(value: Any) -> Any: def _json_safe(value: Any) -> Any:
@@ -61,6 +48,9 @@ def _json_safe(value: Any) -> Any:
def db_ready() -> bool: def db_ready() -> bool:
if asyncpg is None: if asyncpg is None:
return False return False
read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "")
if read_database_url and not read_database_url.startswith("PLACEHOLDER"):
return True
database_url = os.getenv("DATABASE_URL", "") database_url = os.getenv("DATABASE_URL", "")
return bool(database_url and not database_url.startswith("PLACEHOLDER")) or all( 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") os.getenv(name) for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
@@ -70,6 +60,17 @@ def db_ready() -> bool:
async def connect_db() -> Any: async def connect_db() -> Any:
if asyncpg is None: if asyncpg is None:
raise RuntimeError("asyncpg is not installed.") raise RuntimeError("asyncpg is not installed.")
read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "")
if read_database_url and not read_database_url.startswith("PLACEHOLDER"):
return await asyncpg.connect(read_database_url)
if all(os.getenv(name) for name in ("VELOCITY_DB_READ_NAME", "VELOCITY_DB_READ_USER", "VELOCITY_DB_READ_PASSWORD")):
return await asyncpg.connect(
host=os.getenv("VELOCITY_DB_READ_HOST", os.getenv("VELOCITY_DB_HOST", "127.0.0.1")),
port=int(os.getenv("VELOCITY_DB_READ_PORT", os.getenv("VELOCITY_DB_PORT", "5432"))),
database=os.environ["VELOCITY_DB_READ_NAME"],
user=os.environ["VELOCITY_DB_READ_USER"],
password=os.environ["VELOCITY_DB_READ_PASSWORD"],
)
database_url = os.getenv("DATABASE_URL", "") database_url = os.getenv("DATABASE_URL", "")
if database_url and not database_url.startswith("PLACEHOLDER"): if database_url and not database_url.startswith("PLACEHOLDER"):
return await asyncpg.connect(database_url) return await asyncpg.connect(database_url)
@@ -124,13 +125,6 @@ def sanitize_sql(sql: str, row_limit: int) -> tuple[str, list[str], list[str]]:
continue continue
if table and table not in tables: if table and table not in tables:
tables.append(table) 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 return clean, tables, warnings
@@ -151,6 +145,18 @@ def infer_component_type(prompt: str, columns: list[str], rows: list[dict[str, A
return "table" return "table"
def _looks_like_property_rollup_prompt(prompt: str) -> bool:
lower = prompt.lower()
property_terms = ("property", "properties", "project", "projects")
aggregate_terms = ("top", "most", "majority", "highest", "popular", "common")
interest_terms = ("interest", "interested", "liked", "preference", "preferences")
return (
any(term in lower for term in property_terms)
and any(term in lower for term in aggregate_terms)
and any(term in lower for term in interest_terms)
)
def title_from_prompt(prompt: str) -> str: def title_from_prompt(prompt: str) -> str:
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!") words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")
return words[:1].upper() + words[1:80] if words else "Oracle Query Result" return words[:1].upper() + words[1:80] if words else "Oracle Query Result"
@@ -164,19 +170,27 @@ class NaturalDbAgent:
return {"tables": [], "available": False} return {"tables": [], "available": False}
conn = await connect_db() conn = await connect_db()
try: try:
table_names = await conn.fetch(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
)
public_tables = [row["table_name"] for row in table_names]
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable
FROM information_schema.columns c FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = ANY($1::text[]) WHERE c.table_schema = 'public'
ORDER BY c.table_name, c.ordinal_position ORDER BY c.table_name, c.ordinal_position
""", """
sorted(ALLOWED_TABLES),
) )
counts = {} counts = {}
for table in sorted(ALLOWED_TABLES): for table in public_tables:
exists = await conn.fetchval("SELECT to_regclass($1)", f"public.{table}") 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}")) counts[table] = None if not exists else int(await conn.fetchval(f'SELECT COUNT(*) FROM "{table}"'))
tables: dict[str, dict[str, Any]] = {} tables: dict[str, dict[str, Any]] = {}
for row in rows: for row in rows:
entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])}) entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])})
@@ -186,7 +200,7 @@ class NaturalDbAgent:
"udtName": row["udt_name"], "udtName": row["udt_name"],
"nullable": row["is_nullable"] == "YES", "nullable": row["is_nullable"] == "YES",
}) })
return {"available": True, "tables": tables, "allowedTables": sorted(ALLOWED_TABLES)} return {"available": True, "tables": tables, "allowedTables": public_tables}
finally: finally:
if own_conn: if own_conn:
await conn.close() await conn.close()
@@ -210,7 +224,7 @@ class NaturalDbAgent:
"read_next_best_action": 250, "read_next_best_action": 250,
} }
tables = catalog.get("tables", {}) tables = catalog.get("tables", {})
counts = {table: (tables.get(table) or {}).get("rowCount") for table in sorted(ALLOWED_TABLES)} counts = {table: (meta or {}).get("rowCount") for table, meta in sorted(tables.items())}
return { return {
"counts": counts, "counts": counts,
"expectedSyntheticV2Counts": expected, "expectedSyntheticV2Counts": expected,
@@ -238,27 +252,12 @@ class NaturalDbAgent:
async def _run_plan(self, conn: Any, prompt: str, plan: dict[str, Any], row_limit: int) -> NaturalQueryResult: 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() raw_sql = str(plan.get("sql") or "").strip()
if not raw_sql: if not raw_sql:
raw_sql = self._fallback_sql(prompt, row_limit) raise RuntimeError("Natural SQL planner returned no SQL.")
sql, tables, warnings = sanitize_sql(raw_sql, row_limit) sql, tables, warnings = sanitize_sql(raw_sql, row_limit)
try: try:
records = await conn.fetch(sql) records = await conn.fetch(sql)
except Exception as exc: except Exception as exc:
retry = await self._repair_sql(prompt, raw_sql, str(exc), row_limit) raise RuntimeError(f"Natural SQL execution failed: {exc}") from exc
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] rows = [_json_safe(dict(record)) for record in records]
columns = list(rows[0].keys()) if rows else [] columns = list(rows[0].keys()) if rows else []
component_type = infer_component_type(prompt, columns, rows) component_type = infer_component_type(prompt, columns, rows)
@@ -276,17 +275,29 @@ class NaturalDbAgent:
) )
async def _plan_sql(self, prompt: str, catalog: dict[str, Any], row_limit: int) -> dict[str, Any]: 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: try:
providers = runtime_llm_service._provider_catalog() providers = runtime_llm_service._provider_catalog()
except Exception: except Exception:
providers = {} providers = {}
if not providers: if not providers:
return fallback raise RuntimeError("No runtime LLM providers are configured for Oracle natural planning.")
schema_brief = json.dumps(catalog.get("tables", {}), default=str)[:16000] schema_brief = json.dumps(catalog.get("tables", {}), default=str)[:16000]
semantic_rules = """
Velocity SQL semantics:
- QD score means intel_qd_scores.current_value. Do not use crm_people.engagement_score, crm_leads.engagement_score, or intel_interactions.engagement_score as QD.
- For project/property scoped prompts such as "in Atri Surya Toron", "interested in", "for project", or "for property", use crm_property_interests as the primary scoping table.
- Prefer crm_property_interests.project_name for textual project matching. inventory_projects is optional for enrichment, not the primary client-to-project relationship.
- For client lists scoped to a project, join crm_people to crm_property_interests on person_id and filter project_name case-insensitively.
- For lowest/highest/best/worst QD prompts, sort on intel_qd_scores.current_value ASC/DESC as requested.
- Respect the user-requested cardinality exactly when possible. If the prompt says five/top 5/lowest 5, return LIMIT 5.
- When listing clients, include person identity fields from crm_people such as person_id, full_name, primary_phone, and primary_email.
- When aggregating top properties/projects, group by crm_property_interests.project_name and count DISTINCT person_id.
- You may use any table in the public schema that is relevant to the question.
- Use only read-only PostgreSQL SELECT/CTE queries.
"""
system = ( system = (
"You are Oracle's read-only PostgreSQL planner. Generate one useful SELECT or WITH query " "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. " "for the user's CRM question. You have access to the full public schema. Return JSON with sql, title, rationale. "
"Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements." "Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements."
) )
try: try:
@@ -294,7 +305,16 @@ class NaturalDbAgent:
provider_id="sglang", provider_id="sglang",
model=None, model=None,
system_prompt=system, system_prompt=system,
messages=[{"role": "user", "content": f"Schema:\n{schema_brief}\n\nQuestion:\n{prompt}\n\nRow cap: {row_limit}"}], messages=[{
"role": "user",
"content": (
f"Schema:\n{schema_brief}\n\n"
f"Semantic rules:\n{semantic_rules}\n\n"
f"Question:\n{prompt}\n\n"
f"Row cap: {row_limit}\n\n"
"Return strict JSON with keys: sql, title, rationale."
),
}],
temperature=0.05, temperature=0.05,
response_format="json", response_format="json",
metadata={"agent": "oracle_natural_db_agent"}, metadata={"agent": "oracle_natural_db_agent"},
@@ -307,162 +327,7 @@ class NaturalDbAgent:
if isinstance(parsed, dict) and parsed.get("sql"): if isinstance(parsed, dict) and parsed.get("sql"):
return parsed return parsed
except Exception as exc: except Exception as exc:
logger.warning("Natural DB planner LLM failed, using fallback: %s", exc) raise RuntimeError(f"Natural DB planner LLM failed: {exc}") from exc
return fallback raise RuntimeError("Natural DB planner returned no valid SQL.")
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() natural_db_agent = NaturalDbAgent()

View File

@@ -61,6 +61,8 @@ def _coerce_datetime(value: datetime | str | None) -> datetime | None:
def _json_safe(value: Any) -> Any: def _json_safe(value: Any) -> Any:
if isinstance(value, datetime): if isinstance(value, datetime):
return value.isoformat() return value.isoformat()
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, dict): if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()} return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, list): if isinstance(value, list):
@@ -130,6 +132,49 @@ def _build_demo_retrieval_plan(
} }
def _infer_chart_axes(rows: list[dict[str, Any]], columns: list[str]) -> tuple[str | None, str | None]:
if not rows or not columns:
return None, None
sample = rows[0]
string_columns = [
column for column in columns
if isinstance(sample.get(column), str) and sample.get(column) not in (None, "")
]
numeric_columns = [
column for column in columns
if isinstance(sample.get(column), (int, float))
]
preferred_dimension_keys = (
"property_name",
"project_name",
"projects",
"name",
"category",
"label",
)
preferred_measure_keys = (
"interested_clients",
"interest_count",
"total_interest_events",
"count",
"value",
"avg_qd_score",
"qd_score",
)
x_axis = next((key for key in preferred_dimension_keys if key in string_columns), None)
if x_axis is None and string_columns:
x_axis = string_columns[0]
y_axis = next((key for key in preferred_measure_keys if key in numeric_columns), None)
if y_axis is None and numeric_columns:
y_axis = numeric_columns[0]
return x_axis, y_axis
_DATASET_MAP: dict[str, str] = { _DATASET_MAP: dict[str, str] = {
"pipeline_board": "crm_opportunity_pipeline", "pipeline_board": "crm_opportunity_pipeline",
"bar_chart": "oracle_property_interest_rollup", "bar_chart": "oracle_property_interest_rollup",
@@ -168,10 +213,42 @@ def _component_plan_type_from_codebook(example: CodebookExample) -> str:
def _parse_prompt_row_limit(prompt: str, actor_role: str) -> int: def _parse_prompt_row_limit(prompt: str, actor_role: str) -> int:
default_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200 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()) lowered = prompt.lower()
if not match: match = re.search(r"\b(?:top|last|latest|recent|first|show|name of the last|which)\s+(\d{1,4})\b", lowered)
if match:
requested = max(1, int(match.group(1)))
return min(requested, default_limit)
word_to_number = {
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"eleven": 11,
"twelve": 12,
"thirteen": 13,
"fourteen": 14,
"fifteen": 15,
"sixteen": 16,
"seventeen": 17,
"eighteen": 18,
"nineteen": 19,
"twenty": 20,
}
word_match = re.search(
r"\b(?:top|last|latest|recent|first|show|name of the last|which)\s+"
r"(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)\b",
lowered,
)
if not word_match:
return default_limit return default_limit
requested = max(1, int(match.group(1))) requested = word_to_number[word_match.group(1)]
return min(requested, default_limit) return min(requested, default_limit)
@@ -416,59 +493,62 @@ class PromptOrchestrator:
next_order_base = self._next_order_base(existing_comps) next_order_base = self._next_order_base(existing_comps)
section_id = f"sec_prompt_generated_{execution_id.replace('-', '')[:12]}" section_id = f"sec_prompt_generated_{execution_id.replace('-', '')[:12]}"
natural_result = None
try: try:
natural_result = await natural_db_agent.execute_prompt( natural_result = await natural_db_agent.execute_prompt(
prompt, prompt,
row_limit=_parse_prompt_row_limit(prompt, actor_role), row_limit=_parse_prompt_row_limit(prompt, actor_role),
) )
except Exception as exc: except Exception as exc:
logger.warning("ORCH natural DB agent unavailable, falling back to component planner: %s", exc) logger.warning("ORCH natural DB agent failed with no fallback enabled: %s", exc)
warnings.append(f"Natural DB agent unavailable ({exc}); using component planner fallback.") execution["status"] = "failed"
execution["summary"] = f"Oracle planner failed: {exc}"
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["completedAt"] = _now()
execution["warnings"] = warnings + natural_result.warnings execution["warnings"] = warnings + [f"No fallback enabled. Natural planner failure: {exc}"]
await self._persist_execution(execution) await self._persist_execution(execution)
return execution return execution
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) codebook_matches = codebook_service.search_examples(prompt, limit=4)
execution["codebookMatches"] = [ execution["codebookMatches"] = [
{ {
@@ -718,6 +798,27 @@ class PromptOrchestrator:
mapped_type = self._map_type(ctype) mapped_type = self._map_type(ctype)
dataset = "oracle_natural_sql" dataset = "oracle_natural_sql"
component_id = str(uuid.uuid4()) component_id = str(uuid.uuid4())
x_axis, y_axis = _infer_chart_axes(rows, columns)
bindings = self._default_bindings(ctype)
viz_params = {
**self._default_viz_params(ctype, dataset, rows),
"columns": columns,
"sqlSummary": result.get("summary"),
"sourceTables": result.get("sourceTables", []),
"rowCount": result.get("rowCount", len(rows)),
}
if ctype == "bar_chart":
if x_axis:
viz_params["xAxis"] = x_axis
bindings["dimensions"] = [x_axis]
if y_axis:
viz_params["yAxis"] = y_axis
bindings["measures"] = [y_axis]
elif ctype == "line_chart":
if x_axis:
bindings["dimensions"] = [x_axis]
if y_axis:
bindings["measures"] = [y_axis]
comp: dict[str, Any] = { comp: dict[str, Any] = {
"componentId": component_id, "componentId": component_id,
"type": mapped_type, "type": mapped_type,
@@ -735,14 +836,8 @@ class PromptOrchestrator:
"privacyTier": "standard", "privacyTier": "standard",
"cachePolicy": {"mode": "revision_scoped"}, "cachePolicy": {"mode": "revision_scoped"},
}, },
"visualizationParameters": { "visualizationParameters": viz_params,
**self._default_viz_params(ctype, dataset, rows), "dataBindings": bindings,
"columns": columns,
"sqlSummary": result.get("summary"),
"sourceTables": result.get("sourceTables", []),
"rowCount": result.get("rowCount", len(rows)),
},
"dataBindings": self._default_bindings(ctype),
"version": 1, "version": 1,
"lifecycleState": "active", "lifecycleState": "active",
"provenance": { "provenance": {
@@ -966,10 +1061,9 @@ class PromptOrchestrator:
@staticmethod @staticmethod
def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str: def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str:
count = len(viz_plan.get("components", [])) count = len([component for component in viz_plan.get("components", []) if component.get("type") != "textCanvas"])
short_prompt = prompt[:60] + ("" if len(prompt) > 60 else "") short_prompt = prompt[:60] + ("" if len(prompt) > 60 else "")
data_component_count = max(count - 1, 0) return f'Generated {count} component{"s" if count != 1 else ""} for: "{short_prompt}"'
return f'Generated {data_component_count} component{"s" if data_component_count != 1 else ""} for: "{short_prompt}"'
@staticmethod @staticmethod
def _error_component( def _error_component(

View File

@@ -134,6 +134,14 @@ async def _ctx_from_request(request: Request, user: UserPrincipal) -> PolicyCont
) )
async def _resolve_page_id(request: Request, user: UserPrincipal, page_id: str) -> str:
normalized = (page_id or "").strip()
if normalized and normalized.lower() != "main":
return normalized
me = await _get_current_user_profile(request, user)
return str(me["defaultPageId"])
# ── Pydantic Models ─────────────────────────────────────────────────────────── # ── Pydantic Models ───────────────────────────────────────────────────────────
class PromptSubmitRequest(BaseModel): class PromptSubmitRequest(BaseModel):
@@ -184,6 +192,14 @@ class PersonaRenderRequest(BaseModel):
variables: dict[str, Any] = Field(default_factory=dict) variables: dict[str, Any] = Field(default_factory=dict)
class PageCreateRequest(BaseModel):
title: str = Field(default="Untitled Canvas", max_length=256)
class PageUpdateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
# ── Endpoints ───────────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/me", summary="Get current user profile") @router.get("/me", summary="Get current user profile")
@@ -191,8 +207,41 @@ async def get_me(request: Request, user: UserPrincipal = Depends(get_current_use
return _ok(await _get_current_user_profile(request, user)) return _ok(await _get_current_user_profile(request, user))
@router.get("/canvas-pages", summary="List canvas pages for current user")
async def list_canvas_pages(
request: Request,
search: str | None = None,
limit: int = 50,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
ctx = await _ctx_from_request(request, user)
pages = await canvas_service.list_pages(
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
search=search,
limit=limit,
)
return _ok(pages, meta={"count": len(pages)})
@router.post("/canvas-pages", summary="Create a new canvas page")
async def create_canvas_page(
payload: PageCreateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
ctx = await _ctx_from_request(request, user)
page = await canvas_service.create_page(
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
title=payload.title.strip() or "Untitled Canvas",
)
return _ok(page)
@router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID") @router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID")
async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user) ctx = await _ctx_from_request(request, user)
page = await canvas_service.get_page(page_id, ctx.tenant_id) page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page: if not page:
@@ -200,6 +249,46 @@ async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal =
return _ok(page) return _ok(page)
@router.patch("/canvas-pages/{page_id}", summary="Rename a canvas page")
async def rename_canvas_page(
page_id: str,
payload: PageUpdateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
try:
page = await canvas_service.update_page_title(
page_id=page_id,
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
title=payload.title,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _ok(page)
@router.delete("/canvas-pages/{page_id}", summary="Delete a canvas page")
async def delete_canvas_page(
page_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
try:
await canvas_service.delete_page(
page_id=page_id,
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _ok({"pageId": page_id, "deleted": True})
@router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components") @router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components")
async def submit_prompt( async def submit_prompt(
page_id: str, page_id: str,
@@ -207,6 +296,7 @@ async def submit_prompt(
request: Request, request: Request,
user: UserPrincipal = Depends(get_current_user), user: UserPrincipal = Depends(get_current_user),
) -> dict: ) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user) ctx = await _ctx_from_request(request, user)
execution = await prompt_orchestrator.execute( execution = await prompt_orchestrator.execute(
tenant_id=ctx.tenant_id, tenant_id=ctx.tenant_id,
@@ -253,6 +343,7 @@ async def create_fork(
request: Request, request: Request,
user: UserPrincipal = Depends(get_current_user), user: UserPrincipal = Depends(get_current_user),
) -> dict: ) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user) ctx = await _ctx_from_request(request, user)
page = await canvas_service.get_page(page_id, ctx.tenant_id) page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page: if not page:
@@ -277,6 +368,7 @@ async def rollback_canvas(
request: Request, request: Request,
user: UserPrincipal = Depends(get_current_user), user: UserPrincipal = Depends(get_current_user),
) -> dict: ) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user) ctx = await _ctx_from_request(request, user)
result = await canvas_service.rollback( result = await canvas_service.rollback(
page_id=page_id, page_id=page_id,
@@ -295,6 +387,7 @@ async def rollback_canvas(
@router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page") @router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page")
async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user) ctx = await _ctx_from_request(request, user)
revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id) revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id)
return _ok(revisions, meta={"count": len(revisions)}) return _ok(revisions, meta={"count": len(revisions)})