feat: Oracle Canvas Component Schema and Qwen 3.6 integration (#31)
Co-authored-by: Sagnik <sagnik7896@gmail.com> Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
@@ -17,8 +17,23 @@ except Exception: # pragma: no cover
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
@@ -314,8 +329,8 @@ class OracleActionService:
|
||||
json.dumps(action.get("componentIds") or []),
|
||||
json.dumps(action.get("writebackPayload") or {}),
|
||||
json.dumps(action.get("resultPayload") or {}),
|
||||
action["createdAt"],
|
||||
action["updatedAt"],
|
||||
_coerce_datetime(action["createdAt"]),
|
||||
_coerce_datetime(action["updatedAt"]),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
@@ -338,8 +353,8 @@ class OracleActionService:
|
||||
"componentIds": row["component_ids"] or [],
|
||||
"writebackPayload": row["writeback_payload"] or {},
|
||||
"resultPayload": row["result_payload"] or {},
|
||||
"createdAt": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updatedAt": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
"createdAt": _iso(row["created_at"]),
|
||||
"updatedAt": _iso(row["updated_at"]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -57,13 +57,37 @@ def _stringify(value: Any) -> str:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def _json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
logger.warning("canvas_service: failed to parse JSON object field; using empty object")
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_component(component: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = deepcopy(component)
|
||||
normalized["componentId"] = _stringify(normalized.get("componentId"))
|
||||
descriptor = normalized.get("dataSourceDescriptor") or {}
|
||||
descriptor = _json_object(normalized.get("dataSourceDescriptor"))
|
||||
if descriptor.get("descriptorId") is not None:
|
||||
descriptor["descriptorId"] = _stringify(descriptor["descriptorId"])
|
||||
normalized["dataSourceDescriptor"] = descriptor
|
||||
for field in (
|
||||
"visualizationParameters",
|
||||
"dataBindings",
|
||||
"provenance",
|
||||
"renderingHints",
|
||||
"layout",
|
||||
"accessControls",
|
||||
"styleSignature",
|
||||
"validationState",
|
||||
):
|
||||
normalized[field] = _json_object(normalized.get(field))
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -105,7 +129,7 @@ def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[st
|
||||
"isShared": bool(row["is_shared"]),
|
||||
"headRevision": head_revision,
|
||||
"baseRevision": int(row["base_revision"]),
|
||||
"sharingPolicy": row["sharing_policy"] or {
|
||||
"sharingPolicy": _json_object(row["sharing_policy"]) or {
|
||||
"shareMode": "direct_fork_only",
|
||||
"allowReshare": False,
|
||||
"defaultForkVisibility": "private",
|
||||
|
||||
340
backend/oracle/codebook_service.py
Normal file
340
backend/oracle/codebook_service.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
oracle/codebook_service.py
|
||||
Loads, normalizes, and retrieves Oracle Canvas codebook examples from the
|
||||
expanded GPT and Claude seed packs delivered in Sprint 1.
|
||||
|
||||
The runtime treats the GPT pack as the primary normalized corpus and uses the
|
||||
Claude pack as a supplement when it adds unique examples or metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
_STOPWORDS = {
|
||||
"a", "an", "and", "as", "at", "build", "canvas", "chart", "client", "clients",
|
||||
"for", "from", "get", "give", "in", "into", "is", "list", "me", "of", "on",
|
||||
"or", "oracle", "please", "render", "show", "surface", "that", "the", "this",
|
||||
"to", "view", "with",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodebookExample:
|
||||
example_id: str
|
||||
chapter_id: str
|
||||
chapter_name: str
|
||||
subchapter_id: str
|
||||
subchapter_name: str
|
||||
title: str
|
||||
template_name: str
|
||||
component_type: str
|
||||
accepted_shapes: tuple[str, ...]
|
||||
example_json: dict[str, Any]
|
||||
quality_notes: str
|
||||
is_canonical: bool
|
||||
source_pack: str
|
||||
surface_targets: tuple[str, ...]
|
||||
policy_tags: tuple[str, ...]
|
||||
backend_contract_hints: dict[str, Any]
|
||||
score_terms: tuple[str, ...]
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _safe_load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _tokenize(value: str) -> list[str]:
|
||||
lowered = value.lower()
|
||||
return [tok for tok in _TOKEN_RE.findall(lowered) if tok not in _STOPWORDS and len(tok) > 1]
|
||||
|
||||
|
||||
def _make_template_id(example: dict[str, Any]) -> str:
|
||||
base = "|".join(
|
||||
[
|
||||
example.get("chapter_id", ""),
|
||||
example.get("subchapter_id", ""),
|
||||
example.get("template_name", ""),
|
||||
example.get("component_type", ""),
|
||||
]
|
||||
)
|
||||
return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _chapter_maps(payload: dict[str, Any]) -> tuple[dict[str, str], dict[str, str]]:
|
||||
chapters: dict[str, str] = {}
|
||||
subchapters: dict[str, str] = {}
|
||||
for chapter in payload.get("chapters", []):
|
||||
chapter_id = str(chapter.get("chapter_id", "")).strip()
|
||||
if chapter_id:
|
||||
chapters[chapter_id] = str(chapter.get("name", "")).strip()
|
||||
for subchapter in chapter.get("subchapters", []):
|
||||
sub_id = str(subchapter.get("subchapter_id", "")).strip()
|
||||
if sub_id:
|
||||
subchapters[sub_id] = str(subchapter.get("name", "")).strip()
|
||||
return chapters, subchapters
|
||||
|
||||
|
||||
def _normalize_examples(payload: dict[str, Any], source_pack: str) -> list[CodebookExample]:
|
||||
chapter_names, subchapter_names = _chapter_maps(payload)
|
||||
raw_examples = payload.get("seed_examples") or payload.get("examples") or []
|
||||
normalized: list[CodebookExample] = []
|
||||
for raw in raw_examples:
|
||||
chapter_id = str(raw.get("chapter_id", "")).strip()
|
||||
subchapter_id = str(raw.get("subchapter_id", "")).strip()
|
||||
title = str(raw.get("title") or raw.get("template_name") or "Oracle Component").strip()
|
||||
template_name = str(raw.get("template_name") or title).strip()
|
||||
component_type = str(raw.get("component_type") or "summary_card").strip()
|
||||
example_json = raw.get("example_json") or {}
|
||||
terms = _tokenize(
|
||||
" ".join(
|
||||
[
|
||||
title,
|
||||
template_name,
|
||||
component_type.replace("_", " "),
|
||||
chapter_names.get(chapter_id, ""),
|
||||
subchapter_names.get(subchapter_id, ""),
|
||||
str(raw.get("quality_notes", "")),
|
||||
" ".join(raw.get("policy_tags", []) or []),
|
||||
]
|
||||
)
|
||||
)
|
||||
normalized.append(
|
||||
CodebookExample(
|
||||
example_id=str(raw.get("example_id") or _make_template_id(raw)),
|
||||
chapter_id=chapter_id,
|
||||
chapter_name=chapter_names.get(chapter_id, chapter_id),
|
||||
subchapter_id=subchapter_id,
|
||||
subchapter_name=subchapter_names.get(subchapter_id, subchapter_id),
|
||||
title=title,
|
||||
template_name=template_name,
|
||||
component_type=component_type,
|
||||
accepted_shapes=tuple(raw.get("accepted_shapes") or []),
|
||||
example_json=example_json,
|
||||
quality_notes=str(raw.get("quality_notes") or ""),
|
||||
is_canonical=bool(raw.get("is_canonical")),
|
||||
source_pack=source_pack,
|
||||
surface_targets=tuple(raw.get("surface_targets") or []),
|
||||
policy_tags=tuple(raw.get("policy_tags") or []),
|
||||
backend_contract_hints=dict(raw.get("backend_contract_hints") or {}),
|
||||
score_terms=tuple(terms),
|
||||
)
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
class OracleCodebookService:
|
||||
def __init__(self) -> None:
|
||||
root = _repo_root()
|
||||
self.runtime_merged_path = root / "backend" / "oracle" / "oracle_runtime_codebook_merged.json"
|
||||
self.primary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "GPT 5.4" / "oracle_canvas_json_expansion_pack" / "db" / "oracle_template_seed_db_expanded_v1.pretty.json"
|
||||
self.secondary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "Claude Sonnet 4.6" / "oracle_template_expansion" / "oracle_template_seed_db_expanded.json"
|
||||
self.fallback_path = root / "backend" / "oracle" / "oracle_template_seed_db.json"
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load(self) -> dict[str, Any]:
|
||||
corpora: list[CodebookExample] = []
|
||||
sources_loaded: list[str] = []
|
||||
source_paths: list[tuple[Path, str]]
|
||||
if self.runtime_merged_path.exists():
|
||||
source_paths = [
|
||||
(self.runtime_merged_path, "runtime_merged"),
|
||||
(self.fallback_path, "runtime_seed_fallback"),
|
||||
]
|
||||
else:
|
||||
source_paths = [
|
||||
(self.primary_path, "gpt_5_4"),
|
||||
(self.secondary_path, "claude_sonnet_4_6"),
|
||||
(self.fallback_path, "runtime_seed_fallback"),
|
||||
]
|
||||
|
||||
for path, label in source_paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
payload = _safe_load_json(path)
|
||||
examples = _normalize_examples(payload, label)
|
||||
if examples:
|
||||
corpora.extend(examples)
|
||||
sources_loaded.append(f"{label}:{len(examples)}")
|
||||
|
||||
deduped: dict[tuple[str, str, str], CodebookExample] = {}
|
||||
for example in corpora:
|
||||
key = (example.subchapter_id, example.template_name.lower(), example.title.lower())
|
||||
existing = deduped.get(key)
|
||||
if existing is None:
|
||||
deduped[key] = example
|
||||
continue
|
||||
# Prefer canonical GPT examples, then canonical examples, then richer source pack.
|
||||
if example.source_pack == "gpt_5_4" and existing.source_pack != "gpt_5_4":
|
||||
deduped[key] = example
|
||||
elif example.is_canonical and not existing.is_canonical:
|
||||
deduped[key] = example
|
||||
|
||||
examples = list(deduped.values())
|
||||
logger.info("Oracle codebook loaded from %s", ", ".join(sources_loaded) or "no sources")
|
||||
return {
|
||||
"examples": examples,
|
||||
"source_summary": sources_loaded,
|
||||
"template_count": len({(e.chapter_id, e.subchapter_id, e.template_name, e.component_type) for e in examples}),
|
||||
}
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
data = self.load()
|
||||
examples: list[CodebookExample] = data["examples"]
|
||||
return {
|
||||
"example_count": len(examples),
|
||||
"template_count": data["template_count"],
|
||||
"source_summary": data["source_summary"],
|
||||
}
|
||||
|
||||
def list_templates(
|
||||
self,
|
||||
*,
|
||||
category: str | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
del status # runtime codebook templates are always active catalog entries
|
||||
examples: list[CodebookExample] = self.load()["examples"]
|
||||
templates: dict[str, dict[str, Any]] = {}
|
||||
for example in examples:
|
||||
if category and category.lower() not in {example.chapter_name.lower(), example.subchapter_name.lower()}:
|
||||
continue
|
||||
if search:
|
||||
terms = set(example.score_terms)
|
||||
if not set(_tokenize(search)).intersection(terms):
|
||||
continue
|
||||
template_id = _make_template_id(
|
||||
{
|
||||
"chapter_id": example.chapter_id,
|
||||
"subchapter_id": example.subchapter_id,
|
||||
"template_name": example.template_name,
|
||||
"component_type": example.component_type,
|
||||
}
|
||||
)
|
||||
record = templates.get(template_id)
|
||||
if record is None:
|
||||
templates[template_id] = {
|
||||
"templateId": template_id,
|
||||
"tenantId": "_system",
|
||||
"name": example.template_name,
|
||||
"category": example.chapter_name,
|
||||
"status": "catalog_active",
|
||||
"origin": "premade",
|
||||
"version": "codebook-v2",
|
||||
"acceptedShapes": list(example.accepted_shapes),
|
||||
"description": f"{example.subchapter_name} · {example.title}",
|
||||
"chapterId": example.chapter_id,
|
||||
"subchapterId": example.subchapter_id,
|
||||
"componentType": example.component_type,
|
||||
"sourcePack": example.source_pack,
|
||||
"useCount": 0,
|
||||
"updatedAt": None,
|
||||
"createdAt": None,
|
||||
}
|
||||
ordered = list(templates.values())
|
||||
ordered.sort(key=lambda item: (item["category"], item["name"]))
|
||||
total = len(ordered)
|
||||
return {
|
||||
"total": total,
|
||||
"templates": ordered[offset: offset + limit],
|
||||
}
|
||||
|
||||
def search_examples(self, prompt: str, *, limit: int = 8) -> list[CodebookExample]:
|
||||
prompt_terms = set(_tokenize(prompt))
|
||||
if not prompt_terms:
|
||||
prompt_terms = set(_tokenize(prompt.replace("_", " ")))
|
||||
|
||||
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:
|
||||
score += 20
|
||||
if example.chapter_name.lower() in lowered_prompt:
|
||||
score += 14
|
||||
if example.component_type.replace("_", " ") in lowered_prompt:
|
||||
score += 12
|
||||
if example.is_canonical:
|
||||
score += 8
|
||||
if "live_data_first" in example.policy_tags:
|
||||
score += 4
|
||||
if score > 0:
|
||||
scored.append((score, example))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], item[1].chapter_id, item[1].subchapter_id, item[1].title))
|
||||
selected: list[CodebookExample] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for _, example in scored:
|
||||
dedupe_key = (example.subchapter_id, example.template_name)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(dedupe_key)
|
||||
selected.append(example)
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
return selected
|
||||
|
||||
def synthesize_template(self, prompt: str, data_shapes: list[str] | None = None) -> dict[str, Any]:
|
||||
match = next(iter(self.search_examples(prompt, limit=1)), None)
|
||||
shapes = data_shapes or []
|
||||
if match is None:
|
||||
return {
|
||||
"templateId": hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:16],
|
||||
"tenantId": "_system",
|
||||
"name": "Oracle Synthesized Draft",
|
||||
"category": "Custom",
|
||||
"status": "tenant_draft",
|
||||
"origin": "synthesized",
|
||||
"version": "1.0.0",
|
||||
"acceptedShapes": shapes,
|
||||
"description": f"Draft synthesized from prompt: {prompt[:120]}",
|
||||
}
|
||||
|
||||
return {
|
||||
"templateId": _make_template_id(
|
||||
{
|
||||
"chapter_id": match.chapter_id,
|
||||
"subchapter_id": match.subchapter_id,
|
||||
"template_name": match.template_name,
|
||||
"component_type": match.component_type,
|
||||
}
|
||||
),
|
||||
"tenantId": "_system",
|
||||
"name": match.template_name,
|
||||
"category": match.chapter_name,
|
||||
"status": "catalog_active",
|
||||
"origin": "premade",
|
||||
"version": "codebook-v2",
|
||||
"acceptedShapes": list(match.accepted_shapes or shapes),
|
||||
"description": f"Best codebook match · {match.subchapter_name}",
|
||||
"componentType": match.component_type,
|
||||
"chapterId": match.chapter_id,
|
||||
"subchapterId": match.subchapter_id,
|
||||
"sourcePack": match.source_pack,
|
||||
"exampleJson": match.example_json,
|
||||
}
|
||||
|
||||
|
||||
codebook_service = OracleCodebookService()
|
||||
@@ -236,6 +236,86 @@ class DataAccessGateway:
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
if dataset == "crm_contacts_overview":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(p.city, '') AS city,
|
||||
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
|
||||
COALESCE(q.qd_score, 0)::float AS qd_score
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT qd_score
|
||||
FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.scored_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
ORDER BY qd_score DESC, p.full_name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_opportunity_pipeline":
|
||||
sql = """
|
||||
SELECT
|
||||
o.stage::text AS stage,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(o.value), 0)::float AS value,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', o.opportunity_id,
|
||||
'name', p.full_name,
|
||||
'company', COALESCE(a.account_name, ''),
|
||||
'value', COALESCE(o.value, 0),
|
||||
'nextAction', COALESCE(o.next_action, '')
|
||||
)
|
||||
ORDER BY o.value DESC NULLS LAST
|
||||
) FILTER (WHERE o.opportunity_id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
FROM crm_opportunities o
|
||||
JOIN crm_leads l ON l.lead_id = o.lead_id
|
||||
JOIN crm_people p ON p.person_id = l.person_id
|
||||
LEFT JOIN crm_accounts a ON a.account_id = l.account_id
|
||||
GROUP BY o.stage
|
||||
ORDER BY COALESCE(SUM(o.value), 0) DESC, o.stage::text ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_property_interest_rollup":
|
||||
sql = """
|
||||
SELECT
|
||||
project_name AS category,
|
||||
COUNT(*)::int AS value,
|
||||
ROUND(AVG(COALESCE((budget_min + budget_max) / 2.0, budget_max, budget_min, 0)), 2)::float AS average_budget
|
||||
FROM crm_property_interests
|
||||
GROUP BY project_name
|
||||
ORDER BY value DESC, project_name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_interaction_timeline":
|
||||
sql = """
|
||||
SELECT
|
||||
i.interaction_type AS type,
|
||||
COALESCE(i.summary, i.interaction_type) AS title,
|
||||
CONCAT(p.full_name, ' · ', i.channel::text) AS summary,
|
||||
p.full_name AS actor,
|
||||
TO_CHAR(i.happened_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
FROM intel_interactions i
|
||||
JOIN crm_people p ON p.person_id = i.person_id
|
||||
ORDER BY i.happened_at DESC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
raise ValueError(f"Dataset '{dataset}' is not whitelisted for Oracle execution.")
|
||||
|
||||
|
||||
|
||||
153597
backend/oracle/oracle_runtime_codebook_merged.json
Normal file
153597
backend/oracle/oracle_runtime_codebook_merged.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ from .policy_service import PolicyContext, PolicyService
|
||||
from .canvas_service import canvas_service
|
||||
from .data_access_gateway import data_access_gateway
|
||||
from .persona_service import persona_service
|
||||
from .codebook_service import codebook_service, CodebookExample
|
||||
from backend.services.runtime_llm_service import runtime_llm_service
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
|
||||
try:
|
||||
@@ -26,15 +28,30 @@ except Exception: # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NEMOCLAW_URL = os.getenv("NEMOCLAW_API_URL", "")
|
||||
_NEMOCLAW_API_KEY = os.getenv("NEMOCLAW_API_KEY", "")
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
|
||||
policy_svc = PolicyService()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ── Execution store ───────────────────────────────────────────────────────────
|
||||
@@ -52,10 +69,10 @@ _INTENT_KEYWORDS: dict[str, list[str]] = {
|
||||
"pipeline_board": ["pipeline", "stage", "kanban", "deal", "funnel"],
|
||||
"bar_chart": ["bar", "compare", "source", "channel", "distribution", "ranked", "lead", "whale"],
|
||||
"geo_map": ["map", "geographic", "location", "district", "region", "area", "dubai"],
|
||||
"table": ["table", "list", "broker", "performance", "leaderboard", "rank", "top"],
|
||||
"table": ["table", "list", "broker", "performance", "leaderboard", "rank", "top", "contact", "client", "account", "crm"],
|
||||
"line_chart": ["trend", "time", "monthly", "weekly", "absorption", "forecast"],
|
||||
"kpi_tile": ["kpi", "total", "summary", "attainment", "quota", "how many"],
|
||||
"activity_stream": ["timeline", "activity", "history", "follow-up", "queue", "contact"],
|
||||
"activity_stream": ["timeline", "activity", "history", "follow-up", "queue", "contact", "interaction", "message", "call", "email"],
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +126,129 @@ _DATASET_MAP: dict[str, str] = {
|
||||
"activity_stream": "lead_activity_log",
|
||||
}
|
||||
|
||||
_CODEBOOK_COMPONENT_MAP: dict[str, str] = {
|
||||
"summary_card": "kpi_tile",
|
||||
"summary_strip": "kpi_tile",
|
||||
"metric_card_group": "kpi_tile",
|
||||
"compact_alert_card": "kpi_tile",
|
||||
"gauge_stack": "kpi_tile",
|
||||
"lead_profile_card": "table",
|
||||
"property_card": "table",
|
||||
"data_table": "table",
|
||||
"leaderboard_table": "table",
|
||||
"matrix_grid": "table",
|
||||
"interaction_timeline": "activity_stream",
|
||||
"message_thread_summary": "activity_stream",
|
||||
"timeline": "activity_stream",
|
||||
"bar_chart": "bar_chart",
|
||||
"line_chart": "line_chart",
|
||||
"heatmap": "geo_map",
|
||||
"geo_map": "geo_map",
|
||||
"pipeline_board": "pipeline_board",
|
||||
}
|
||||
|
||||
|
||||
def _component_plan_type_from_codebook(example: CodebookExample) -> str:
|
||||
return _CODEBOOK_COMPONENT_MAP.get(example.component_type, "table")
|
||||
|
||||
|
||||
def _dataset_for_codebook(example: CodebookExample, prompt: str, component_plan_type: str | None = None) -> str:
|
||||
chapter = example.chapter_name.lower()
|
||||
subchapter = example.subchapter_name.lower()
|
||||
component_plan_type = component_plan_type or _component_plan_type_from_codebook(example)
|
||||
lowered_prompt = prompt.lower()
|
||||
|
||||
if component_plan_type == "activity_stream":
|
||||
return "crm_interaction_timeline"
|
||||
if component_plan_type == "pipeline_board":
|
||||
return "crm_opportunity_pipeline"
|
||||
if component_plan_type == "line_chart" and any(term in lowered_prompt for term in ("trend", "time", "history", "growth")):
|
||||
return "crm_property_interest_rollup"
|
||||
|
||||
if any(term in lowered_prompt for term in ("contact", "client 360", "crm", "account", "lead")):
|
||||
if "timeline" in lowered_prompt or "message" in lowered_prompt or "call" in lowered_prompt or "email" in lowered_prompt:
|
||||
return "crm_interaction_timeline"
|
||||
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:
|
||||
return "crm_property_interest_rollup"
|
||||
return "crm_contacts_overview"
|
||||
|
||||
if "client" in chapter or "client" in subchapter or "contact" in subchapter:
|
||||
return "crm_contacts_overview"
|
||||
if "opportun" in chapter or "pipeline" in subchapter:
|
||||
return "crm_opportunity_pipeline"
|
||||
if "interaction" in chapter or "communication" in chapter or "timeline" in subchapter:
|
||||
return "crm_interaction_timeline"
|
||||
if "property" in chapter or "inventory" in chapter or "interest" in subchapter:
|
||||
return "crm_property_interest_rollup"
|
||||
return _DATASET_MAP.get(component_plan_type, "oracle_aggregated_metric")
|
||||
|
||||
|
||||
def _build_codebook_retrieval_plan(
|
||||
prompt: str,
|
||||
tenant_id: str,
|
||||
actor_role: str,
|
||||
matches: list[CodebookExample],
|
||||
) -> dict[str, Any]:
|
||||
row_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
||||
desired_types = _detect_component_types(prompt)
|
||||
if not desired_types:
|
||||
desired_types = [_component_plan_type_from_codebook(matches[0])] if matches else ["table"]
|
||||
|
||||
title_hints: dict[str, str] = {}
|
||||
for example in matches:
|
||||
mapped = _component_plan_type_from_codebook(example)
|
||||
title_hints.setdefault(mapped, example.title)
|
||||
|
||||
components: list[dict[str, Any]] = []
|
||||
exemplar = matches[0]
|
||||
for component_plan_type in desired_types[:4]:
|
||||
dataset = _dataset_for_codebook(exemplar, prompt, component_plan_type)
|
||||
components.append(
|
||||
{
|
||||
"suggestedType": component_plan_type,
|
||||
"dataset": dataset,
|
||||
"privacyTier": "standard",
|
||||
"rowLimit": row_limit,
|
||||
"joins": [],
|
||||
"queryTemplate": f"SELECT * FROM {dataset} WHERE tenant_id = :tenant_id LIMIT :limit",
|
||||
"queryParameters": {"tenant_id": tenant_id, "limit": row_limit},
|
||||
"templateRef": {
|
||||
"exampleId": exemplar.example_id,
|
||||
"templateName": exemplar.template_name,
|
||||
"componentType": exemplar.component_type,
|
||||
"chapterName": exemplar.chapter_name,
|
||||
"subchapterName": exemplar.subchapter_name,
|
||||
"sourcePack": exemplar.source_pack,
|
||||
},
|
||||
"titleHint": title_hints.get(component_plan_type, exemplar.title),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"planId": str(uuid.uuid4()),
|
||||
"components": components,
|
||||
"semanticModelVersion": "oracle_codebook_v2026_04_19_01",
|
||||
"intentClass": "analytical",
|
||||
"planner": "codebook_retrieval",
|
||||
}
|
||||
|
||||
|
||||
_RUNTIME_ALLOWED_DATASETS = {
|
||||
"deals",
|
||||
"lead_daily_snapshot",
|
||||
"lead_geo_interest_rollup",
|
||||
"broker_performance",
|
||||
"inventory_absorption",
|
||||
"oracle_aggregated_metric",
|
||||
"lead_activity_log",
|
||||
"crm_contacts_overview",
|
||||
"crm_opportunity_pipeline",
|
||||
"crm_property_interest_rollup",
|
||||
"crm_interaction_timeline",
|
||||
}
|
||||
|
||||
|
||||
class PromptOrchestrator:
|
||||
"""
|
||||
@@ -155,18 +295,35 @@ class PromptOrchestrator:
|
||||
"prompt": prompt,
|
||||
"intentClass": "analytical",
|
||||
"status": "planning",
|
||||
"modelRuntime": "nemoclaw_hosted" if _NEMOCLAW_URL else "deterministic_fallback",
|
||||
"modelRuntime": "runtime_llm" if runtime_llm_service._provider_catalog() else "deterministic_fallback",
|
||||
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
|
||||
"warnings": warnings,
|
||||
"componentsCreated": [],
|
||||
"clientRequestId": client_request_id,
|
||||
"createdAt": now,
|
||||
"codebookMatches": [],
|
||||
}
|
||||
_DEMO_EXECUTIONS[execution_id] = execution
|
||||
await self._persist_execution(execution)
|
||||
|
||||
# ── Step 1: Build retrieval plan ──────────────────────────────────────
|
||||
if _NEMOCLAW_URL and _NEMOCLAW_API_KEY:
|
||||
codebook_matches = codebook_service.search_examples(prompt, limit=4)
|
||||
execution["codebookMatches"] = [
|
||||
{
|
||||
"exampleId": match.example_id,
|
||||
"templateName": match.template_name,
|
||||
"componentType": match.component_type,
|
||||
"chapterName": match.chapter_name,
|
||||
"subchapterName": match.subchapter_name,
|
||||
"sourcePack": match.source_pack,
|
||||
}
|
||||
for match in codebook_matches
|
||||
]
|
||||
|
||||
if codebook_matches:
|
||||
retrieval_plan = _build_codebook_retrieval_plan(prompt, tenant_id, actor_role, codebook_matches)
|
||||
execution["status"] = "validated"
|
||||
elif runtime_llm_service._provider_catalog():
|
||||
try:
|
||||
retrieval_plan = await self._call_nemoclaw(prompt, conversation_context or [], ctx)
|
||||
execution["status"] = "validated"
|
||||
@@ -298,7 +455,7 @@ class PromptOrchestrator:
|
||||
comp: dict[str, Any] = {
|
||||
"componentId": component_id,
|
||||
"type": mapped_type,
|
||||
"title": self._generate_title(prompt, ctype),
|
||||
"title": str(plan.get("titleHint") or self._generate_title(prompt, ctype)),
|
||||
"description": f"Generated from: \"{prompt[:80]}\"",
|
||||
"dataSourceDescriptor": {
|
||||
"descriptorId": str(uuid.uuid4()),
|
||||
@@ -321,7 +478,7 @@ class PromptOrchestrator:
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
"createdAt": _iso(_now()),
|
||||
},
|
||||
"renderingHints": self._rendering_hints(ctype),
|
||||
"layout": {
|
||||
@@ -413,7 +570,7 @@ class PromptOrchestrator:
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
"createdAt": _iso(_now()),
|
||||
},
|
||||
"renderingHints": {"estimatedHeightPx": 180, "skeletonVariant": "text", "virtualizationPriority": 4},
|
||||
"layout": {
|
||||
@@ -560,7 +717,7 @@ class PromptOrchestrator:
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
"createdAt": _iso(_now()),
|
||||
},
|
||||
"renderingHints": {"estimatedHeightPx": 140, "skeletonVariant": "generic", "virtualizationPriority": 5},
|
||||
"layout": {
|
||||
@@ -601,24 +758,80 @@ class PromptOrchestrator:
|
||||
ctx: PolicyContext,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calls the Nemoclaw hosted model endpoint.
|
||||
Raises on failure so the orchestrator can fall back to demo.
|
||||
Uses the shared runtime LLM service to propose a retrieval plan.
|
||||
Raises on malformed output so the orchestrator can fall back safely.
|
||||
"""
|
||||
import httpx # type: ignore
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{_NEMOCLAW_URL}/v1/oracle/plan",
|
||||
headers={"Authorization": f"Bearer {_NEMOCLAW_API_KEY}"},
|
||||
json={
|
||||
"prompt": prompt,
|
||||
"conversationContext": context,
|
||||
"tenantId": ctx.tenant_id,
|
||||
"actorRole": ctx.actor_role,
|
||||
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
|
||||
row_limit = 50 if ctx.actor_role in ("senior_broker", "junior_broker") else 200
|
||||
system_prompt = (
|
||||
"You are the Oracle planner for Project Velocity. "
|
||||
"Return JSON only. "
|
||||
"Choose up to 4 analytical components for the prompt. "
|
||||
"Allowed component types: pipeline_board, bar_chart, geo_map, table, line_chart, kpi_tile, activity_stream. "
|
||||
"Allowed datasets: deals, lead_daily_snapshot, lead_geo_interest_rollup, broker_performance, inventory_absorption, "
|
||||
"oracle_aggregated_metric, lead_activity_log, crm_contacts_overview, crm_opportunity_pipeline, "
|
||||
"crm_property_interest_rollup, crm_interaction_timeline. "
|
||||
"Return an object with keys semanticModelVersion, intentClass, components. "
|
||||
"Each component must include suggestedType, dataset, and titleHint. "
|
||||
"Do not emit SQL. Do not invent datasets outside the allowlist."
|
||||
)
|
||||
response = await runtime_llm_service.chat(
|
||||
provider_id=None,
|
||||
model=None,
|
||||
system_prompt=system_prompt,
|
||||
messages=[
|
||||
*context,
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"tenantId": ctx.tenant_id,
|
||||
"actorRole": ctx.actor_role,
|
||||
"rowLimit": row_limit,
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
temperature=0.1,
|
||||
response_format="json",
|
||||
metadata={"planner": "oracle_canvas"},
|
||||
)
|
||||
payload = response.get("message", {}).get("parsedJson") or {}
|
||||
components_payload = payload.get("components")
|
||||
if not isinstance(components_payload, list) or not components_payload:
|
||||
raise ValueError("Runtime LLM planner returned no components.")
|
||||
|
||||
normalized_components: list[dict[str, Any]] = []
|
||||
for raw_component in components_payload[:4]:
|
||||
if not isinstance(raw_component, dict):
|
||||
continue
|
||||
suggested_type = str(raw_component.get("suggestedType", "")).strip()
|
||||
dataset = str(raw_component.get("dataset", "")).strip()
|
||||
if suggested_type not in _DATASET_MAP or dataset not in _RUNTIME_ALLOWED_DATASETS:
|
||||
continue
|
||||
normalized_components.append(
|
||||
{
|
||||
"suggestedType": suggested_type,
|
||||
"dataset": dataset,
|
||||
"privacyTier": "standard",
|
||||
"rowLimit": row_limit,
|
||||
"joins": [],
|
||||
"queryTemplate": f"SELECT * FROM {dataset} WHERE tenant_id = :tenant_id LIMIT :limit",
|
||||
"queryParameters": {"tenant_id": ctx.tenant_id, "limit": row_limit},
|
||||
"titleHint": str(raw_component.get("titleHint", "")).strip() or self._generate_title(prompt, suggested_type),
|
||||
}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() # type: ignore[no-any-return]
|
||||
|
||||
if not normalized_components:
|
||||
raise ValueError("Runtime LLM planner returned no valid whitelisted components.")
|
||||
|
||||
return {
|
||||
"planId": str(uuid.uuid4()),
|
||||
"components": normalized_components,
|
||||
"semanticModelVersion": str(payload.get("semanticModelVersion") or "oracle_runtime_llm_v2026_04_19_01"),
|
||||
"intentClass": str(payload.get("intentClass") or "analytical"),
|
||||
"planner": "runtime_llm",
|
||||
}
|
||||
|
||||
async def get_execution(self, execution_id: str) -> dict[str, Any] | None:
|
||||
return _DEMO_EXECUTIONS.get(execution_id)
|
||||
@@ -668,8 +881,8 @@ class PromptOrchestrator:
|
||||
execution.get("summary"),
|
||||
execution.get("componentsCreated", []),
|
||||
execution.get("clientRequestId"),
|
||||
execution["createdAt"],
|
||||
execution.get("completedAt"),
|
||||
_coerce_datetime(execution["createdAt"]),
|
||||
_coerce_datetime(execution.get("completedAt")),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -26,20 +26,23 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Set
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from .canvas_service import canvas_service
|
||||
from .collaboration_service import collaboration_service
|
||||
from .action_service import oracle_action_service
|
||||
from .persona_service import persona_service
|
||||
from .prompt_orchestrator import prompt_orchestrator
|
||||
from .policy_service import PolicyService, PolicyContext
|
||||
from .codebook_service import codebook_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
policy_svc = PolicyService()
|
||||
_DEFAULT_TENANT_ID = os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity")
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,13 +54,32 @@ def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _build_user_profile(default_page_id: str) -> dict[str, Any]:
|
||||
def _normalize_oracle_role(role: str) -> str:
|
||||
mapping = {
|
||||
"JUNIOR_BROKER": "junior_broker",
|
||||
"SENIOR_BROKER": "senior_broker",
|
||||
"SALES_DIRECTOR": "sales_director",
|
||||
"ADMIN": "platform_admin",
|
||||
}
|
||||
return mapping.get(role.strip().upper(), "sales_director")
|
||||
|
||||
|
||||
def _build_user_profile(
|
||||
*,
|
||||
user_id: str,
|
||||
email: str,
|
||||
display_name: str,
|
||||
role: str,
|
||||
avatar_url: str | None,
|
||||
default_page_id: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"userId": os.getenv("ORACLE_DEFAULT_USER_ID", "oracle_operator"),
|
||||
"tenantId": os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity"),
|
||||
"email": os.getenv("ORACLE_DEFAULT_EMAIL", "oracle@velocity.local"),
|
||||
"displayName": os.getenv("ORACLE_DEFAULT_DISPLAY_NAME", "Oracle Operator"),
|
||||
"role": os.getenv("ORACLE_DEFAULT_ROLE", "sales_director"),
|
||||
"userId": user_id,
|
||||
"tenantId": _DEFAULT_TENANT_ID,
|
||||
"email": email,
|
||||
"displayName": display_name,
|
||||
"role": _normalize_oracle_role(role),
|
||||
"avatarUrl": avatar_url,
|
||||
"timezone": os.getenv("ORACLE_DEFAULT_TIMEZONE", "Asia/Dubai"),
|
||||
"locale": os.getenv("ORACLE_DEFAULT_LOCALE", "en-AE"),
|
||||
"defaultPageId": default_page_id,
|
||||
@@ -72,17 +94,39 @@ def _build_user_profile(default_page_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def _get_current_user() -> dict[str, Any]:
|
||||
async def _get_current_user_profile(request: Request, user: UserPrincipal) -> dict[str, Any]:
|
||||
seed_page = await canvas_service.ensure_default_page(
|
||||
tenant_id=os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity"),
|
||||
owner_id=os.getenv("ORACLE_DEFAULT_USER_ID", "oracle_operator"),
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
owner_id=user.user_id,
|
||||
title=os.getenv("ORACLE_DEFAULT_PAGE_TITLE", "Oracle Main Canvas"),
|
||||
)
|
||||
return _build_user_profile(seed_page["pageId"])
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(full_name, split_part(email, '@', 1), id::text) AS display_name,
|
||||
COALESCE(email, id::text || '@velocity.local') AS email,
|
||||
avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
user.user_id,
|
||||
)
|
||||
return _build_user_profile(
|
||||
user_id=user.user_id,
|
||||
email=row["email"] if row else f"{user.user_id}@velocity.local",
|
||||
display_name=row["display_name"] if row else user.user_id,
|
||||
role=user.role,
|
||||
avatar_url=row["avatar_url"] if row else None,
|
||||
default_page_id=seed_page["pageId"],
|
||||
)
|
||||
|
||||
|
||||
async def _ctx_from_me() -> PolicyContext:
|
||||
me = await _get_current_user()
|
||||
async def _ctx_from_request(request: Request, user: UserPrincipal) -> PolicyContext:
|
||||
me = await _get_current_user_profile(request, user)
|
||||
return PolicyContext(
|
||||
tenant_id=me["tenantId"],
|
||||
actor_id=me["userId"],
|
||||
@@ -143,13 +187,13 @@ class PersonaRenderRequest(BaseModel):
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/me", summary="Get current user profile")
|
||||
async def get_me() -> dict:
|
||||
return _ok(await _get_current_user())
|
||||
async def get_me(request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||
return _ok(await _get_current_user_profile(request, user))
|
||||
|
||||
|
||||
@router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID")
|
||||
async def get_canvas_page(page_id: str) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Canvas page '{page_id}' not found.")
|
||||
@@ -157,8 +201,13 @@ async def get_canvas_page(page_id: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components")
|
||||
async def submit_prompt(page_id: str, payload: PromptSubmitRequest) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def submit_prompt(
|
||||
page_id: str,
|
||||
payload: PromptSubmitRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
execution = await prompt_orchestrator.execute(
|
||||
tenant_id=ctx.tenant_id,
|
||||
page_id=page_id,
|
||||
@@ -198,8 +247,13 @@ async def submit_prompt(page_id: str, payload: PromptSubmitRequest) -> dict:
|
||||
|
||||
|
||||
@router.post("/canvas-pages/{page_id}/forks", summary="Create a fork (share) from a canvas page")
|
||||
async def create_fork(page_id: str, payload: ForkCreateRequest) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def create_fork(
|
||||
page_id: str,
|
||||
payload: ForkCreateRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Source page not found.")
|
||||
@@ -214,8 +268,13 @@ async def create_fork(page_id: str, payload: ForkCreateRequest) -> dict:
|
||||
|
||||
|
||||
@router.post("/canvas-pages/{page_id}/rollback", summary="Rollback canvas to a prior revision")
|
||||
async def rollback_canvas(page_id: str, payload: RollbackRequest) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def rollback_canvas(
|
||||
page_id: str,
|
||||
payload: RollbackRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
result = await canvas_service.rollback(
|
||||
page_id=page_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
@@ -232,38 +291,44 @@ async def rollback_canvas(page_id: str, payload: RollbackRequest) -> dict:
|
||||
|
||||
|
||||
@router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page")
|
||||
async def list_revisions(page_id: str) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||
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)})
|
||||
|
||||
|
||||
@router.get("/component-templates", summary="List component templates")
|
||||
async def list_templates(category: str | None = None, status: str | None = None) -> dict:
|
||||
templates = PREMADE_TEMPLATES
|
||||
if category:
|
||||
templates = [t for t in templates if t["category"] == category]
|
||||
if status:
|
||||
templates = [t for t in templates if t["status"] == status]
|
||||
return _ok(templates, meta={"count": len(templates)})
|
||||
async def list_templates(
|
||||
category: str | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
result = codebook_service.list_templates(
|
||||
category=category,
|
||||
status=status,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _ok(result["templates"], meta={"count": result["total"], "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@router.post("/component-templates/synthesize", summary="Synthesize a new component template from a prompt")
|
||||
async def synthesize_template(payload: TemplateSynthesizeRequest) -> dict:
|
||||
me = await _get_current_user()
|
||||
# Stub — full implementation requires Nemoclaw model runtime
|
||||
template = {
|
||||
"templateId": str(uuid.uuid4()),
|
||||
"tenantId": me["tenantId"],
|
||||
"name": "Synthesized Component",
|
||||
"category": "custom",
|
||||
"status": "tenant_draft",
|
||||
"origin": "synthesized",
|
||||
"version": "1.0.0",
|
||||
"acceptedShapes": payload.dataShape,
|
||||
"createdAt": _now(),
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
async def synthesize_template(
|
||||
payload: TemplateSynthesizeRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
me = await _get_current_user_profile(request, user)
|
||||
template = codebook_service.synthesize_template(
|
||||
prompt=payload.prompt,
|
||||
data_shapes=payload.dataShape,
|
||||
)
|
||||
template["tenantId"] = me["tenantId"]
|
||||
template.setdefault("createdAt", _now())
|
||||
template.setdefault("updatedAt", _now())
|
||||
return _ok(template)
|
||||
|
||||
|
||||
@@ -293,8 +358,12 @@ async def list_merge_requests(targetPageId: str | None = None, status: str | Non
|
||||
|
||||
|
||||
@router.post("/merge-requests", summary="Open a merge request")
|
||||
async def create_merge_request(payload: MergeRequestCreateRequest) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def create_merge_request(
|
||||
payload: MergeRequestCreateRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
source_page = await canvas_service.get_page(payload.sourcePageId, ctx.tenant_id)
|
||||
target_page = await canvas_service.get_page(payload.targetPageId, ctx.tenant_id)
|
||||
if not source_page or not target_page:
|
||||
@@ -319,8 +388,13 @@ async def create_merge_request(payload: MergeRequestCreateRequest) -> dict:
|
||||
|
||||
|
||||
@router.post("/merge-requests/{mr_id}/review", summary="Submit a merge request review")
|
||||
async def review_merge_request(mr_id: str, payload: MergeReviewRequest) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
async def review_merge_request(
|
||||
mr_id: str,
|
||||
payload: MergeReviewRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
mr = await collaboration_service.review_merge_request(
|
||||
mr_id=mr_id,
|
||||
decision=payload.decision,
|
||||
@@ -382,17 +456,3 @@ async def oracle_canvas_ws(ws: WebSocket, page_id: str) -> None:
|
||||
|
||||
# ── Pre-made templates seed ───────────────────────────────────────────────────
|
||||
|
||||
PREMADE_TEMPLATES = [
|
||||
{"templateId": "tpl_kpi_pipeline_health_v1", "tenantId": "_system", "name": "Pipeline Health KPI", "category": "Executive overview", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["scalar", "trend_scalar"]},
|
||||
{"templateId": "tpl_bar_source_quality_v3", "tenantId": "_system", "name": "Lead Source Quality Bar", "category": "Lead quality", "status": "catalog_active", "origin": "premade", "version": "3.0.0", "acceptedShapes": ["categorical_aggregate"]},
|
||||
{"templateId": "tpl_geo_investor_heat_v2", "tenantId": "_system", "name": "Investor Geography Heat Map", "category": "Geographic demand", "status": "catalog_active", "origin": "premade", "version": "2.0.0", "acceptedShapes": ["geospatial_aggregate"]},
|
||||
{"templateId": "tpl_pipeline_board_v2", "tenantId": "_system", "name": "Deal Pipeline Board", "category": "Pipeline management", "status": "catalog_active", "origin": "premade", "version": "2.0.0", "acceptedShapes": ["categorical_records"]},
|
||||
{"templateId": "tpl_broker_performance_v1", "tenantId": "_system", "name": "Broker Performance Ranked", "category": "Broker performance", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["ranked_records"]},
|
||||
{"templateId": "tpl_followup_queue_v1", "tenantId": "_system", "name": "Follow-up Queue", "category": "Operational queues", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["task_records"]},
|
||||
{"templateId": "tpl_investor_timeline_v1", "tenantId": "_system", "name": "Investor Timeline", "category": "Investor timelines", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["chronological_events"]},
|
||||
{"templateId": "tpl_absorption_trend_v1", "tenantId": "_system", "name": "Project Absorption Trend", "category": "Inventory and project analytics", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["time_series"]},
|
||||
{"templateId": "tpl_quota_gauge_v1", "tenantId": "_system", "name": "Quota Attainment Gauge", "category": "Executive overview", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["scalar"]},
|
||||
{"templateId": "tpl_campaign_lead_line_v1", "tenantId": "_system", "name": "Campaign-to-Lead Quality Timeline", "category": "Marketing analytics", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["time_series"]},
|
||||
{"templateId": "tpl_followup_gap_v1", "tenantId": "_system", "name": "Follow-up Gap Report", "category": "Operational queues", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["task_records"]},
|
||||
{"templateId": "tpl_qd_source_compare_v1", "tenantId": "_system", "name": "QD-Weighted Source Comparison", "category": "Lead quality", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["categorical_aggregate"]},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user