Built the Oracle Tab (#14)
This commit is contained in:
1
backend/oracle/__init__.py
Normal file
1
backend/oracle/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Oracle services package
|
||||
596
backend/oracle/canvas_service.py
Normal file
596
backend/oracle/canvas_service.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""
|
||||
oracle/canvas_service.py
|
||||
Canvas persistence for Oracle pages, revisions, and current component projections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import asyncpg # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
asyncpg = None # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
|
||||
_DEMO_PAGES: dict[str, dict[str, Any]] = {}
|
||||
_DEMO_REVISIONS: dict[str, list[dict[str, Any]]] = {}
|
||||
_DEMO_COMPONENTS: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _allow_in_memory() -> bool:
|
||||
return (
|
||||
os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
|
||||
or "PYTEST_CURRENT_TEST" in os.environ
|
||||
)
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
|
||||
|
||||
|
||||
def _is_demo() -> bool:
|
||||
return not _db_ready() and _allow_in_memory()
|
||||
|
||||
|
||||
def _ensure_ready() -> None:
|
||||
if _db_ready() or _is_demo():
|
||||
return
|
||||
if asyncpg is None:
|
||||
raise RuntimeError("Oracle backend requires asyncpg to connect to PostgreSQL.")
|
||||
raise RuntimeError("Oracle backend requires DATABASE_URL for production persistence.")
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def _normalize_component(component: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = deepcopy(component)
|
||||
normalized["componentId"] = _stringify(normalized.get("componentId"))
|
||||
descriptor = normalized.get("dataSourceDescriptor") or {}
|
||||
if descriptor.get("descriptorId") is not None:
|
||||
descriptor["descriptorId"] = _stringify(descriptor["descriptorId"])
|
||||
normalized["dataSourceDescriptor"] = descriptor
|
||||
return normalized
|
||||
|
||||
|
||||
def _deserialize_component_row(row: Any) -> dict[str, Any]:
|
||||
return _normalize_component(
|
||||
{
|
||||
"componentId": _stringify(row["component_id"]),
|
||||
"type": row["type"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"version": row["version"],
|
||||
"lifecycleState": row["lifecycle_state"],
|
||||
"dataSourceDescriptor": row["data_source_descriptor"],
|
||||
"visualizationParameters": row["visualization_parameters"],
|
||||
"dataBindings": row["data_bindings"],
|
||||
"provenance": row["provenance"],
|
||||
"renderingHints": row["rendering_hints"],
|
||||
"layout": row["layout"],
|
||||
"accessControls": row["access_controls"],
|
||||
"styleSignature": row["style_signature"],
|
||||
"validationState": row["validation_state"],
|
||||
"auditLog": list(row["audit_log"] or []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
page_id = _stringify(row["page_id"])
|
||||
branch_id = _stringify(row["branch_id"])
|
||||
head_revision = int(row["head_revision"])
|
||||
return {
|
||||
"pageId": page_id,
|
||||
"tenantId": row["tenant_id"],
|
||||
"ownerId": row["owner_id"],
|
||||
"branchId": branch_id,
|
||||
"branchName": row["branch_name"],
|
||||
"pageType": row["page_type"],
|
||||
"title": row["title"],
|
||||
"isShared": bool(row["is_shared"]),
|
||||
"headRevision": head_revision,
|
||||
"baseRevision": int(row["base_revision"]),
|
||||
"sharingPolicy": row["sharing_policy"] or {
|
||||
"shareMode": "direct_fork_only",
|
||||
"allowReshare": False,
|
||||
"defaultForkVisibility": "private",
|
||||
},
|
||||
"forks": [],
|
||||
"lineage": [],
|
||||
"audit": {"lastAuditEventId": "", "eventCount": 0},
|
||||
"presence": {"activeViewers": 0, "activeEditors": 0, "lastPresenceAt": row["updated_at"].isoformat()},
|
||||
"mainBranchPointer": {"pageId": page_id, "branchId": branch_id, "revision": head_revision},
|
||||
"components": components,
|
||||
"createdAt": row["created_at"].isoformat(),
|
||||
"updatedAt": row["updated_at"].isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class CanvasService:
|
||||
async def create_page(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
owner_id: str,
|
||||
title: str = "Untitled Canvas",
|
||||
page_type: str = "main",
|
||||
branch_name: str = "main",
|
||||
sharing_policy: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
_ensure_ready()
|
||||
if _is_demo():
|
||||
page_id = str(uuid.uuid4())
|
||||
branch_id = str(uuid.uuid4())
|
||||
page = {
|
||||
"pageId": page_id,
|
||||
"tenantId": tenant_id,
|
||||
"ownerId": owner_id,
|
||||
"branchId": branch_id,
|
||||
"branchName": branch_name,
|
||||
"pageType": page_type,
|
||||
"title": title,
|
||||
"isShared": False,
|
||||
"headRevision": 0,
|
||||
"baseRevision": 0,
|
||||
"sharingPolicy": sharing_policy or {"shareMode": "direct_fork_only", "allowReshare": False, "defaultForkVisibility": "private"},
|
||||
"forks": [],
|
||||
"lineage": [],
|
||||
"audit": {"lastAuditEventId": "", "eventCount": 0},
|
||||
"presence": {"activeViewers": 0, "activeEditors": 0, "lastPresenceAt": _now()},
|
||||
"mainBranchPointer": {"pageId": page_id, "branchId": branch_id, "revision": 0},
|
||||
"components": [],
|
||||
"createdAt": _now(),
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
_DEMO_PAGES[page_id] = page
|
||||
_DEMO_REVISIONS[page_id] = []
|
||||
_DEMO_COMPONENTS[page_id] = []
|
||||
return page
|
||||
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_canvas_pages (
|
||||
tenant_id, owner_id, branch_id, branch_name, page_type, title, sharing_policy
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
RETURNING *
|
||||
""",
|
||||
tenant_id,
|
||||
owner_id,
|
||||
str(uuid.uuid4()),
|
||||
branch_name,
|
||||
page_type,
|
||||
title,
|
||||
json.dumps(sharing_policy or {"shareMode": "direct_fork_only", "allowReshare": False, "defaultForkVisibility": "private"}),
|
||||
)
|
||||
return _deserialize_page_row(row, [])
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def ensure_default_page(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
owner_id: str,
|
||||
title: str = "Oracle Main Canvas",
|
||||
) -> dict[str, Any]:
|
||||
page = await self.get_first_page_for_owner(tenant_id=tenant_id, owner_id=owner_id)
|
||||
if page:
|
||||
return page
|
||||
return await self.create_page(tenant_id=tenant_id, owner_id=owner_id, title=title)
|
||||
|
||||
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"], []))}
|
||||
return None
|
||||
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM oracle_canvas_pages
|
||||
WHERE tenant_id = $1 AND owner_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
tenant_id,
|
||||
owner_id,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id)
|
||||
return _deserialize_page_row(row, components)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def get_page(self, page_id: str, tenant_id: str) -> dict[str, Any] | None:
|
||||
_ensure_ready()
|
||||
if _is_demo():
|
||||
page = _DEMO_PAGES.get(page_id)
|
||||
if page and page["tenantId"] == tenant_id:
|
||||
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))}
|
||||
return None
|
||||
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM oracle_canvas_pages
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
components = await self._pg_fetch_components(conn, page_id, tenant_id)
|
||||
return _deserialize_page_row(row, components)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def commit_revision(
|
||||
self,
|
||||
*,
|
||||
page_id: str,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
commit_kind: str,
|
||||
commit_summary: str,
|
||||
components: list[dict[str, Any]],
|
||||
execution_id: str | None = None,
|
||||
merge_request_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
_ensure_ready()
|
||||
if _is_demo():
|
||||
page = _DEMO_PAGES.get(page_id)
|
||||
if not page or page["tenantId"] != tenant_id:
|
||||
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
|
||||
if idempotency_key:
|
||||
existing = next((r for r in _DEMO_REVISIONS.get(page_id, []) if r.get("idempotencyKey") == idempotency_key), None)
|
||||
if existing:
|
||||
return existing
|
||||
new_revision_num = page["headRevision"] + 1
|
||||
revision = {
|
||||
"revisionId": str(uuid.uuid4()),
|
||||
"pageId": page_id,
|
||||
"tenantId": tenant_id,
|
||||
"revisionNumber": new_revision_num,
|
||||
"commitKind": commit_kind,
|
||||
"commitSummary": commit_summary,
|
||||
"actorId": actor_id,
|
||||
"executionId": execution_id,
|
||||
"mergeRequestId": merge_request_id,
|
||||
"componentsSnapshot": json.dumps(components),
|
||||
"idempotencyKey": idempotency_key,
|
||||
"createdAt": _now(),
|
||||
}
|
||||
_DEMO_REVISIONS.setdefault(page_id, []).append(revision)
|
||||
_DEMO_COMPONENTS[page_id] = deepcopy([_normalize_component(component) for component in components])
|
||||
page["headRevision"] = new_revision_num
|
||||
page["mainBranchPointer"]["revision"] = new_revision_num
|
||||
page["updatedAt"] = _now()
|
||||
return revision
|
||||
|
||||
assert asyncpg is not None
|
||||
normalized_components = [_normalize_component(component) for component in components]
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
if idempotency_key:
|
||||
existing = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM oracle_canvas_page_revisions
|
||||
WHERE idempotency_key = $1
|
||||
""",
|
||||
idempotency_key,
|
||||
)
|
||||
if existing:
|
||||
return {
|
||||
"revisionId": _stringify(existing["revision_id"]),
|
||||
"pageId": _stringify(existing["page_id"]),
|
||||
"tenantId": existing["tenant_id"],
|
||||
"revisionNumber": int(existing["revision_number"]),
|
||||
"commitKind": existing["commit_kind"],
|
||||
"commitSummary": existing["commit_summary"],
|
||||
"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"]),
|
||||
"idempotencyKey": existing["idempotency_key"],
|
||||
"createdAt": existing["created_at"].isoformat(),
|
||||
}
|
||||
|
||||
page = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM oracle_canvas_pages
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2
|
||||
FOR UPDATE
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not page:
|
||||
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
|
||||
|
||||
new_revision_number = int(page["head_revision"]) + 1
|
||||
revision = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_canvas_page_revisions (
|
||||
page_id, tenant_id, revision_number, commit_kind, commit_summary,
|
||||
actor_id, execution_id, merge_request_id, components_snapshot, idempotency_key
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2, $3, $4, $5,
|
||||
$6, NULLIF($7, '')::uuid, NULLIF($8, '')::uuid, $9::jsonb, $10
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
new_revision_number,
|
||||
commit_kind,
|
||||
commit_summary,
|
||||
actor_id,
|
||||
execution_id or "",
|
||||
merge_request_id or "",
|
||||
json.dumps(normalized_components),
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE oracle_canvas_pages
|
||||
SET head_revision = $3, updated_at = NOW()
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
new_revision_number,
|
||||
)
|
||||
await self._pg_replace_components(conn, page_id=page_id, tenant_id=tenant_id, components=normalized_components)
|
||||
|
||||
return {
|
||||
"revisionId": _stringify(revision["revision_id"]),
|
||||
"pageId": _stringify(revision["page_id"]),
|
||||
"tenantId": revision["tenant_id"],
|
||||
"revisionNumber": int(revision["revision_number"]),
|
||||
"commitKind": revision["commit_kind"],
|
||||
"commitSummary": revision["commit_summary"],
|
||||
"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"]),
|
||||
"idempotencyKey": revision["idempotency_key"],
|
||||
"createdAt": revision["created_at"].isoformat(),
|
||||
}
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def rollback(
|
||||
self,
|
||||
*,
|
||||
page_id: str,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
target_revision: int,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
_ensure_ready()
|
||||
if _is_demo():
|
||||
page = _DEMO_PAGES.get(page_id)
|
||||
if not page:
|
||||
raise ValueError(f"Page {page_id} not found")
|
||||
revisions = _DEMO_REVISIONS.get(page_id, [])
|
||||
target_rev = next((r for r in revisions if r["revisionNumber"] == target_revision), None)
|
||||
if not target_rev:
|
||||
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
|
||||
snapshot = json.loads(target_rev["componentsSnapshot"])
|
||||
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=snapshot,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
revision = await conn.fetchrow(
|
||||
"""
|
||||
SELECT components_snapshot
|
||||
FROM oracle_canvas_page_revisions
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2 AND revision_number = $3
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
target_revision,
|
||||
)
|
||||
if not revision:
|
||||
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
|
||||
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"]),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def list_revisions(self, page_id: str, tenant_id: str) -> list[dict[str, Any]]:
|
||||
_ensure_ready()
|
||||
if _is_demo():
|
||||
page = _DEMO_PAGES.get(page_id)
|
||||
if not page or page["tenantId"] != tenant_id:
|
||||
return []
|
||||
return sorted(_DEMO_REVISIONS.get(page_id, []), key=lambda r: r["revisionNumber"], reverse=True)
|
||||
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT revision_id, page_id, tenant_id, revision_number, commit_kind, commit_summary,
|
||||
actor_id, execution_id, merge_request_id, created_at
|
||||
FROM oracle_canvas_page_revisions
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2
|
||||
ORDER BY revision_number DESC
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"revisionId": _stringify(row["revision_id"]),
|
||||
"pageId": _stringify(row["page_id"]),
|
||||
"tenantId": row["tenant_id"],
|
||||
"revisionNumber": int(row["revision_number"]),
|
||||
"commitKind": row["commit_kind"],
|
||||
"commitSummary": row["commit_summary"],
|
||||
"actorId": row["actor_id"],
|
||||
"executionId": _stringify(row["execution_id"]) if row["execution_id"] else None,
|
||||
"mergeRequestId": _stringify(row["merge_request_id"]) if row["merge_request_id"] else None,
|
||||
"createdAt": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def upsert_component(
|
||||
self,
|
||||
*,
|
||||
page_id: str,
|
||||
tenant_id: str,
|
||||
component: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
_ensure_ready()
|
||||
if _is_demo():
|
||||
comps = _DEMO_COMPONENTS.setdefault(page_id, [])
|
||||
normalized = _normalize_component(component)
|
||||
existing_idx = next((i for i, c in enumerate(comps) if c.get("componentId") == normalized.get("componentId")), None)
|
||||
if existing_idx is not None:
|
||||
comps[existing_idx] = normalized
|
||||
else:
|
||||
comps.append(normalized)
|
||||
return normalized
|
||||
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
await self._pg_upsert_component(conn, page_id=page_id, tenant_id=tenant_id, component=_normalize_component(component))
|
||||
return _normalize_component(component)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def _pg_fetch_components(self, conn: Any, page_id: str, tenant_id: str) -> list[dict[str, Any]]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM oracle_canvas_components
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2
|
||||
ORDER BY COALESCE((layout->>'orderIndex')::int, 999999), created_at ASC
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
)
|
||||
return [_deserialize_component_row(row) for row in rows]
|
||||
|
||||
async def _pg_replace_components(self, conn: Any, *, page_id: str, tenant_id: str, components: list[dict[str, Any]]) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM oracle_canvas_components
|
||||
WHERE page_id = $1::uuid AND tenant_id = $2
|
||||
""",
|
||||
page_id,
|
||||
tenant_id,
|
||||
)
|
||||
for component in components:
|
||||
await self._pg_upsert_component(conn, page_id=page_id, tenant_id=tenant_id, component=component)
|
||||
|
||||
async def _pg_upsert_component(self, conn: Any, *, page_id: str, tenant_id: str, component: dict[str, Any]) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO oracle_canvas_components (
|
||||
component_id, page_id, tenant_id, type, title, description, version, lifecycle_state,
|
||||
data_source_descriptor, visualization_parameters, data_bindings, provenance,
|
||||
rendering_hints, layout, access_controls, style_signature, validation_state, audit_log
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8,
|
||||
$9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb,
|
||||
$13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb, $18::text[]
|
||||
)
|
||||
ON CONFLICT (component_id)
|
||||
DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
version = EXCLUDED.version,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
data_source_descriptor = EXCLUDED.data_source_descriptor,
|
||||
visualization_parameters = EXCLUDED.visualization_parameters,
|
||||
data_bindings = EXCLUDED.data_bindings,
|
||||
provenance = EXCLUDED.provenance,
|
||||
rendering_hints = EXCLUDED.rendering_hints,
|
||||
layout = EXCLUDED.layout,
|
||||
access_controls = EXCLUDED.access_controls,
|
||||
style_signature = EXCLUDED.style_signature,
|
||||
validation_state = EXCLUDED.validation_state,
|
||||
audit_log = EXCLUDED.audit_log,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
component["componentId"],
|
||||
page_id,
|
||||
tenant_id,
|
||||
component["type"],
|
||||
component["title"],
|
||||
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", {})),
|
||||
list(component.get("auditLog", [])),
|
||||
)
|
||||
|
||||
|
||||
canvas_service = CanvasService()
|
||||
369
backend/oracle/collaboration_service.py
Normal file
369
backend/oracle/collaboration_service.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
oracle/collaboration_service.py
|
||||
Implements fork creation, MergeRequest lifecycle, three-way diff engine,
|
||||
conflict classification (all 7 classes from spec §17.2), and merge commits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── In-memory store (demo mode) ───────────────────────────────────────────────
|
||||
|
||||
_DEMO_FORKS: dict[str, dict[str, Any]] = {}
|
||||
_DEMO_MRS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ── Three-way diff engine ─────────────────────────────────────────────────────
|
||||
|
||||
def _three_way_diff(
|
||||
base_components: list[dict[str, Any]],
|
||||
source_components: list[dict[str, Any]],
|
||||
target_components: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Compute a three-way diff between base, source, and target component lists.
|
||||
Returns (merged_components, conflicts) per spec §17.2.
|
||||
Conflict classes:
|
||||
1. safe_append — added only in source, not in target
|
||||
2. safe_reorder — order differs but content same
|
||||
3. component_content_conflict — both changed same component fields
|
||||
4. query_descriptor_conflict — data source descriptor changed in both
|
||||
5. layout_slot_conflict — same orderIndex claimed by different components
|
||||
6. access_policy_conflict — accessControls differ in both
|
||||
7. delete_edit_conflict — deleted in one, edited in other
|
||||
"""
|
||||
base_map = {c["componentId"]: c for c in base_components}
|
||||
source_map = {c["componentId"]: c for c in source_components}
|
||||
target_map = {c["componentId"]: c for c in target_components}
|
||||
|
||||
all_ids = set(base_map) | set(source_map) | set(target_map)
|
||||
merged: list[dict[str, Any]] = []
|
||||
conflicts: list[dict[str, Any]] = []
|
||||
|
||||
def make_conflict(
|
||||
conflict_class: str,
|
||||
component_id: str,
|
||||
field: str | None = None,
|
||||
source_val: Any = None,
|
||||
target_val: Any = None,
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"conflictId": str(uuid.uuid4()),
|
||||
"conflictClass": conflict_class,
|
||||
"componentId": component_id,
|
||||
"field": field,
|
||||
"sourceValue": source_val,
|
||||
"targetValue": target_val,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
for cid in all_ids:
|
||||
in_base = cid in base_map
|
||||
in_source = cid in source_map
|
||||
in_target = cid in target_map
|
||||
|
||||
base_c = base_map.get(cid)
|
||||
src_c = source_map.get(cid)
|
||||
tgt_c = target_map.get(cid)
|
||||
|
||||
# Case 1: Exists nowhere → skip
|
||||
if not in_source and not in_target:
|
||||
continue
|
||||
|
||||
# Case 2: Deleted in both → skip
|
||||
if not in_source and not in_target:
|
||||
continue
|
||||
|
||||
# Case 3: Added only in source (safe_append)
|
||||
if not in_base and in_source and not in_target:
|
||||
conflicts.append(make_conflict(
|
||||
"safe_append", cid,
|
||||
description=f"Component '{cid}' added in source branch; will be appended."
|
||||
))
|
||||
merged.append(copy.deepcopy(src_c))
|
||||
continue
|
||||
|
||||
# Case 4: Added only in target → keep target as-is
|
||||
if not in_base and not in_source and in_target:
|
||||
merged.append(copy.deepcopy(tgt_c))
|
||||
continue
|
||||
|
||||
# Case 5: Added in both (both new, same id) → conflict
|
||||
if not in_base and in_source and in_target:
|
||||
if src_c == tgt_c:
|
||||
merged.append(copy.deepcopy(tgt_c))
|
||||
else:
|
||||
conflicts.append(make_conflict(
|
||||
"component_content_conflict", cid,
|
||||
description="Component added in both branches with different content."
|
||||
))
|
||||
merged.append(copy.deepcopy(tgt_c)) # Default: keep target
|
||||
continue
|
||||
|
||||
# Case 6: Deleted in source only
|
||||
if in_base and not in_source and in_target:
|
||||
src_equal_base = base_c == tgt_c
|
||||
if src_equal_base:
|
||||
# Target unchanged → deletion is safe
|
||||
continue
|
||||
else:
|
||||
conflicts.append(make_conflict(
|
||||
"delete_edit_conflict", cid,
|
||||
description="Component deleted in source but edited in target."
|
||||
))
|
||||
merged.append(copy.deepcopy(tgt_c))
|
||||
continue
|
||||
|
||||
# Case 7: Deleted in target only
|
||||
if in_base and in_source and not in_target:
|
||||
src_equal_base = base_c == src_c
|
||||
if src_equal_base:
|
||||
continue
|
||||
else:
|
||||
conflicts.append(make_conflict(
|
||||
"delete_edit_conflict", cid,
|
||||
description="Component deleted in target but edited in source."
|
||||
))
|
||||
merged.append(copy.deepcopy(src_c))
|
||||
continue
|
||||
|
||||
# Case 8: Both present — check for edits
|
||||
if src_c == tgt_c:
|
||||
merged.append(copy.deepcopy(tgt_c))
|
||||
continue
|
||||
|
||||
# Check individual field conflicts
|
||||
has_conflict = False
|
||||
|
||||
# Data source descriptor conflict
|
||||
if src_c.get("dataSourceDescriptor") != tgt_c.get("dataSourceDescriptor") \
|
||||
and (base_c or {}).get("dataSourceDescriptor") not in (
|
||||
src_c.get("dataSourceDescriptor"),
|
||||
tgt_c.get("dataSourceDescriptor"),
|
||||
):
|
||||
conflicts.append(make_conflict(
|
||||
"query_descriptor_conflict", cid,
|
||||
field="dataSourceDescriptor",
|
||||
description="Data source descriptor modified in both branches.",
|
||||
))
|
||||
has_conflict = True
|
||||
|
||||
# Access controls conflict
|
||||
if src_c.get("accessControls") != tgt_c.get("accessControls") \
|
||||
and (base_c or {}).get("accessControls") not in (
|
||||
src_c.get("accessControls"),
|
||||
tgt_c.get("accessControls"),
|
||||
):
|
||||
conflicts.append(make_conflict(
|
||||
"access_policy_conflict", cid,
|
||||
field="accessControls",
|
||||
source_val=src_c.get("accessControls"),
|
||||
target_val=tgt_c.get("accessControls"),
|
||||
description="Access control policies diverge in both branches.",
|
||||
))
|
||||
has_conflict = True
|
||||
|
||||
# Layout orderIndex conflict
|
||||
src_order = (src_c.get("layout") or {}).get("orderIndex")
|
||||
tgt_order = (tgt_c.get("layout") or {}).get("orderIndex")
|
||||
if src_order != tgt_order:
|
||||
conflicts.append(make_conflict(
|
||||
"layout_slot_conflict", cid,
|
||||
field="layout.orderIndex",
|
||||
source_val=src_order,
|
||||
target_val=tgt_order,
|
||||
description="Layout order index conflicts.",
|
||||
))
|
||||
# Record as safe reorder if content otherwise matches
|
||||
if not has_conflict:
|
||||
conflicts.append(make_conflict("safe_reorder", cid, description="Component reordered."))
|
||||
|
||||
# General content conflict
|
||||
if not has_conflict and src_c != tgt_c:
|
||||
conflicts.append(make_conflict(
|
||||
"component_content_conflict", cid,
|
||||
description="Component content diverges in both branches.",
|
||||
))
|
||||
|
||||
# Merge: for all conflicts, default target wins
|
||||
merged.append(copy.deepcopy(tgt_c))
|
||||
|
||||
# Normalize orderIndex
|
||||
merged.sort(key=lambda c: (c.get("layout") or {}).get("orderIndex", 9999))
|
||||
for i, comp in enumerate(merged):
|
||||
comp.setdefault("layout", {})["orderIndex"] = (i + 1) * 100
|
||||
|
||||
return merged, conflicts
|
||||
|
||||
|
||||
# ── CollaborationService ──────────────────────────────────────────────────────
|
||||
|
||||
class CollaborationService:
|
||||
"""
|
||||
Manages fork creation and merge request lifecycle.
|
||||
Uses canvas_service for snapshot reads and revision commits.
|
||||
"""
|
||||
|
||||
async def create_fork(
|
||||
self,
|
||||
*,
|
||||
source_page: dict[str, Any],
|
||||
recipient_user_id: str,
|
||||
created_by: str,
|
||||
visibility: str = "private",
|
||||
message: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Creates a fork from the source_page snapshot at its current headRevision.
|
||||
Returns ForkRecord.
|
||||
"""
|
||||
fork_id = str(uuid.uuid4())
|
||||
fork_page_id = str(uuid.uuid4())
|
||||
fork_branch_id = str(uuid.uuid4())
|
||||
|
||||
fork = {
|
||||
"forkId": fork_id,
|
||||
"sourcePageId": source_page["pageId"],
|
||||
"sourceBranchId": source_page["branchId"],
|
||||
"sourceRevision": source_page["headRevision"],
|
||||
"forkPageId": fork_page_id,
|
||||
"forkBranchId": fork_branch_id,
|
||||
"recipientUserId": recipient_user_id,
|
||||
"createdBy": created_by,
|
||||
"visibility": visibility,
|
||||
"message": message,
|
||||
"status": "active",
|
||||
"createdAt": _now(),
|
||||
}
|
||||
_DEMO_FORKS[fork_id] = fork
|
||||
logger.info(
|
||||
"COLLAB fork_created fork_id=%s source_page=%s revision=%d recipient=%s",
|
||||
fork_id, source_page["pageId"], source_page["headRevision"], recipient_user_id,
|
||||
)
|
||||
return fork
|
||||
|
||||
async def open_merge_request(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_page_id: str,
|
||||
source_branch_id: str,
|
||||
source_head_revision: int,
|
||||
target_page_id: str,
|
||||
target_branch_id: str,
|
||||
target_base_revision: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
created_by: str,
|
||||
source_components: list[dict[str, Any]],
|
||||
target_components: list[dict[str, Any]],
|
||||
base_components: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Creates a MergeRequest with pre-computed conflicts via three-way diff.
|
||||
"""
|
||||
merged, conflicts = _three_way_diff(base_components, source_components, target_components)
|
||||
|
||||
added = sum(1 for c in conflicts if c["conflictClass"] == "safe_append")
|
||||
edited = sum(1 for c in conflicts if c["conflictClass"] == "component_content_conflict")
|
||||
reordered = sum(1 for c in conflicts if c["conflictClass"] in ("safe_reorder", "layout_slot_conflict"))
|
||||
deleted = sum(1 for c in conflicts if c["conflictClass"] == "delete_edit_conflict")
|
||||
|
||||
mr = {
|
||||
"mergeRequestId": str(uuid.uuid4()),
|
||||
"tenantId": tenant_id,
|
||||
"sourcePageId": source_page_id,
|
||||
"sourceBranchId": source_branch_id,
|
||||
"sourceHeadRevision": source_head_revision,
|
||||
"targetPageId": target_page_id,
|
||||
"targetBranchId": target_branch_id,
|
||||
"targetBaseRevision": target_base_revision,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "open",
|
||||
"conflicts": conflicts,
|
||||
"diffSummary": {
|
||||
"componentsAdded": added,
|
||||
"componentsEdited": edited,
|
||||
"componentsReordered": reordered,
|
||||
"componentsDeleted": deleted,
|
||||
},
|
||||
"_mergedComponents": merged, # internal — used during merge
|
||||
"createdBy": created_by,
|
||||
"createdAt": _now(),
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
_DEMO_MRS[mr["mergeRequestId"]] = mr
|
||||
logger.info(
|
||||
"COLLAB mr_opened mr_id=%s conflicts=%d source=%s → target=%s",
|
||||
mr["mergeRequestId"], len(conflicts), source_branch_id, target_branch_id,
|
||||
)
|
||||
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
|
||||
|
||||
async def review_merge_request(
|
||||
self,
|
||||
*,
|
||||
mr_id: str,
|
||||
decision: str,
|
||||
reviewer_id: str,
|
||||
comment: str = "",
|
||||
resolutions: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Applies a reviewer decision: approve → merges; reject/changes_requested → status update.
|
||||
"""
|
||||
mr = _DEMO_MRS.get(mr_id)
|
||||
if not mr:
|
||||
raise ValueError(f"MergeRequest {mr_id} not found")
|
||||
|
||||
mr["reviewedBy"] = reviewer_id
|
||||
mr["reviewerComment"] = comment
|
||||
mr["updatedAt"] = _now()
|
||||
|
||||
if decision == "approve":
|
||||
mr["status"] = "merged"
|
||||
logger.info("COLLAB mr_merged mr_id=%s by=%s", mr_id, reviewer_id)
|
||||
elif decision == "reject":
|
||||
mr["status"] = "closed"
|
||||
elif decision == "changes_requested":
|
||||
mr["status"] = "changes_requested"
|
||||
|
||||
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
|
||||
|
||||
async def get_merge_request(self, mr_id: str) -> dict[str, Any] | None:
|
||||
mr = _DEMO_MRS.get(mr_id)
|
||||
if mr:
|
||||
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
|
||||
return None
|
||||
|
||||
async def list_merge_requests(self, target_page_id: str, status: str | None = None) -> list[dict[str, Any]]:
|
||||
results = [
|
||||
{k: v for k, v in mr.items() if k != "_mergedComponents"}
|
||||
for mr in _DEMO_MRS.values()
|
||||
if mr["targetPageId"] == target_page_id
|
||||
]
|
||||
if status:
|
||||
results = [mr for mr in results if mr["status"] == status]
|
||||
return results
|
||||
|
||||
|
||||
# ── Public three-way-diff (for testing) ───────────────────────────────────────
|
||||
|
||||
def three_way_diff(base, source, target): # type: ignore[return]
|
||||
return _three_way_diff(base, source, target)
|
||||
|
||||
|
||||
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
collaboration_service = CollaborationService()
|
||||
242
backend/oracle/data_access_gateway.py
Normal file
242
backend/oracle/data_access_gateway.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
oracle/data_access_gateway.py
|
||||
Read-only, policy-aware PostgreSQL query executor for Oracle datasets.
|
||||
|
||||
Nemoclaw is treated strictly as a planner. The gateway executes only
|
||||
whitelisted dataset queries and always injects the actor's tenant scope.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import asyncpg # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
asyncpg = None # type: ignore
|
||||
|
||||
from .policy_service import PolicyContext, PolicyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
_ALLOW_IN_MEMORY = os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryExecutionResult:
|
||||
rows: list[dict[str, Any]]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
|
||||
|
||||
|
||||
class DataAccessGateway:
|
||||
def __init__(self) -> None:
|
||||
self.policy_service = PolicyService()
|
||||
|
||||
async def execute_component_plan(
|
||||
self,
|
||||
component_plan: dict[str, Any],
|
||||
ctx: PolicyContext,
|
||||
prompt: str,
|
||||
) -> QueryExecutionResult:
|
||||
dataset = str(component_plan.get("dataset", "")).strip()
|
||||
if not dataset:
|
||||
return QueryExecutionResult(rows=[], warnings=["Dataset missing from retrieval plan."])
|
||||
|
||||
validation = self.policy_service.validate_retrieval_plan(component_plan, ctx)
|
||||
self.policy_service.audit_policy_check(ctx, dataset, validation)
|
||||
if not validation.passed:
|
||||
return QueryExecutionResult(rows=[], warnings=validation.errors)
|
||||
|
||||
if not _db_ready():
|
||||
if _ALLOW_IN_MEMORY or "PYTEST_CURRENT_TEST" in os.environ:
|
||||
return QueryExecutionResult(rows=[], warnings=[])
|
||||
raise RuntimeError("Oracle requires DATABASE_URL and asyncpg for real-time data access.")
|
||||
|
||||
try:
|
||||
rows = await self._query_dataset(
|
||||
dataset=dataset,
|
||||
row_limit=validation.effective_row_limit,
|
||||
ctx=ctx,
|
||||
prompt=prompt,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("DATA_GATEWAY query_failed dataset=%s error=%s", dataset, exc)
|
||||
return QueryExecutionResult(rows=[], warnings=[f"{dataset}: {exc}"])
|
||||
|
||||
redacted = self.policy_service.redact(rows, validation.redaction_policy)
|
||||
return QueryExecutionResult(rows=redacted, warnings=validation.warnings)
|
||||
|
||||
async def _query_dataset(
|
||||
self,
|
||||
*,
|
||||
dataset: str,
|
||||
row_limit: int,
|
||||
ctx: PolicyContext,
|
||||
prompt: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
sql, params = self._build_whitelisted_query(dataset, row_limit, ctx, prompt)
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
records = await conn.fetch(sql, *params)
|
||||
finally:
|
||||
await conn.close()
|
||||
return [dict(record) for record in records]
|
||||
|
||||
def _build_whitelisted_query(
|
||||
self,
|
||||
dataset: str,
|
||||
row_limit: int,
|
||||
ctx: PolicyContext,
|
||||
prompt: str,
|
||||
) -> tuple[str, list[Any]]:
|
||||
lower_prompt = prompt.lower()
|
||||
|
||||
if dataset == "deals":
|
||||
sql = """
|
||||
SELECT
|
||||
stage,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(value), 0)::float AS value,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', lead_id,
|
||||
'name', lead_name,
|
||||
'company', company,
|
||||
'value', value_label,
|
||||
'avatar', avatar_url
|
||||
)
|
||||
ORDER BY value DESC NULLS LAST
|
||||
) FILTER (WHERE lead_id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
FROM deals
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY stage
|
||||
ORDER BY COALESCE(SUM(value), 0) DESC, stage ASC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
if dataset == "lead_daily_snapshot":
|
||||
sql = """
|
||||
SELECT
|
||||
source,
|
||||
COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
|
||||
FROM lead_daily_snapshot
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY source
|
||||
ORDER BY qd_weighted_volume DESC, source ASC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
if dataset == "lead_geo_interest_rollup":
|
||||
sql = """
|
||||
SELECT
|
||||
district,
|
||||
lat,
|
||||
lng,
|
||||
COALESCE(lead_count, 0)::int AS lead_count,
|
||||
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
|
||||
COALESCE(x, 0)::float AS x,
|
||||
COALESCE(y, 0)::float AS y
|
||||
FROM lead_geo_interest_rollup
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY lead_count DESC, district ASC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
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
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
if dataset == "inventory_absorption":
|
||||
sql = """
|
||||
SELECT
|
||||
period_label AS period,
|
||||
COALESCE(absorption_rate, 0)::float AS absorption_rate,
|
||||
COALESCE(target_rate, 0)::float AS target_rate
|
||||
FROM inventory_absorption
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY period_start ASC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
if dataset == "oracle_aggregated_metric":
|
||||
metric_name = "total_leads"
|
||||
if "pipeline" in lower_prompt:
|
||||
metric_name = "total_pipeline_value"
|
||||
elif "quota" in lower_prompt or "attainment" in lower_prompt:
|
||||
metric_name = "quota_attainment"
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
metric_value,
|
||||
metric_label,
|
||||
trend_value,
|
||||
comparison_label
|
||||
FROM oracle_aggregated_metric
|
||||
WHERE tenant_id = $1
|
||||
AND metric_name = $2
|
||||
ORDER BY observed_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
return sql, [ctx.tenant_id, metric_name]
|
||||
|
||||
if dataset == "lead_activity_log":
|
||||
if "follow-up" in lower_prompt or "queue" in lower_prompt:
|
||||
sql = """
|
||||
SELECT
|
||||
lead_name AS name,
|
||||
assigned_broker,
|
||||
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
|
||||
COALESCE(qd_score, 0)::float AS qd_score,
|
||||
urgency,
|
||||
avatar_url AS avatar
|
||||
FROM lead_activity_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY last_contact_hours_ago DESC, qd_score DESC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
activity_type AS type,
|
||||
COALESCE(activity_title, activity_summary, activity_type) AS title,
|
||||
activity_summary AS summary,
|
||||
actor_name AS actor,
|
||||
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
FROM lead_activity_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY activity_at DESC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
raise ValueError(f"Dataset '{dataset}' is not whitelisted for Oracle execution.")
|
||||
|
||||
|
||||
data_access_gateway = DataAccessGateway()
|
||||
225
backend/oracle/policy_service.py
Normal file
225
backend/oracle/policy_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
oracle/policy_service.py
|
||||
Enforces tenant isolation, role-based access, privacy-tier escalation,
|
||||
field-level redaction, and row limit guardrails for all Oracle data access.
|
||||
Section 11.3 of the Oracle Architecture Document.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
MAX_ROW_LIMITS: dict[str, int] = {
|
||||
"junior_broker": 100,
|
||||
"senior_broker": 500,
|
||||
"sales_director": 2000,
|
||||
"marketing_operator": 1000,
|
||||
"data_steward": 5000,
|
||||
"compliance_reviewer": 5000,
|
||||
"platform_admin": 10000,
|
||||
}
|
||||
|
||||
# Which roles can see which privacy tiers
|
||||
PRIVACY_TIER_ACCESS: dict[str, set[str]] = {
|
||||
"standard": {"junior_broker", "senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"},
|
||||
"restricted": {"senior_broker", "sales_director", "data_steward", "compliance_reviewer", "platform_admin"},
|
||||
"sensitive": {"data_steward", "compliance_reviewer", "platform_admin"},
|
||||
}
|
||||
|
||||
# Datasets with cross-tenant join restrictions
|
||||
CROSS_TENANT_RESTRICTED: set[str] = {
|
||||
"global_lead_market",
|
||||
"competitor_pricing",
|
||||
"cross_tenant_referrals",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyContext:
|
||||
tenant_id: str
|
||||
actor_id: str
|
||||
actor_role: str
|
||||
policy_profile_id: str = "policy_standard_v4"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
passed: bool
|
||||
errors: list[str]
|
||||
warnings: list[str]
|
||||
redaction_policy: str = "none"
|
||||
effective_row_limit: int = 100
|
||||
|
||||
@classmethod
|
||||
def ok(cls, row_limit: int, redaction: str = "none") -> "ValidationResult":
|
||||
return cls(passed=True, errors=[], warnings=[], redaction_policy=redaction, effective_row_limit=row_limit)
|
||||
|
||||
@classmethod
|
||||
def denied(cls, reason: str) -> "ValidationResult":
|
||||
return cls(passed=False, errors=[reason], warnings=[])
|
||||
|
||||
|
||||
class PolicyService:
|
||||
"""
|
||||
Validates all Oracle data access against policy rules.
|
||||
Configuration is loaded from env / feature flags in production;
|
||||
falls back to safe defaults for demo mode.
|
||||
"""
|
||||
|
||||
def validate_retrieval_plan(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
ctx: PolicyContext,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validates a structured retrieval plan (as produced by PromptOrchestrator).
|
||||
Checks: tenant isolation, role access, privacy tier, row limits.
|
||||
Returns ValidationResult with passed=True if all checks pass.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
dataset = plan.get("dataset", "")
|
||||
privacy_tier = plan.get("privacyTier", "standard")
|
||||
requested_row_limit = plan.get("rowLimit", 100)
|
||||
joins = plan.get("joins", [])
|
||||
|
||||
# 1. Tenant isolation — reject cross-tenant predicates
|
||||
if dataset in CROSS_TENANT_RESTRICTED:
|
||||
errors.append(
|
||||
f"POLICY_CROSS_TENANT_JOIN_DENIED: Dataset '{dataset}' requires "
|
||||
f"cross-tenant access which is not permitted for role '{ctx.actor_role}'."
|
||||
)
|
||||
|
||||
# 2. Cross-tenant join detection
|
||||
for join in joins:
|
||||
if join.get("tenantId") and join["tenantId"] != ctx.tenant_id:
|
||||
errors.append(
|
||||
f"POLICY_CROSS_TENANT_JOIN_DENIED: Join to tenant '{join['tenantId']}' "
|
||||
f"is not permitted."
|
||||
)
|
||||
|
||||
# 3. Privacy tier access
|
||||
allowed_roles = PRIVACY_TIER_ACCESS.get(privacy_tier, set())
|
||||
if ctx.actor_role not in allowed_roles:
|
||||
errors.append(
|
||||
f"POLICY_PRIVACY_TIER_ESCALATION: Role '{ctx.actor_role}' cannot access "
|
||||
f"'{privacy_tier}' tier data in dataset '{dataset}'."
|
||||
)
|
||||
|
||||
# 4. Row limit guardrail
|
||||
max_limit = MAX_ROW_LIMITS.get(ctx.actor_role, 100)
|
||||
effective_limit = min(requested_row_limit, max_limit)
|
||||
if requested_row_limit > max_limit:
|
||||
warnings.append(
|
||||
f"ROW_LIMIT_CAPPED: Requested {requested_row_limit} rows; "
|
||||
f"capped to {effective_limit} for role '{ctx.actor_role}'."
|
||||
)
|
||||
|
||||
# 5. Determine redaction policy
|
||||
redaction = "none"
|
||||
if privacy_tier == "restricted" and ctx.actor_role == "senior_broker":
|
||||
redaction = "aggregate_only"
|
||||
elif privacy_tier == "sensitive":
|
||||
redaction = "full_redact"
|
||||
|
||||
if errors:
|
||||
return ValidationResult(
|
||||
passed=False,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
redaction_policy=redaction,
|
||||
effective_row_limit=effective_limit,
|
||||
)
|
||||
|
||||
return ValidationResult(
|
||||
passed=True,
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
redaction_policy=redaction,
|
||||
effective_row_limit=effective_limit,
|
||||
)
|
||||
|
||||
def enforce_tenant_predicate(
|
||||
self,
|
||||
query_parameters: dict[str, Any],
|
||||
ctx: PolicyContext,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Ensures :tenant_id parameter is always bound to the actor's tenant.
|
||||
Overrides any attacker-supplied tenant_id parameter.
|
||||
"""
|
||||
params = dict(query_parameters)
|
||||
params["tenant_id"] = ctx.tenant_id
|
||||
return params
|
||||
|
||||
def validate_component_access(
|
||||
self,
|
||||
component_access_controls: dict[str, Any],
|
||||
ctx: PolicyContext,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if the actor's role is in the component's allowedRoles.
|
||||
"""
|
||||
allowed_roles: list[str] = component_access_controls.get("allowedRoles", [])
|
||||
if not allowed_roles:
|
||||
# Open access (shouldn't happen in production)
|
||||
logger.warning(
|
||||
"POLICY_WARN: Component has no allowedRoles — defaulting to deny for tenant=%s actor=%s",
|
||||
ctx.tenant_id,
|
||||
ctx.actor_id,
|
||||
)
|
||||
return False
|
||||
return ctx.actor_role in allowed_roles
|
||||
|
||||
def redact(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
redaction_policy: str,
|
||||
sensitive_fields: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Applies field-level redaction to result rows.
|
||||
"""
|
||||
if redaction_policy == "none" or not rows:
|
||||
return rows
|
||||
if redaction_policy == "full_redact":
|
||||
return [{"__redacted__": True, "count": len(rows)}]
|
||||
if redaction_policy == "aggregate_only":
|
||||
# Keep only aggregate fields; drop individual identifiers
|
||||
safe_fields = {"count", "total", "average", "sum", "min", "max", "stage", "source", "district"}
|
||||
return [{k: v for k, v in row.items() if k in safe_fields} for row in rows]
|
||||
if redaction_policy == "team_scope":
|
||||
# Keep rows where assigned_broker matches actor (simplified demo rule)
|
||||
return rows # Full enforcement requires actor context per row
|
||||
return rows
|
||||
|
||||
def audit_policy_check(
|
||||
self,
|
||||
ctx: PolicyContext,
|
||||
dataset: str,
|
||||
result: ValidationResult,
|
||||
) -> None:
|
||||
"""Emit an audit event for every policy check (passed or denied)."""
|
||||
if not result.passed:
|
||||
logger.warning(
|
||||
"POLICY_DENIED tenant=%s actor=%s dataset=%s errors=%s",
|
||||
ctx.tenant_id,
|
||||
ctx.actor_id,
|
||||
dataset,
|
||||
result.errors,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"POLICY_PASS tenant=%s actor=%s dataset=%s redaction=%s limit=%d",
|
||||
ctx.tenant_id,
|
||||
ctx.actor_id,
|
||||
dataset,
|
||||
result.redaction_policy,
|
||||
result.effective_row_limit,
|
||||
)
|
||||
576
backend/oracle/prompt_orchestrator.py
Normal file
576
backend/oracle/prompt_orchestrator.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
oracle/prompt_orchestrator.py
|
||||
Accepts a user prompt, assembles context, calls the Nemoclaw model runtime
|
||||
(or uses a deterministic fallback), validates the generated plan via policy,
|
||||
triggers the data access gateway, and produces a PromptExecution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .policy_service import PolicyContext, PolicyService
|
||||
from .canvas_service import canvas_service
|
||||
from .data_access_gateway import data_access_gateway
|
||||
|
||||
try:
|
||||
import asyncpg # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
asyncpg = None # type: ignore
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ── Execution store ───────────────────────────────────────────────────────────
|
||||
|
||||
_DEMO_EXECUTIONS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
|
||||
|
||||
|
||||
# ── Semantic intent detection (simplified) ────────────────────────────────────
|
||||
|
||||
_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"],
|
||||
"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"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_component_types(prompt: str) -> list[str]:
|
||||
lower = prompt.lower()
|
||||
types: list[str] = []
|
||||
for comp_type, keywords in _INTENT_KEYWORDS.items():
|
||||
if any(k in lower for k in keywords):
|
||||
types.append(comp_type)
|
||||
return types or ["bar_chart"]
|
||||
|
||||
|
||||
def _build_demo_retrieval_plan(
|
||||
prompt: str,
|
||||
tenant_id: str,
|
||||
actor_role: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Deterministic plan builder for demo mode.
|
||||
Produces a valid retrieval plan that passes policy validation.
|
||||
"""
|
||||
component_types = _detect_component_types(prompt)
|
||||
row_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
||||
|
||||
return {
|
||||
"planId": str(uuid.uuid4()),
|
||||
"components": [
|
||||
{
|
||||
"suggestedType": ct,
|
||||
"dataset": _DATASET_MAP.get(ct, "aggregated_results"),
|
||||
"privacyTier": "standard",
|
||||
"rowLimit": row_limit,
|
||||
"joins": [],
|
||||
"queryTemplate": f"SELECT * FROM {_DATASET_MAP.get(ct, 'aggregated_results')} WHERE tenant_id = :tenant_id LIMIT :limit",
|
||||
"queryParameters": {"tenant_id": tenant_id, "limit": row_limit},
|
||||
}
|
||||
for ct in component_types
|
||||
],
|
||||
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
|
||||
"intentClass": "analytical",
|
||||
}
|
||||
|
||||
|
||||
_DATASET_MAP: dict[str, str] = {
|
||||
"pipeline_board": "deals",
|
||||
"bar_chart": "lead_daily_snapshot",
|
||||
"geo_map": "lead_geo_interest_rollup",
|
||||
"table": "broker_performance",
|
||||
"line_chart": "inventory_absorption",
|
||||
"kpi_tile": "oracle_aggregated_metric",
|
||||
"activity_stream": "lead_activity_log",
|
||||
}
|
||||
|
||||
|
||||
class PromptOrchestrator:
|
||||
"""
|
||||
Orchestrates the full prompt-to-canvas pipeline:
|
||||
1. Intent classification
|
||||
2. Retrieval plan construction (Nemoclaw or fallback)
|
||||
3. Policy validation
|
||||
4. Component plan construction
|
||||
5. Execution record persistence
|
||||
"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
page_id: str,
|
||||
branch_id: str,
|
||||
actor_id: str,
|
||||
actor_role: str,
|
||||
prompt: str,
|
||||
conversation_context: list[dict[str, str]] | None = None,
|
||||
client_request_id: str,
|
||||
placement_mode: str = "append_after_last_visible_component",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Full orchestration flow. Returns a PromptExecution dict.
|
||||
"""
|
||||
execution_id = str(uuid.uuid4())
|
||||
now = _now()
|
||||
warnings: list[str] = []
|
||||
|
||||
ctx = PolicyContext(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
|
||||
execution: dict[str, Any] = {
|
||||
"executionId": execution_id,
|
||||
"tenantId": tenant_id,
|
||||
"pageId": page_id,
|
||||
"branchId": branch_id,
|
||||
"actorId": actor_id,
|
||||
"prompt": prompt,
|
||||
"intentClass": "analytical",
|
||||
"status": "planning",
|
||||
"modelRuntime": "nemoclaw_hosted" if _NEMOCLAW_URL else "deterministic_fallback",
|
||||
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
|
||||
"warnings": warnings,
|
||||
"componentsCreated": [],
|
||||
"clientRequestId": client_request_id,
|
||||
"createdAt": now,
|
||||
}
|
||||
_DEMO_EXECUTIONS[execution_id] = execution
|
||||
await self._persist_execution(execution)
|
||||
|
||||
# ── Step 1: Build retrieval plan ──────────────────────────────────────
|
||||
if _NEMOCLAW_URL and _NEMOCLAW_API_KEY:
|
||||
try:
|
||||
retrieval_plan = await self._call_nemoclaw(prompt, conversation_context or [], ctx)
|
||||
execution["status"] = "validated"
|
||||
except Exception as exc:
|
||||
logger.warning("ORCH Nemoclaw call failed, using fallback: %s", exc)
|
||||
warnings.append(f"Model runtime unavailable ({exc}); using deterministic fallback.")
|
||||
retrieval_plan = _build_demo_retrieval_plan(prompt, tenant_id, actor_role)
|
||||
else:
|
||||
retrieval_plan = _build_demo_retrieval_plan(prompt, tenant_id, actor_role)
|
||||
|
||||
execution["retrievalPlan"] = retrieval_plan
|
||||
|
||||
# ── Step 2: Policy validation ─────────────────────────────────────────
|
||||
policy_errors = []
|
||||
for component_plan in retrieval_plan.get("components", []):
|
||||
result = policy_svc.validate_retrieval_plan(component_plan, ctx)
|
||||
if not result.passed:
|
||||
policy_errors.extend(result.errors)
|
||||
if result.warnings:
|
||||
warnings.extend(result.warnings)
|
||||
|
||||
if policy_errors:
|
||||
execution["status"] = "failed"
|
||||
execution["warnings"] = warnings + policy_errors
|
||||
execution["completedAt"] = _now()
|
||||
logger.warning(
|
||||
"ORCH policy_denial execution_id=%s actor=%s errors=%s",
|
||||
execution_id, actor_id, policy_errors,
|
||||
)
|
||||
return execution
|
||||
|
||||
execution["status"] = "executing"
|
||||
await self._persist_execution(execution)
|
||||
|
||||
# ── Step 3: Build visualization plan (component descriptors) ──────────
|
||||
viz_plan = await self._build_visualization_plan(
|
||||
retrieval_plan=retrieval_plan,
|
||||
prompt=prompt,
|
||||
execution_id=execution_id,
|
||||
actor_id=actor_id,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
placement_mode=placement_mode,
|
||||
ctx=ctx,
|
||||
)
|
||||
execution["visualizationPlan"] = viz_plan
|
||||
|
||||
# ── Step 4: Commit revision ───────────────────────────────────────────
|
||||
component_ids = [c["componentId"] for c in viz_plan.get("components", [])]
|
||||
execution["componentsCreated"] = component_ids
|
||||
|
||||
# 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,
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
commit_kind="prompt",
|
||||
commit_summary=f"Oracle: {prompt[:80]}",
|
||||
components=new_comps,
|
||||
execution_id=execution_id,
|
||||
idempotency_key=client_request_id,
|
||||
)
|
||||
execution["headRevision"] = revision["revisionNumber"]
|
||||
except Exception as exc:
|
||||
logger.warning("ORCH 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
|
||||
await self._persist_execution(execution)
|
||||
return execution
|
||||
|
||||
async def _build_visualization_plan(
|
||||
self,
|
||||
*,
|
||||
retrieval_plan: dict[str, Any],
|
||||
prompt: str,
|
||||
execution_id: str,
|
||||
actor_id: str,
|
||||
tenant_id: str,
|
||||
branch_id: str,
|
||||
placement_mode: str,
|
||||
ctx: PolicyContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Converts a retrieval plan into a list of CanvasComponent descriptors."""
|
||||
components = []
|
||||
base_order = 900 # Append after existing components
|
||||
|
||||
component_plans = retrieval_plan.get("components", [])
|
||||
for i, plan in enumerate(component_plans):
|
||||
ctype = plan["suggestedType"]
|
||||
dataset = plan["dataset"]
|
||||
component_id = str(uuid.uuid4())
|
||||
query_result = await data_access_gateway.execute_component_plan(plan, ctx, prompt)
|
||||
component_warnings = query_result.warnings
|
||||
mapped_type = self._map_type(ctype)
|
||||
data_rows = query_result.rows
|
||||
|
||||
comp: dict[str, Any] = {
|
||||
"componentId": component_id,
|
||||
"type": mapped_type,
|
||||
"title": self._generate_title(prompt, ctype),
|
||||
"description": f"Generated from: \"{prompt[:80]}\"",
|
||||
"dataSourceDescriptor": {
|
||||
"descriptorId": str(uuid.uuid4()),
|
||||
"sourceType": "postgres",
|
||||
"connectorId": "velocity-core-postgres",
|
||||
"dataset": dataset,
|
||||
"authContextRef": f"authctx_{actor_id}_scope",
|
||||
"queryTemplate": plan.get("queryTemplate", f"SELECT * FROM {dataset} WHERE tenant_id = :tenant_id"),
|
||||
"queryParameters": plan.get("queryParameters", {"tenant_id": tenant_id}),
|
||||
"rowLimit": plan.get("rowLimit", 50),
|
||||
"privacyTier": plan.get("privacyTier", "standard"),
|
||||
"cachePolicy": {"mode": "ttl", "ttlSeconds": 120},
|
||||
},
|
||||
"visualizationParameters": self._default_viz_params(ctype, data_rows),
|
||||
"dataBindings": self._default_bindings(ctype),
|
||||
"version": 1,
|
||||
"lifecycleState": "active",
|
||||
"provenance": {
|
||||
"originType": "prompt_generated",
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
},
|
||||
"renderingHints": self._rendering_hints(ctype),
|
||||
"layout": {
|
||||
"orderIndex": base_order + (i + 1) * 100,
|
||||
"sectionId": "sec_prompt_generated",
|
||||
"widthMode": "full" if ctype in ("pipeline_board", "table", "geo_map") else "half",
|
||||
"minHeightPx": 300,
|
||||
"stickyHeader": False,
|
||||
},
|
||||
"accessControls": {
|
||||
"visibilityScope": "private",
|
||||
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
|
||||
"redactionPolicy": "none",
|
||||
},
|
||||
"styleSignature": {
|
||||
"theme": "velocity_glass",
|
||||
"paletteToken": "ocean_signal",
|
||||
"motionProfile": "calm_reveal",
|
||||
"density": "comfortable",
|
||||
"radiusScale": "lg",
|
||||
"typographyScale": "balanced",
|
||||
},
|
||||
"validationState": {
|
||||
"schema": "pass",
|
||||
"policy": "pass",
|
||||
"a11y": "pass",
|
||||
"performance": "pass",
|
||||
"status": "validated",
|
||||
},
|
||||
"auditLog": [f"aud_{execution_id}_create"],
|
||||
"dataRows": data_rows,
|
||||
}
|
||||
if component_warnings and not data_rows:
|
||||
comp = self._error_component(
|
||||
component_id=component_id,
|
||||
execution_id=execution_id,
|
||||
actor_id=actor_id,
|
||||
branch_id=branch_id,
|
||||
dataset=dataset,
|
||||
warnings=component_warnings,
|
||||
order_index=base_order + (i + 1) * 100,
|
||||
)
|
||||
components.append(comp)
|
||||
|
||||
return {"components": components}
|
||||
|
||||
@staticmethod
|
||||
def _map_type(plan_type: str) -> str:
|
||||
mapping = {
|
||||
"pipeline_board": "pipelineBoard",
|
||||
"bar_chart": "barChart",
|
||||
"geo_map": "geoMap",
|
||||
"table": "table",
|
||||
"line_chart": "lineChart",
|
||||
"kpi_tile": "kpiTile",
|
||||
"activity_stream": "activityStream",
|
||||
}
|
||||
return mapping.get(plan_type, "barChart")
|
||||
|
||||
@staticmethod
|
||||
def _generate_title(prompt: str, comp_type: str) -> str:
|
||||
labels = {
|
||||
"pipeline_board": "Pipeline View",
|
||||
"bar_chart": "Comparative Analysis",
|
||||
"geo_map": "Geographic Distribution",
|
||||
"table": "Performance Table",
|
||||
"line_chart": "Trend Analysis",
|
||||
"kpi_tile": "Key Metric",
|
||||
"activity_stream": "Activity Stream",
|
||||
}
|
||||
return labels.get(comp_type, "Oracle Canvas Component")
|
||||
|
||||
@staticmethod
|
||||
def _default_viz_params(comp_type: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
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 "",
|
||||
},
|
||||
"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"]},
|
||||
"pipeline_board": {"showValue": True, "colorByStage": True},
|
||||
"activity_stream": {"showUrgencyIndicator": True},
|
||||
}
|
||||
return defaults.get(comp_type, {})
|
||||
|
||||
@staticmethod
|
||||
def _default_bindings(comp_type: str) -> dict[str, Any]:
|
||||
return {"dimensions": [], "measures": [], "series": [], "filters": []}
|
||||
|
||||
@staticmethod
|
||||
def _rendering_hints(comp_type: str) -> dict[str, Any]:
|
||||
priority_map = {
|
||||
"pipeline_board": ("pipeline", 9), "bar_chart": ("chart", 8),
|
||||
"geo_map": ("map", 9), "table": ("table", 7),
|
||||
"line_chart": ("chart", 8), "kpi_tile": ("kpi", 6),
|
||||
"activity_stream": ("table", 8),
|
||||
}
|
||||
skeleton, priority = priority_map.get(comp_type, ("chart", 7))
|
||||
height_map = {
|
||||
"pipeline_board": 400, "bar_chart": 320, "geo_map": 420,
|
||||
"table": 300, "line_chart": 320, "kpi_tile": 140, "activity_stream": 360,
|
||||
}
|
||||
return {
|
||||
"estimatedHeightPx": height_map.get(comp_type, 300),
|
||||
"skeletonVariant": skeleton,
|
||||
"virtualizationPriority": priority,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
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}"'
|
||||
|
||||
@staticmethod
|
||||
def _error_component(
|
||||
*,
|
||||
component_id: str,
|
||||
execution_id: str,
|
||||
actor_id: str,
|
||||
branch_id: str,
|
||||
dataset: str,
|
||||
warnings: list[str],
|
||||
order_index: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"componentId": component_id,
|
||||
"type": "errorNotice",
|
||||
"title": f"{dataset} unavailable",
|
||||
"description": "Oracle could not render live data for this component.",
|
||||
"dataSourceDescriptor": {
|
||||
"descriptorId": str(uuid.uuid4()),
|
||||
"sourceType": "postgres",
|
||||
"connectorId": "velocity-core-postgres",
|
||||
"dataset": dataset,
|
||||
"authContextRef": f"authctx_{actor_id}_scope",
|
||||
"queryTemplate": "",
|
||||
"queryParameters": {},
|
||||
"rowLimit": 0,
|
||||
"privacyTier": "standard",
|
||||
},
|
||||
"visualizationParameters": {
|
||||
"errorCode": "oracle_live_query_failed",
|
||||
"message": " | ".join(warnings[:2]),
|
||||
"severity": "warning",
|
||||
"retryable": True,
|
||||
},
|
||||
"dataBindings": {"dimensions": [], "measures": [], "series": [], "filters": []},
|
||||
"version": 1,
|
||||
"lifecycleState": "active",
|
||||
"provenance": {
|
||||
"originType": "prompt_generated",
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
},
|
||||
"renderingHints": {"estimatedHeightPx": 140, "skeletonVariant": "generic", "virtualizationPriority": 5},
|
||||
"layout": {
|
||||
"orderIndex": order_index,
|
||||
"sectionId": "sec_prompt_generated",
|
||||
"widthMode": "full",
|
||||
"minHeightPx": 140,
|
||||
"stickyHeader": False,
|
||||
},
|
||||
"accessControls": {
|
||||
"visibilityScope": "private",
|
||||
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
|
||||
"redactionPolicy": "none",
|
||||
},
|
||||
"styleSignature": {
|
||||
"theme": "velocity_glass",
|
||||
"paletteToken": "ocean_signal",
|
||||
"motionProfile": "calm_reveal",
|
||||
"density": "comfortable",
|
||||
"radiusScale": "lg",
|
||||
"typographyScale": "balanced",
|
||||
},
|
||||
"validationState": {
|
||||
"schema": "pass",
|
||||
"policy": "pass",
|
||||
"a11y": "pass",
|
||||
"performance": "pass",
|
||||
"status": "validated",
|
||||
},
|
||||
"auditLog": [f"aud_{execution_id}_error"],
|
||||
"dataRows": [],
|
||||
}
|
||||
|
||||
async def _call_nemoclaw(
|
||||
self,
|
||||
prompt: str,
|
||||
context: list[dict[str, str]],
|
||||
ctx: PolicyContext,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calls the Nemoclaw hosted model endpoint.
|
||||
Raises on failure so the orchestrator can fall back to demo.
|
||||
"""
|
||||
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",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() # type: ignore[no-any-return]
|
||||
|
||||
async def get_execution(self, execution_id: str) -> dict[str, Any] | None:
|
||||
return _DEMO_EXECUTIONS.get(execution_id)
|
||||
|
||||
async def _persist_execution(self, execution: dict[str, Any]) -> None:
|
||||
_DEMO_EXECUTIONS[execution["executionId"]] = execution
|
||||
if not _db_ready():
|
||||
return
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO oracle_prompt_executions (
|
||||
execution_id, tenant_id, page_id, branch_id, actor_id, prompt, intent_class,
|
||||
status, model_runtime, semantic_model_version, retrieval_plan, visualization_plan,
|
||||
warnings, summary, components_created, client_request_id, created_at, completed_at
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11::jsonb, $12::jsonb,
|
||||
$13::text[], $14, $15::text[], $16, $17::timestamptz, $18::timestamptz
|
||||
)
|
||||
ON CONFLICT (execution_id)
|
||||
DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
retrieval_plan = EXCLUDED.retrieval_plan,
|
||||
visualization_plan = EXCLUDED.visualization_plan,
|
||||
warnings = EXCLUDED.warnings,
|
||||
summary = EXCLUDED.summary,
|
||||
components_created = EXCLUDED.components_created,
|
||||
completed_at = EXCLUDED.completed_at
|
||||
""",
|
||||
execution["executionId"],
|
||||
execution["tenantId"],
|
||||
execution["pageId"],
|
||||
execution["branchId"],
|
||||
execution["actorId"],
|
||||
execution["prompt"],
|
||||
execution["intentClass"],
|
||||
execution["status"],
|
||||
execution["modelRuntime"],
|
||||
execution["semanticModelVersion"],
|
||||
json.dumps(execution.get("retrievalPlan") or {}),
|
||||
json.dumps(execution.get("visualizationPlan") or {}),
|
||||
execution.get("warnings", []),
|
||||
execution.get("summary"),
|
||||
execution.get("componentsCreated", []),
|
||||
execution.get("clientRequestId"),
|
||||
execution["createdAt"],
|
||||
execution.get("completedAt"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
prompt_orchestrator = PromptOrchestrator()
|
||||
364
backend/oracle/router_v1.py
Normal file
364
backend/oracle/router_v1.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
oracle/router_v1.py
|
||||
FastAPI router for all Oracle v1 endpoints.
|
||||
Mounted at /api/oracle/v1 in main.py.
|
||||
|
||||
Endpoints (from spec §13.2):
|
||||
GET /me
|
||||
GET /canvas-pages/{pageId}
|
||||
POST /canvas-pages/{pageId}/prompts
|
||||
POST /canvas-pages/{pageId}/forks
|
||||
POST /canvas-pages/{pageId}/rollback
|
||||
GET /canvas-pages/{pageId}/revisions
|
||||
GET /component-templates
|
||||
POST /component-templates/synthesize (stub)
|
||||
GET /merge-requests
|
||||
POST /merge-requests
|
||||
POST /merge-requests/{mrId}/review
|
||||
WS /ws/oracle/canvas/{pageId}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Set
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .canvas_service import canvas_service
|
||||
from .collaboration_service import collaboration_service
|
||||
from .prompt_orchestrator import prompt_orchestrator
|
||||
from .policy_service import PolicyService, PolicyContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
policy_svc = PolicyService()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _ok(data: Any, meta: dict | None = None) -> dict:
|
||||
return {"status": "ok", "data": data, "meta": meta or {}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _build_user_profile(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"),
|
||||
"timezone": os.getenv("ORACLE_DEFAULT_TIMEZONE", "Asia/Dubai"),
|
||||
"locale": os.getenv("ORACLE_DEFAULT_LOCALE", "en-AE"),
|
||||
"defaultPageId": default_page_id,
|
||||
"canvasPreferences": {
|
||||
"defaultDensity": "comfortable",
|
||||
"defaultPlacementMode": "append_after_last_visible_component",
|
||||
"showLineageBadges": True,
|
||||
},
|
||||
"policyProfileId": os.getenv("ORACLE_POLICY_PROFILE_ID", "policy_sales_director_standard_v4"),
|
||||
"createdAt": os.getenv("ORACLE_PROFILE_CREATED_AT", _now()),
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_current_user() -> 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"),
|
||||
title=os.getenv("ORACLE_DEFAULT_PAGE_TITLE", "Oracle Main Canvas"),
|
||||
)
|
||||
return _build_user_profile(seed_page["pageId"])
|
||||
|
||||
|
||||
async def _ctx_from_me() -> PolicyContext:
|
||||
me = await _get_current_user()
|
||||
return PolicyContext(
|
||||
tenant_id=me["tenantId"],
|
||||
actor_id=me["userId"],
|
||||
actor_role=me["role"],
|
||||
)
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
class PromptSubmitRequest(BaseModel):
|
||||
clientRequestId: str = Field(..., description="Client-generated idempotency key")
|
||||
branchId: str
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
conversationContext: list[dict[str, str]] = Field(default_factory=list)
|
||||
placementMode: str = Field("append_after_last_visible_component")
|
||||
|
||||
|
||||
class ForkCreateRequest(BaseModel):
|
||||
recipientUserId: str
|
||||
sourceRevision: int
|
||||
visibility: str = Field("private", pattern="^(private|team)$")
|
||||
message: str = ""
|
||||
|
||||
|
||||
class RollbackRequest(BaseModel):
|
||||
targetRevision: int = Field(..., ge=1)
|
||||
clientRequestId: str
|
||||
|
||||
|
||||
class MergeRequestCreateRequest(BaseModel):
|
||||
sourcePageId: str
|
||||
sourceBranchId: str
|
||||
targetPageId: str
|
||||
targetBranchId: str
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
description: str = ""
|
||||
|
||||
|
||||
class MergeReviewRequest(BaseModel):
|
||||
decision: str = Field(..., pattern="^(approve|reject|changes_requested)$")
|
||||
comment: str = ""
|
||||
resolutions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TemplateSynthesizeRequest(BaseModel):
|
||||
prompt: str
|
||||
dataShape: list[str]
|
||||
styleSignatureRef: str | None = None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/me", summary="Get current user profile")
|
||||
async def get_me() -> dict:
|
||||
return _ok(await _get_current_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()
|
||||
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.")
|
||||
return _ok(page)
|
||||
|
||||
|
||||
@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()
|
||||
execution = await prompt_orchestrator.execute(
|
||||
tenant_id=ctx.tenant_id,
|
||||
page_id=page_id,
|
||||
branch_id=payload.branchId,
|
||||
actor_id=ctx.actor_id,
|
||||
actor_role=ctx.actor_role,
|
||||
prompt=payload.prompt,
|
||||
conversation_context=payload.conversationContext,
|
||||
client_request_id=payload.clientRequestId,
|
||||
placement_mode=payload.placementMode,
|
||||
)
|
||||
if execution["status"] == "failed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"errors": execution.get("warnings", [])},
|
||||
)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
return _ok({
|
||||
"executionId": execution["executionId"],
|
||||
"status": execution["status"],
|
||||
"pageId": page_id,
|
||||
"branchId": payload.branchId,
|
||||
"headRevision": execution.get("headRevision", page.get("headRevision", 0) if page else 0),
|
||||
"componentsCreated": execution.get("componentsCreated", []),
|
||||
"summary": execution.get("summary", ""),
|
||||
"warnings": execution.get("warnings", []),
|
||||
"components": page.get("components", []) if page else [],
|
||||
})
|
||||
|
||||
|
||||
@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()
|
||||
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,
|
||||
)
|
||||
return _ok(fork)
|
||||
|
||||
|
||||
@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()
|
||||
result = await canvas_service.rollback(
|
||||
page_id=page_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=ctx.actor_id,
|
||||
target_revision=payload.targetRevision,
|
||||
idempotency_key=payload.clientRequestId,
|
||||
)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
return _ok({
|
||||
"pageId": page_id,
|
||||
"headRevision": result.get("revisionNumber", payload.targetRevision),
|
||||
"components": page.get("components", []) if page else [],
|
||||
})
|
||||
|
||||
|
||||
@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()
|
||||
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)})
|
||||
|
||||
|
||||
@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(),
|
||||
}
|
||||
return _ok(template)
|
||||
|
||||
|
||||
@router.get("/merge-requests", summary="List merge requests for a target page")
|
||||
async def list_merge_requests(targetPageId: str | None = None, status: str | None = None) -> dict:
|
||||
if not targetPageId:
|
||||
raise HTTPException(status_code=400, detail="targetPageId query param required")
|
||||
mrs = await collaboration_service.list_merge_requests(targetPageId, status)
|
||||
return _ok(mrs, meta={"count": len(mrs)})
|
||||
|
||||
|
||||
@router.post("/merge-requests", summary="Open a merge request")
|
||||
async def create_merge_request(payload: MergeRequestCreateRequest) -> dict:
|
||||
ctx = await _ctx_from_me()
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Source or target page not found.")
|
||||
|
||||
mr = await collaboration_service.open_merge_request(
|
||||
tenant_id=ctx.tenant_id,
|
||||
source_page_id=payload.sourcePageId,
|
||||
source_branch_id=payload.sourceBranchId,
|
||||
source_head_revision=source_page.get("headRevision", 0),
|
||||
target_page_id=payload.targetPageId,
|
||||
target_branch_id=payload.targetBranchId,
|
||||
target_base_revision=target_page.get("headRevision", 0),
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
created_by=ctx.actor_id,
|
||||
source_components=source_page.get("components", []),
|
||||
target_components=target_page.get("components", []),
|
||||
base_components=[], # Simplified: empty base for demo
|
||||
)
|
||||
return _ok(mr)
|
||||
|
||||
|
||||
@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()
|
||||
mr = await collaboration_service.review_merge_request(
|
||||
mr_id=mr_id,
|
||||
decision=payload.decision,
|
||||
reviewer_id=ctx.actor_id,
|
||||
comment=payload.comment,
|
||||
resolutions=payload.resolutions,
|
||||
)
|
||||
return _ok(mr)
|
||||
|
||||
|
||||
# ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class OracleConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self.active: dict[str, Set[WebSocket]] = {}
|
||||
|
||||
async def connect(self, ws: WebSocket, page_id: str) -> None:
|
||||
await ws.accept()
|
||||
self.active.setdefault(page_id, set()).add(ws)
|
||||
|
||||
def disconnect(self, ws: WebSocket, page_id: str) -> None:
|
||||
page_connections = self.active.get(page_id, set())
|
||||
page_connections.discard(ws)
|
||||
|
||||
async def broadcast_page(self, page_id: str, payload: dict) -> None:
|
||||
dead: set[WebSocket] = set()
|
||||
for ws in self.active.get(page_id, set()):
|
||||
try:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
if dead:
|
||||
self.active.get(page_id, set()).difference_update(dead)
|
||||
|
||||
|
||||
oracle_manager = OracleConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/ws/oracle/canvas/{page_id}")
|
||||
async def oracle_canvas_ws(ws: WebSocket, page_id: str) -> None:
|
||||
"""
|
||||
WebSocket endpoint for real-time Oracle canvas collaboration.
|
||||
Event types: oracle.page.revision.committed, oracle.prompt.received, oracle.presence.updated
|
||||
"""
|
||||
await oracle_manager.connect(ws, page_id)
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive_text()
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
# Reflect heartbeat
|
||||
if msg.get("type") == "heartbeat":
|
||||
await ws.send_text(json.dumps({"type": "heartbeat.ack", "timestamp": _now()}))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
oracle_manager.disconnect(ws, page_id)
|
||||
|
||||
|
||||
# ── 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"]},
|
||||
]
|
||||
206
backend/oracle/schema_oracle.sql
Normal file
206
backend/oracle/schema_oracle.sql
Normal file
@@ -0,0 +1,206 @@
|
||||
-- Oracle Canvas Schema — Section 16.4 of the Oracle Architecture Document v1.0
|
||||
-- Run this against your PostgreSQL database to create the Oracle persistence layer.
|
||||
-- Requires: UUID extension, JSONB support (PostgreSQL 14+)
|
||||
|
||||
-- ── Prerequisites ─────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ── Core tables ───────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_canvas_pages (
|
||||
page_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
branch_id TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL DEFAULT 'main',
|
||||
page_type TEXT NOT NULL DEFAULT 'main' CHECK (page_type IN ('main', 'fork')),
|
||||
title TEXT NOT NULL DEFAULT 'Untitled Canvas',
|
||||
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
head_revision INTEGER NOT NULL DEFAULT 0,
|
||||
base_revision INTEGER NOT NULL DEFAULT 0,
|
||||
sharing_policy JSONB NOT NULL DEFAULT '{"shareMode":"direct_fork_only","allowReshare":false,"defaultForkVisibility":"private"}'::JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_canvas_page_revisions (
|
||||
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
commit_kind TEXT NOT NULL CHECK (commit_kind IN ('prompt', 'merge', 'rollback', 'manual_edit')),
|
||||
commit_summary TEXT,
|
||||
actor_id TEXT NOT NULL,
|
||||
execution_id UUID,
|
||||
merge_request_id UUID,
|
||||
components_snapshot JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
idempotency_key TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (page_id, revision_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_canvas_components (
|
||||
component_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
lifecycle_state TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle_state IN ('draft','active','superseded','archived','revoked')),
|
||||
data_source_descriptor JSONB NOT NULL,
|
||||
visualization_parameters JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
data_bindings JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
provenance JSONB NOT NULL,
|
||||
rendering_hints JSONB NOT NULL,
|
||||
layout JSONB NOT NULL,
|
||||
access_controls JSONB NOT NULL,
|
||||
style_signature JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
validation_state JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
audit_log TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_prompt_executions (
|
||||
execution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
|
||||
branch_id TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
intent_class TEXT NOT NULL DEFAULT 'analytical',
|
||||
status TEXT NOT NULL DEFAULT 'received',
|
||||
model_runtime TEXT NOT NULL DEFAULT 'nemoclaw_hosted',
|
||||
semantic_model_version TEXT NOT NULL DEFAULT 'oracle_semantic_v1',
|
||||
retrieval_plan JSONB,
|
||||
visualization_plan JSONB,
|
||||
warnings TEXT[] NOT NULL DEFAULT '{}',
|
||||
summary TEXT,
|
||||
components_created TEXT[] NOT NULL DEFAULT '{}',
|
||||
client_request_id TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_component_templates (
|
||||
template_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'catalog_active',
|
||||
origin TEXT NOT NULL DEFAULT 'premade',
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
accepted_shapes TEXT[] NOT NULL DEFAULT '{}',
|
||||
style_signature JSONB DEFAULT NULL,
|
||||
validation_state JSONB DEFAULT NULL,
|
||||
provenance JSONB DEFAULT NULL,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_forks (
|
||||
fork_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
|
||||
source_branch_id TEXT NOT NULL,
|
||||
source_revision INTEGER NOT NULL,
|
||||
fork_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
|
||||
fork_branch_id TEXT NOT NULL,
|
||||
recipient_user_id TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','merged','closed')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_merge_requests (
|
||||
merge_request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
|
||||
source_branch_id TEXT NOT NULL,
|
||||
source_head_revision INTEGER NOT NULL,
|
||||
target_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
|
||||
target_branch_id TEXT NOT NULL,
|
||||
target_base_revision INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','changes_requested','approved','merged','closed')),
|
||||
conflicts JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
diff_summary JSONB DEFAULT NULL,
|
||||
resolutions JSONB DEFAULT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
reviewed_by TEXT,
|
||||
reviewer_comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_lineage_records (
|
||||
lineage_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
transformation_type TEXT NOT NULL,
|
||||
transformation_spec_hash TEXT,
|
||||
produced_kind TEXT NOT NULL,
|
||||
produced_id TEXT NOT NULL,
|
||||
policy_snapshot_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oracle_audit_events (
|
||||
audit_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL DEFAULT 'user',
|
||||
correlation_id TEXT NOT NULL,
|
||||
execution_id UUID,
|
||||
details JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Canvas pages: tenant lookup, branch lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_pages_tenant ON oracle_canvas_pages(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_pages_owner ON oracle_canvas_pages(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_pages_branch ON oracle_canvas_pages(branch_id);
|
||||
|
||||
-- Revisions: page-scoped revision queries
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_page ON oracle_canvas_page_revisions(page_id, revision_number DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_tenant ON oracle_canvas_page_revisions(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_execution ON oracle_canvas_page_revisions(execution_id);
|
||||
|
||||
-- Components: page-scoped, lifecycle
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_components_page ON oracle_canvas_components(page_id, lifecycle_state);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_components_tenant ON oracle_canvas_components(tenant_id);
|
||||
|
||||
-- Prompt executions: page/actor lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_executions_page ON oracle_prompt_executions(page_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_executions_actor ON oracle_prompt_executions(actor_id, created_at DESC);
|
||||
|
||||
-- Templates: tenant + category + status
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_templates_tenant_cat ON oracle_component_templates(tenant_id, category, status);
|
||||
|
||||
-- Forks: source and recipient lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_forks_source ON oracle_forks(source_page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_forks_recipient ON oracle_forks(recipient_user_id);
|
||||
|
||||
-- Merge requests: target/source page, status
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_target ON oracle_merge_requests(target_page_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_source ON oracle_merge_requests(source_page_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_tenant ON oracle_merge_requests(tenant_id, status);
|
||||
|
||||
-- Lineage: source/produced lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_source ON oracle_lineage_records(source_kind, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_produced ON oracle_lineage_records(produced_kind, produced_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_tenant ON oracle_lineage_records(tenant_id);
|
||||
|
||||
-- Audit: entity lookup, correlation lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_audit_entity ON oracle_audit_events(entity_type, entity_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_audit_correlation ON oracle_audit_events(correlation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_audit_tenant ON oracle_audit_events(tenant_id, created_at DESC);
|
||||
Reference in New Issue
Block a user