feat: Oracle Canvas, Revision History and Canvas Sharing (#33)

Co-authored-by: Sagnik <sagnik7896@gmail.com>
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
2026-04-23 01:20:21 +05:30
parent e519339cc9
commit 6cdc366718
58 changed files with 3187 additions and 705 deletions

View File

@@ -70,6 +70,31 @@ def _json_object(value: Any) -> dict[str, Any]:
return {}
def _json_array(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return parsed
except Exception:
logger.warning("canvas_service: failed to parse JSON array field; using empty array")
return []
def _json_safe(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, tuple):
return [_json_safe(item) for item in value]
return value
def _normalize_component(component: dict[str, Any]) -> dict[str, Any]:
normalized = deepcopy(component)
normalized["componentId"] = _stringify(normalized.get("componentId"))
@@ -224,9 +249,15 @@ class CanvasService:
async def get_first_page_for_owner(self, *, tenant_id: str, owner_id: str) -> dict[str, Any] | None:
_ensure_ready()
if _is_demo():
for page in _DEMO_PAGES.values():
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id:
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))}
candidates = [
page
for page in _DEMO_PAGES.values()
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id
]
if candidates:
candidates.sort(key=lambda page: page.get("updatedAt", ""), reverse=True)
page = candidates[0]
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))}
return None
assert asyncpg is not None
@@ -237,7 +268,7 @@ class CanvasService:
SELECT *
FROM oracle_canvas_pages
WHERE tenant_id = $1 AND owner_id = $2
ORDER BY created_at ASC
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
""",
tenant_id,
@@ -310,7 +341,7 @@ class CanvasService:
"actorId": actor_id,
"executionId": execution_id,
"mergeRequestId": merge_request_id,
"componentsSnapshot": json.dumps(components),
"componentsSnapshot": json.dumps(_json_safe(components)),
"idempotencyKey": idempotency_key,
"createdAt": _now(),
}
@@ -346,7 +377,7 @@ class CanvasService:
"actorId": existing["actor_id"],
"executionId": _stringify(existing["execution_id"]) if existing["execution_id"] else None,
"mergeRequestId": _stringify(existing["merge_request_id"]) if existing["merge_request_id"] else None,
"componentsSnapshot": json.dumps(existing["components_snapshot"]),
"componentsSnapshot": json.dumps(_json_safe(existing["components_snapshot"])),
"idempotencyKey": existing["idempotency_key"],
"createdAt": existing["created_at"].isoformat(),
}
@@ -385,7 +416,7 @@ class CanvasService:
actor_id,
execution_id or "",
merge_request_id or "",
json.dumps(normalized_components),
json.dumps(_json_safe(normalized_components)),
idempotency_key,
)
@@ -411,7 +442,7 @@ class CanvasService:
"actorId": revision["actor_id"],
"executionId": _stringify(revision["execution_id"]) if revision["execution_id"] else None,
"mergeRequestId": _stringify(revision["merge_request_id"]) if revision["merge_request_id"] else None,
"componentsSnapshot": json.dumps(revision["components_snapshot"]),
"componentsSnapshot": json.dumps(_json_safe(revision["components_snapshot"])),
"idempotencyKey": revision["idempotency_key"],
"createdAt": revision["created_at"].isoformat(),
}
@@ -462,13 +493,14 @@ class CanvasService:
)
if not revision:
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
snapshot = _json_array(revision["components_snapshot"])
return await self.commit_revision(
page_id=page_id,
tenant_id=tenant_id,
actor_id=actor_id,
commit_kind="rollback",
commit_summary=f"Rollback to revision {target_revision}",
components=list(revision["components_snapshot"]),
components=snapshot,
idempotency_key=idempotency_key,
)
finally:
@@ -604,15 +636,15 @@ class CanvasService:
component.get("description"),
int(component.get("version", 1)),
component.get("lifecycleState", "active"),
json.dumps(component.get("dataSourceDescriptor", {})),
json.dumps(component.get("visualizationParameters", {})),
json.dumps(component.get("dataBindings", {})),
json.dumps(component.get("provenance", {})),
json.dumps(component.get("renderingHints", {})),
json.dumps(component.get("layout", {})),
json.dumps(component.get("accessControls", {})),
json.dumps(component.get("styleSignature", {})),
json.dumps(component.get("validationState", {})),
json.dumps(_json_safe(component.get("dataSourceDescriptor", {}))),
json.dumps(_json_safe(component.get("visualizationParameters", {}))),
json.dumps(_json_safe(component.get("dataBindings", {}))),
json.dumps(_json_safe(component.get("provenance", {}))),
json.dumps(_json_safe(component.get("renderingHints", {}))),
json.dumps(_json_safe(component.get("layout", {}))),
json.dumps(_json_safe(component.get("accessControls", {}))),
json.dumps(_json_safe(component.get("styleSignature", {}))),
json.dumps(_json_safe(component.get("validationState", {}))),
list(component.get("auditLog", [])),
)

View File

@@ -261,13 +261,17 @@ class OracleCodebookService:
if not prompt_terms:
prompt_terms = set(_tokenize(prompt.replace("_", " ")))
lowered_prompt = prompt.lower()
crm_prompt = any(term in lowered_prompt for term in ("client", "clients", "contact", "contacts", "crm", "lead", "account"))
interaction_prompt = any(term in lowered_prompt for term in ("interaction", "timeline", "call", "message", "email", "whatsapp", "follow-up"))
property_prompt = any(term in lowered_prompt for term in ("property", "properties", "project", "projects", "interest", "interested"))
scored: list[tuple[int, CodebookExample]] = []
for example in self.load()["examples"]:
score = 0
term_set = set(example.score_terms)
overlap = prompt_terms.intersection(term_set)
score += len(overlap) * 6
lowered_prompt = prompt.lower()
if example.template_name.lower() in lowered_prompt:
score += 24
if example.subchapter_name.lower() in lowered_prompt:
@@ -280,6 +284,15 @@ class OracleCodebookService:
score += 8
if "live_data_first" in example.policy_tags:
score += 4
chapter = example.chapter_name.lower()
subchapter = example.subchapter_name.lower()
title = example.title.lower()
if crm_prompt and any(term in " ".join((chapter, subchapter, title, example.template_name.lower())) for term in ("lead", "client", "contact", "crm", "account", "pipeline")):
score += 18
if interaction_prompt and any(term in " ".join((chapter, subchapter, title, example.template_name.lower())) for term in ("interaction", "timeline", "call", "message", "email", "whatsapp", "follow-up")):
score += 16
if property_prompt and any(term in " ".join((chapter, subchapter, title, example.template_name.lower())) for term in ("property", "inventory", "interest", "project")):
score += 16
if score > 0:
scored.append((score, example))

View File

@@ -11,6 +11,8 @@ import uuid
from datetime import datetime, timezone
from typing import Any
from .canvas_service import canvas_service
logger = logging.getLogger(__name__)
# ── In-memory store (demo mode) ───────────────────────────────────────────────
@@ -23,6 +25,32 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _clone_components_for_fork(
components: list[dict[str, Any]],
*,
actor_id: str,
source_page_id: str,
source_branch_id: str,
source_revision: int,
) -> list[dict[str, Any]]:
cloned: list[dict[str, Any]] = []
for component in components:
forked = copy.deepcopy(component)
original_component_id = str(forked.get("componentId") or "")
forked["componentId"] = str(uuid.uuid4())
provenance = dict(forked.get("provenance") or {})
provenance["forkedAt"] = _now()
provenance["forkedBy"] = actor_id
provenance["sourcePageId"] = source_page_id
provenance["sourceBranchId"] = source_branch_id
provenance["sourceRevision"] = source_revision
if original_component_id:
provenance["sourceComponentId"] = original_component_id
forked["provenance"] = provenance
cloned.append(forked)
return cloned
# ── Three-way diff engine ─────────────────────────────────────────────────────
def _three_way_diff(
@@ -228,17 +256,50 @@ class CollaborationService:
Creates a fork from the source_page snapshot at its current headRevision.
Returns ForkRecord.
"""
if recipient_user_id == created_by:
raise ValueError("You cannot share a canvas with your own account.")
fork_id = str(uuid.uuid4())
fork_page_id = str(uuid.uuid4())
fork_branch_id = str(uuid.uuid4())
fork_page = await canvas_service.create_page(
tenant_id=source_page["tenantId"],
owner_id=recipient_user_id,
title=f"{source_page['title']} Fork",
page_type="fork",
branch_name=f"fork-{str(fork_id)[:8]}",
sharing_policy={
"shareMode": "direct_fork_only",
"allowReshare": visibility == "team",
"defaultForkVisibility": visibility,
},
)
fork_components = _clone_components_for_fork(
source_page.get("components", []),
actor_id=created_by,
source_page_id=source_page["pageId"],
source_branch_id=source_page["branchId"],
source_revision=source_page["headRevision"],
)
await canvas_service.commit_revision(
page_id=fork_page["pageId"],
tenant_id=source_page["tenantId"],
actor_id=created_by,
commit_kind="merge",
commit_summary=f"Forked from {source_page['title']} at rev.{source_page['headRevision']}",
components=fork_components,
execution_id=None,
merge_request_id=None,
idempotency_key=f"fork_{fork_id}",
)
fork = {
"forkId": fork_id,
"sourcePageId": source_page["pageId"],
"sourceBranchId": source_page["branchId"],
"sourceRevision": source_page["headRevision"],
"forkPageId": fork_page_id,
"forkBranchId": fork_branch_id,
"forkPageId": fork_page["pageId"],
"forkBranchId": fork_page["branchId"],
"recipientUserId": recipient_user_id,
"createdBy": created_by,
"visibility": visibility,

View File

@@ -159,14 +159,20 @@ class DataAccessGateway:
if dataset == "broker_performance":
sql = """
SELECT
ROW_NUMBER() OVER (ORDER BY COALESCE(revenue_generated, 0) DESC, broker_name ASC)::int AS rank,
broker_name AS name,
deals_closed::int AS deals_closed,
COALESCE(revenue_generated, 0)::float AS revenue_generated,
avatar_url AS avatar
FROM broker_performance
WHERE tenant_id = $1
ORDER BY revenue_generated DESC, broker_name ASC
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
WHERE u.is_active = TRUE
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
"""
return sql, [ctx.tenant_id, row_limit]
@@ -245,13 +251,20 @@ class DataAccessGateway:
COALESCE(p.primary_phone, '') AS phone,
COALESCE(p.city, '') AS city,
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
COALESCE(q.qd_score, 0)::float AS qd_score
COALESCE(q.current_value, 0)::float AS qd_score
FROM crm_people p
LEFT JOIN LATERAL (
SELECT qd_score
SELECT current_value
FROM intel_qd_scores q
WHERE q.person_id = p.person_id
ORDER BY q.scored_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
@@ -301,6 +314,71 @@ class DataAccessGateway:
"""
return sql, [row_limit]
if dataset == "crm_last_interacted_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
FROM crm_people p
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
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
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
LIMIT $1
"""
return sql, [row_limit]
if dataset == "crm_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
FROM crm_people p
INNER JOIN crm_property_interests pi ON pi.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
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 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":
sql = """
SELECT

View File

@@ -56,6 +56,18 @@ def _coerce_datetime(value: datetime | str | None) -> datetime | None:
# ── Execution store ───────────────────────────────────────────────────────────
def _json_safe(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, tuple):
return [_json_safe(item) for item in value]
return value
_DEMO_EXECUTIONS: dict[str, dict[str, Any]] = {}
@@ -117,13 +129,13 @@ def _build_demo_retrieval_plan(
_DATASET_MAP: dict[str, str] = {
"pipeline_board": "deals",
"bar_chart": "lead_daily_snapshot",
"pipeline_board": "crm_opportunity_pipeline",
"bar_chart": "crm_property_interest_rollup",
"geo_map": "lead_geo_interest_rollup",
"table": "broker_performance",
"line_chart": "inventory_absorption",
"table": "crm_contacts_overview",
"line_chart": "crm_property_interest_rollup",
"kpi_tile": "oracle_aggregated_metric",
"activity_stream": "lead_activity_log",
"activity_stream": "crm_interaction_timeline",
}
_CODEBOOK_COMPONENT_MAP: dict[str, str] = {
@@ -162,6 +174,10 @@ def _dataset_for_codebook(example: CodebookExample, prompt: str, component_plan_
return "crm_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"
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"
if component_plan_type == "line_chart" and any(term in lowered_prompt for term in ("trend", "time", "history", "growth")):
return "crm_property_interest_rollup"
@@ -170,8 +186,12 @@ def _dataset_for_codebook(example: CodebookExample, prompt: str, component_plan_
return "crm_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"
if "interest" in lowered_prompt or "project" in lowered_prompt or "property" in lowered_prompt:
return "crm_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 "crm_contacts_overview"
if "client" in chapter or "client" in subchapter or "contact" in subchapter:
@@ -205,6 +225,7 @@ def _build_codebook_retrieval_plan(
exemplar = matches[0]
for component_plan_type in desired_types[:4]:
dataset = _dataset_for_codebook(exemplar, prompt, component_plan_type)
title_hint = _title_for_dataset(dataset, component_plan_type, prompt) or title_hints.get(component_plan_type, exemplar.title)
components.append(
{
"suggestedType": component_plan_type,
@@ -222,7 +243,7 @@ def _build_codebook_retrieval_plan(
"subchapterName": exemplar.subchapter_name,
"sourcePack": exemplar.source_pack,
},
"titleHint": title_hints.get(component_plan_type, exemplar.title),
"titleHint": title_hint,
}
)
@@ -235,6 +256,24 @@ def _build_codebook_retrieval_plan(
}
def _title_for_dataset(dataset: str, component_plan_type: str, prompt: str) -> str | None:
lowered_prompt = prompt.lower()
dataset_titles = {
"crm_contacts_overview": "CRM Contacts Overview",
"crm_opportunity_pipeline": "Opportunity Pipeline",
"crm_property_interest_rollup": "Property Interest Rollup",
"crm_interaction_timeline": "Client Interaction Timeline",
"crm_last_interacted_clients": "Last Interacted Clients",
"crm_top_interested_clients": "Top Interested Clients",
"broker_performance": "Broker Performance",
}
if dataset == "crm_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"
return dataset_titles.get(dataset)
_RUNTIME_ALLOWED_DATASETS = {
"deals",
"lead_daily_snapshot",
@@ -247,6 +286,8 @@ _RUNTIME_ALLOWED_DATASETS = {
"crm_opportunity_pipeline",
"crm_property_interest_rollup",
"crm_interaction_timeline",
"crm_last_interacted_clients",
"crm_top_interested_clients",
}
@@ -371,6 +412,11 @@ class PromptOrchestrator:
execution["status"] = "executing"
await self._persist_execution(execution)
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]}"
# ── Step 3: Build visualization plan (component descriptors) ──────────
viz_plan = await self._build_visualization_plan(
retrieval_plan=retrieval_plan,
@@ -382,6 +428,8 @@ class PromptOrchestrator:
placement_mode=placement_mode,
ctx=ctx,
persona_plan=persona_plan,
base_order=next_order_base,
section_id=section_id,
)
execution["visualizationPlan"] = viz_plan
@@ -391,9 +439,7 @@ class PromptOrchestrator:
# Commit a revision bump with the new components
try:
page = await canvas_service.get_page(page_id, tenant_id)
if page:
existing_comps = page.get("components", [])
new_comps = existing_comps + viz_plan.get("components", [])
revision = await canvas_service.commit_revision(
page_id=page_id,
@@ -429,6 +475,8 @@ class PromptOrchestrator:
placement_mode: str,
ctx: PolicyContext,
persona_plan: dict[str, Any],
base_order: int,
section_id: str,
) -> dict[str, Any]:
"""Converts a retrieval plan into a list of CanvasComponent descriptors."""
components = [
@@ -438,9 +486,10 @@ class PromptOrchestrator:
branch_id=branch_id,
prompt=prompt,
persona_plan=persona_plan,
order_index=base_order + 100,
section_id=section_id,
)
]
base_order = 900 # Append after existing components
component_plans = retrieval_plan.get("components", [])
for i, plan in enumerate(component_plans):
@@ -469,7 +518,7 @@ class PromptOrchestrator:
"privacyTier": plan.get("privacyTier", "standard"),
"cachePolicy": {"mode": "ttl", "ttlSeconds": 120},
},
"visualizationParameters": self._default_viz_params(ctype, data_rows),
"visualizationParameters": self._default_viz_params(ctype, dataset, data_rows),
"dataBindings": self._default_bindings(ctype),
"version": 1,
"lifecycleState": "active",
@@ -483,7 +532,7 @@ class PromptOrchestrator:
"renderingHints": self._rendering_hints(ctype),
"layout": {
"orderIndex": base_order + (i + 1) * 100,
"sectionId": "sec_prompt_generated",
"sectionId": section_id,
"widthMode": "full" if ctype in ("pipeline_board", "table", "geo_map") else "half",
"minHeightPx": 300,
"stickyHeader": False,
@@ -520,11 +569,29 @@ class PromptOrchestrator:
dataset=dataset,
warnings=component_warnings,
order_index=base_order + (i + 1) * 100,
section_id=section_id,
)
components.append(comp)
if len(components) > 1:
planning_component = components.pop(0)
planning_component["layout"]["orderIndex"] = base_order + (len(component_plans) + 1) * 100
components.append(planning_component)
return {"components": components}
@staticmethod
def _next_order_base(existing_components: list[dict[str, Any]]) -> int:
max_existing = 0
for component in existing_components:
try:
order_index = int((component.get("layout") or {}).get("orderIndex", 0))
except (TypeError, ValueError):
order_index = 0
if order_index > max_existing:
max_existing = order_index
return ((max_existing // 100) + 1) * 100
@staticmethod
def _persona_text_canvas(
*,
@@ -533,13 +600,13 @@ class PromptOrchestrator:
branch_id: str,
prompt: str,
persona_plan: dict[str, Any],
order_index: int,
section_id: str,
) -> dict[str, Any]:
recommended = ", ".join(persona_plan.get("recommendedTemplates", [])) or "no direct template matches"
content = (
f"Oracle received: {prompt}\n\n"
f"Reusable templates: {recommended}\n\n"
"Execution policy: query live CRM data first, reuse matching templates, "
"synthesize missing UI blocks, then dispatch the required ComfyUI-backed workflow."
"Execution policy: query live CRM data first, pick the strongest-fitting canvas components, "
"and synthesize any missing UI blocks before rendering the result."
)
return {
"componentId": str(uuid.uuid4()),
@@ -574,8 +641,8 @@ class PromptOrchestrator:
},
"renderingHints": {"estimatedHeightPx": 180, "skeletonVariant": "text", "virtualizationPriority": 4},
"layout": {
"orderIndex": 910,
"sectionId": "sec_prompt_generated",
"orderIndex": order_index,
"sectionId": section_id,
"widthMode": "full",
"minHeightPx": 180,
"stickyHeader": False,
@@ -631,17 +698,34 @@ class PromptOrchestrator:
return labels.get(comp_type, "Oracle Canvas Component")
@staticmethod
def _default_viz_params(comp_type: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
def _default_viz_params(comp_type: str, dataset: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
first_row = rows[0] if rows else {}
inferred_columns = [key for key in first_row.keys() if key not in {"avatar"}] or ["name", "status"]
table_columns_by_dataset: dict[str, list[str]] = {
"broker_performance": ["name", "deals_closed", "revenue_generated"],
"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"],
}
defaults: dict[str, dict[str, Any]] = {
"bar_chart": {"xAxis": "category", "yAxis": "value", "sort": "desc", "showLabels": True, "legend": False},
"line_chart": {"showPoints": True, "smooth": True},
"kpi_tile": {
"label": rows[0].get("metric_label", "Result") if rows else "Result",
"trend": str(rows[0].get("trend_value", "")) if rows else "",
"comparisonLabel": rows[0].get("comparison_label", "") if rows else "",
"label": first_row.get("metric_label", "Result"),
"trend": str(first_row.get("trend_value", "")),
"comparisonLabel": first_row.get("comparison_label", ""),
},
"geo_map": {"mapStyle": "dubai_district_heat", "intensityField": "lead_count", "interactive": True, "tooltipFields": ["district", "lead_count", "avg_qd_score"]},
"table": {"rankBy": "revenue_generated", "showTopBadge": True, "columns": ["name", "deals_closed", "revenue_generated"]},
"table": {
"rankBy": "revenue_generated",
"showTopBadge": True,
"columns": table_columns_by_dataset.get(
dataset,
inferred_columns,
),
"emptyStateTitle": "No matching records found",
"emptyStateDescription": "The query ran successfully but returned no rows for this prompt.",
},
"pipeline_board": {"showValue": True, "colorByStage": True},
"activity_stream": {"showUrgencyIndicator": True},
}
@@ -674,7 +758,8 @@ class PromptOrchestrator:
def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str:
count = len(viz_plan.get("components", []))
short_prompt = prompt[:60] + ("" if len(prompt) > 60 else "")
return f'Generated {count} component{"s" if count != 1 else ""} for: "{short_prompt}"'
data_component_count = max(count - 1, 0)
return f'Generated {data_component_count} component{"s" if data_component_count != 1 else ""} for: "{short_prompt}"'
@staticmethod
def _error_component(
@@ -686,6 +771,7 @@ class PromptOrchestrator:
dataset: str,
warnings: list[str],
order_index: int,
section_id: str,
) -> dict[str, Any]:
return {
"componentId": component_id,
@@ -722,7 +808,7 @@ class PromptOrchestrator:
"renderingHints": {"estimatedHeightPx": 140, "skeletonVariant": "generic", "virtualizationPriority": 5},
"layout": {
"orderIndex": order_index,
"sectionId": "sec_prompt_generated",
"sectionId": section_id,
"widthMode": "full",
"minHeightPx": 140,
"stickyHeader": False,
@@ -875,8 +961,8 @@ class PromptOrchestrator:
execution["status"],
execution["modelRuntime"],
execution["semanticModelVersion"],
json.dumps(execution.get("retrievalPlan") or {}),
json.dumps(execution.get("visualizationPlan") or {}),
json.dumps(_json_safe(execution.get("retrievalPlan") or {})),
json.dumps(_json_safe(execution.get("visualizationPlan") or {})),
execution.get("warnings", []),
execution.get("summary"),
execution.get("componentsCreated", []),

View File

@@ -257,13 +257,16 @@ async def create_fork(
page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page:
raise HTTPException(status_code=404, detail="Source page not found.")
fork = await collaboration_service.create_fork(
source_page=page,
recipient_user_id=payload.recipientUserId,
created_by=ctx.actor_id,
visibility=payload.visibility,
message=payload.message,
)
try:
fork = await collaboration_service.create_fork(
source_page=page,
recipient_user_id=payload.recipientUserId,
created_by=ctx.actor_id,
visibility=payload.visibility,
message=payload.message,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _ok(fork)