feat: New Chat, Search Chat and Master Slave DB Architecture for CRM and Oracle Canvas
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ export interface OracleExecutionState {
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
clearError: () => void;
|
||||
resetHistory: () => void;
|
||||
}
|
||||
|
||||
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([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +81,32 @@ export async function fetchCanvasPage(pageId: string): Promise<CanvasPage> {
|
||||
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(
|
||||
pageId: string,
|
||||
payload: PromptSubmitRequest,
|
||||
|
||||
@@ -86,6 +86,8 @@ def _json_array(value: Any) -> list[Any]:
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(val) for key, val in value.items()}
|
||||
if isinstance(value, list):
|
||||
@@ -173,6 +175,54 @@ def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[st
|
||||
|
||||
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -310,6 +360,80 @@ class CanvasService:
|
||||
finally:
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
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.
|
||||
The LLM can plan arbitrary analytical SELECT statements over the full public
|
||||
Velocity app schema. The executor enforces only a read-only SQL contract and a
|
||||
UI row cap; write paths stay behind typed API endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,25 +25,12 @@ except Exception: # pragma: no cover
|
||||
|
||||
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)
|
||||
CTE_NAME_RE = re.compile(r"\b(?:with|,)\s*([a-zA-Z_][\w]*)\s+as\s*\(", re.IGNORECASE)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
@@ -61,6 +48,9 @@ def _json_safe(value: Any) -> Any:
|
||||
def db_ready() -> bool:
|
||||
if asyncpg is None:
|
||||
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", "")
|
||||
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")
|
||||
@@ -70,6 +60,17 @@ def db_ready() -> bool:
|
||||
async def connect_db() -> Any:
|
||||
if asyncpg is None:
|
||||
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", "")
|
||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -151,6 +145,18 @@ def infer_component_type(prompt: str, columns: list[str], rows: list[dict[str, A
|
||||
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:
|
||||
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")
|
||||
return words[:1].upper() + words[1:80] if words else "Oracle Query Result"
|
||||
@@ -164,19 +170,27 @@ class NaturalDbAgent:
|
||||
return {"tables": [], "available": False}
|
||||
conn = await connect_db()
|
||||
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(
|
||||
"""
|
||||
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[])
|
||||
WHERE c.table_schema = 'public'
|
||||
ORDER BY c.table_name, c.ordinal_position
|
||||
""",
|
||||
sorted(ALLOWED_TABLES),
|
||||
"""
|
||||
)
|
||||
counts = {}
|
||||
for table in sorted(ALLOWED_TABLES):
|
||||
for table in public_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}"))
|
||||
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"])})
|
||||
@@ -186,7 +200,7 @@ class NaturalDbAgent:
|
||||
"udtName": row["udt_name"],
|
||||
"nullable": row["is_nullable"] == "YES",
|
||||
})
|
||||
return {"available": True, "tables": tables, "allowedTables": sorted(ALLOWED_TABLES)}
|
||||
return {"available": True, "tables": tables, "allowedTables": public_tables}
|
||||
finally:
|
||||
if own_conn:
|
||||
await conn.close()
|
||||
@@ -210,7 +224,7 @@ class NaturalDbAgent:
|
||||
"read_next_best_action": 250,
|
||||
}
|
||||
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 {
|
||||
"counts": counts,
|
||||
"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:
|
||||
raw_sql = str(plan.get("sql") or "").strip()
|
||||
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)
|
||||
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.")
|
||||
raise RuntimeError(f"Natural SQL execution failed: {exc}") from exc
|
||||
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)
|
||||
@@ -276,17 +275,29 @@ class NaturalDbAgent:
|
||||
)
|
||||
|
||||
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
|
||||
raise RuntimeError("No runtime LLM providers are configured for Oracle natural planning.")
|
||||
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 = (
|
||||
"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."
|
||||
)
|
||||
try:
|
||||
@@ -294,7 +305,16 @@ class NaturalDbAgent:
|
||||
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}"}],
|
||||
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,
|
||||
response_format="json",
|
||||
metadata={"agent": "oracle_natural_db_agent"},
|
||||
@@ -307,162 +327,7 @@ class NaturalDbAgent:
|
||||
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)
|
||||
|
||||
raise RuntimeError(f"Natural DB planner LLM failed: {exc}") from exc
|
||||
raise RuntimeError("Natural DB planner returned no valid SQL.")
|
||||
|
||||
natural_db_agent = NaturalDbAgent()
|
||||
|
||||
@@ -61,6 +61,8 @@ def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(val) for key, val in value.items()}
|
||||
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] = {
|
||||
"pipeline_board": "crm_opportunity_pipeline",
|
||||
"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:
|
||||
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:
|
||||
lowered = prompt.lower()
|
||||
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
|
||||
requested = max(1, int(match.group(1)))
|
||||
requested = word_to_number[word_match.group(1)]
|
||||
return min(requested, default_limit)
|
||||
|
||||
|
||||
@@ -416,59 +493,62 @@ class PromptOrchestrator:
|
||||
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)
|
||||
logger.warning("ORCH natural DB agent failed with no fallback enabled: %s", exc)
|
||||
execution["status"] = "failed"
|
||||
execution["summary"] = f"Oracle planner failed: {exc}"
|
||||
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)
|
||||
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)
|
||||
execution["codebookMatches"] = [
|
||||
{
|
||||
@@ -718,6 +798,27 @@ class PromptOrchestrator:
|
||||
mapped_type = self._map_type(ctype)
|
||||
dataset = "oracle_natural_sql"
|
||||
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] = {
|
||||
"componentId": component_id,
|
||||
"type": mapped_type,
|
||||
@@ -735,14 +836,8 @@ class PromptOrchestrator:
|
||||
"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),
|
||||
"visualizationParameters": viz_params,
|
||||
"dataBindings": bindings,
|
||||
"version": 1,
|
||||
"lifecycleState": "active",
|
||||
"provenance": {
|
||||
@@ -966,10 +1061,9 @@ class PromptOrchestrator:
|
||||
|
||||
@staticmethod
|
||||
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 "")
|
||||
data_component_count = max(count - 1, 0)
|
||||
return f'Generated {data_component_count} component{"s" if data_component_count != 1 else ""} for: "{short_prompt}"'
|
||||
return f'Generated {count} component{"s" if count != 1 else ""} for: "{short_prompt}"'
|
||||
|
||||
@staticmethod
|
||||
def _error_component(
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
class PromptSubmitRequest(BaseModel):
|
||||
@@ -184,6 +192,14 @@ class PersonaRenderRequest(BaseModel):
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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")
|
||||
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)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
if not page:
|
||||
@@ -200,6 +249,46 @@ async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal =
|
||||
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")
|
||||
async def submit_prompt(
|
||||
page_id: str,
|
||||
@@ -207,6 +296,7 @@ async def submit_prompt(
|
||||
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)
|
||||
execution = await prompt_orchestrator.execute(
|
||||
tenant_id=ctx.tenant_id,
|
||||
@@ -253,6 +343,7 @@ async def create_fork(
|
||||
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)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
if not page:
|
||||
@@ -277,6 +368,7 @@ async def rollback_canvas(
|
||||
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)
|
||||
result = await canvas_service.rollback(
|
||||
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")
|
||||
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)
|
||||
revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id)
|
||||
return _ok(revisions, meta={"count": len(revisions)})
|
||||
|
||||
Reference in New Issue
Block a user