Built the Oracle Tab (#14)

This commit is contained in:
2026-04-11 19:35:45 +05:30
committed by Sagnik
parent 8e1ffe0e43
commit fb656d1443
54 changed files with 10651 additions and 818 deletions

View File

@@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
from api.routes_catalyst import router as catalyst_router
from oracle.router_v1 import router as oracle_router
load_dotenv()
@@ -40,6 +41,7 @@ app.add_middleware(
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
app.include_router(oracle_router, prefix="/api/oracle/v1", tags=["Oracle"])
# ── WebSocket — Live Optimization Feed ────────────────────────────────────────

View File

@@ -0,0 +1 @@
# Oracle services package

View 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()

View 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()

View 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()

View 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,
)

View 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
View 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"]},
]

View 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);

View File

@@ -6,3 +6,4 @@ python-dotenv>=1.0.0
httpx>=0.27.0
pydantic>=2.9.0
python-multipart>=0.0.12
asyncpg>=0.30.0

View File

@@ -0,0 +1 @@
"""Tests for Oracle backend services — runs without any live database or model runtime."""

View File

@@ -0,0 +1 @@
"""Tests for Oracle backend services — runs without any live database or model runtime."""

View File

@@ -0,0 +1,133 @@
"""
test_canvas_service.py — Unit tests for CanvasService (demo mode in-memory store).
"""
import asyncio
import pytest
import sys
import os
# Ensure backend is on path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from oracle.canvas_service import CanvasService
TENANT = "tenant_test_001"
ACTOR = "user_test_001"
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@pytest.fixture
def svc():
"""Fresh CanvasService instance with empty demo store per test."""
from oracle import canvas_service as _mod
_mod._DEMO_PAGES.clear()
_mod._DEMO_REVISIONS.clear()
_mod._DEMO_COMPONENTS.clear()
return CanvasService()
def test_create_page(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR, title="Test Canvas"))
assert page["tenantId"] == TENANT
assert page["ownerId"] == ACTOR
assert page["title"] == "Test Canvas"
assert page["headRevision"] == 0
assert page["pageType"] == "main"
assert page["branchName"] == "main"
def test_get_page_not_found(svc):
result = run(svc.get_page("nonexistent_id", TENANT))
assert result is None
def test_get_page_returns_page(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
retrieved = run(svc.get_page(page["pageId"], TENANT))
assert retrieved is not None
assert retrieved["pageId"] == page["pageId"]
def test_commit_revision_advances_head(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
comps = [{"componentId": "cmp_001", "type": "barChart", "title": "Test"}]
rev = run(svc.commit_revision(
page_id=page["pageId"],
tenant_id=TENANT,
actor_id=ACTOR,
commit_kind="prompt",
commit_summary="Test prompt",
components=comps,
idempotency_key="ikey_001",
))
assert rev["revisionNumber"] == 1
updated = run(svc.get_page(page["pageId"], TENANT))
assert updated["headRevision"] == 1
assert len(updated["components"]) == 1
def test_idempotency_key_prevents_double_commit(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
comps = [{"componentId": "cmp_001", "type": "barChart", "title": "Test"}]
rev1 = run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="First", components=comps,
idempotency_key="ikey_idempotent",
))
rev2 = run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="Duplicate", components=comps,
idempotency_key="ikey_idempotent",
))
assert rev1["revisionId"] == rev2["revisionId"]
# Head should still be 1
updated = run(svc.get_page(page["pageId"], TENANT))
assert updated["headRevision"] == 1
def test_rollback_creates_new_revision(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
comps_v1 = [{"componentId": "cmp_v1", "type": "barChart", "title": "V1"}]
comps_v2 = [{"componentId": "cmp_v2", "type": "lineChart", "title": "V2"}]
run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="V1", components=comps_v1, idempotency_key="key_v1",
))
run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="V2", components=comps_v2, idempotency_key="key_v2",
))
# Rollback to revision 1
rollback_rev = run(svc.rollback(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
target_revision=1, idempotency_key="key_rollback",
))
assert rollback_rev["revisionNumber"] == 3
assert rollback_rev["commitKind"] == "rollback"
revisions = run(svc.list_revisions(page["pageId"], TENANT))
assert len(revisions) == 3 # 3 revisions total
def test_list_revisions_returns_newest_first(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
for i in range(3):
run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary=f"Rev {i+1}",
components=[], idempotency_key=f"key_{i}",
))
revisions = run(svc.list_revisions(page["pageId"], TENANT))
assert revisions[0]["revisionNumber"] > revisions[-1]["revisionNumber"]
def test_tenant_isolation(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
# Different tenant cannot access the page
result = run(svc.get_page(page["pageId"], "tenant_different_999"))
assert result is None

View File

@@ -0,0 +1,207 @@
"""
test_collaboration_service.py — Unit tests for three-way diff, fork, merge request lifecycle.
"""
import asyncio
import copy
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from oracle.collaboration_service import CollaborationService, three_way_diff
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
def _comp(cid, title="Test", order=100, content="default"):
return {
"componentId": cid,
"type": "barChart",
"title": title,
"dataSourceDescriptor": {"dataset": "test_ds", "queryTemplate": content},
"accessControls": {"allowedRoles": ["sales_director"], "visibilityScope": "private"},
"layout": {"orderIndex": order, "sectionId": "sec_test", "widthMode": "full"},
}
# ── Three-way diff tests ──────────────────────────────────────────────────────
def test_safe_append_in_source():
base = [_comp("cmp_a")]
source = [_comp("cmp_a"), _comp("cmp_b")] # cmp_b added in source
target = [_comp("cmp_a")]
merged, conflicts = three_way_diff(base, source, target)
assert any(c["conflictClass"] == "safe_append" and c["componentId"] == "cmp_b" for c in conflicts)
assert any(c["componentId"] == "cmp_b" for c in merged)
def test_no_conflict_when_identical():
base = [_comp("cmp_a")]
source = [_comp("cmp_a")]
target = [_comp("cmp_a")]
merged, conflicts = three_way_diff(base, source, target)
assert len(merged) == 1
assert all(c["conflictClass"] not in ("component_content_conflict", "query_descriptor_conflict") for c in conflicts)
def test_component_content_conflict():
base = [_comp("cmp_a", content="SELECT 1")]
source = [_comp("cmp_a", content="SELECT 2")]
target = [_comp("cmp_a", content="SELECT 3")]
merged, conflicts = three_way_diff(base, source, target)
# Expect query_descriptor_conflict or component_content_conflict
conflict_classes = {c["conflictClass"] for c in conflicts}
assert conflict_classes & {"component_content_conflict", "query_descriptor_conflict"}
def test_delete_edit_conflict_source_deletes():
base = [_comp("cmp_a"), _comp("cmp_b")]
source = [_comp("cmp_a")] # cmp_b deleted in source
target = [_comp("cmp_a"), _comp("cmp_b", title="Edited in target")] # cmp_b edited in target
merged, conflicts = three_way_diff(base, source, target)
assert any(c["conflictClass"] == "delete_edit_conflict" and c["componentId"] == "cmp_b" for c in conflicts)
# Default: keep target (edited version)
assert any(c["componentId"] == "cmp_b" for c in merged)
def test_deleted_in_both_is_removed():
base = [_comp("cmp_a"), _comp("cmp_b")]
source = [_comp("cmp_a")]
target = [_comp("cmp_a")]
merged, conflicts = three_way_diff(base, source, target)
assert not any(c["componentId"] == "cmp_b" for c in merged)
def test_orderindex_normalization():
base = []
source = [_comp("c1", order=100), _comp("c2", order=200)]
target = [_comp("c1", order=100), _comp("c2", order=200)]
merged, _ = three_way_diff(base, source, target)
orders = [c["layout"]["orderIndex"] for c in merged]
# Orders should be normalized (multiples of 100, sequential)
assert orders == sorted(orders)
assert all(o % 100 == 0 for o in orders)
# ── CollaborationService tests ────────────────────────────────────────────────
@pytest.fixture
def collab():
from oracle import collaboration_service as _mod
_mod._DEMO_FORKS.clear()
_mod._DEMO_MRS.clear()
return CollaborationService()
def test_create_fork(collab):
source_page = {
"pageId": "page_src",
"branchId": "branch_main",
"headRevision": 5,
"components": [],
}
fork = run(collab.create_fork(
source_page=source_page,
recipient_user_id="user_recipient",
created_by="user_src",
))
assert fork["sourcePageId"] == "page_src"
assert fork["sourceRevision"] == 5
assert fork["status"] == "active"
assert fork["recipientUserId"] == "user_recipient"
def test_merge_request_lifecycle(collab):
mr = run(collab.open_merge_request(
tenant_id="tenant_test",
source_page_id="page_fork",
source_branch_id="branch_fork",
source_head_revision=2,
target_page_id="page_main",
target_branch_id="branch_main",
target_base_revision=5,
title="Test MR",
description="My changes",
created_by="user_a",
source_components=[_comp("cmp_a"), _comp("cmp_new")],
target_components=[_comp("cmp_a")],
base_components=[_comp("cmp_a")],
))
assert mr["status"] == "open"
assert "mergeRequestId" in mr
# Approve it
reviewed = run(collab.review_merge_request(
mr_id=mr["mergeRequestId"],
decision="approve",
reviewer_id="user_reviewer",
))
assert reviewed["status"] == "merged"
def test_merge_request_reject(collab):
mr = run(collab.open_merge_request(
tenant_id="tenant_test",
source_page_id="page_fork",
source_branch_id="branch_fork",
source_head_revision=1,
target_page_id="page_main",
target_branch_id="branch_main",
target_base_revision=1,
title="Rejected MR",
created_by="user_a",
source_components=[],
target_components=[],
base_components=[],
))
reviewed = run(collab.review_merge_request(
mr_id=mr["mergeRequestId"],
decision="reject",
reviewer_id="user_reviewer",
))
assert reviewed["status"] == "closed"
def test_list_merge_requests_filters_by_target(collab):
for i in range(3):
run(collab.open_merge_request(
tenant_id="tenant_t",
source_page_id=f"page_fork_{i}",
source_branch_id=f"branch_{i}",
source_head_revision=1,
target_page_id="page_target",
target_branch_id="branch_main",
target_base_revision=1,
title=f"MR {i}",
created_by="user_a",
source_components=[],
target_components=[],
base_components=[],
))
# Different target
run(collab.open_merge_request(
tenant_id="tenant_t",
source_page_id="page_other",
source_branch_id="branch_other",
source_head_revision=1,
target_page_id="page_other_target",
target_branch_id="branch_main",
target_base_revision=1,
title="Different target MR",
created_by="user_b",
source_components=[],
target_components=[],
base_components=[],
))
mrs = run(collab.list_merge_requests("page_target"))
assert len(mrs) == 3
assert all(mr["targetPageId"] == "page_target" for mr in mrs)

View File

@@ -0,0 +1,142 @@
"""
test_policy_service.py — Unit tests for Oracle policy engine.
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from oracle.policy_service import PolicyService, PolicyContext
@pytest.fixture
def svc():
return PolicyService()
def _ctx(role: str = "sales_director") -> PolicyContext:
return PolicyContext(
tenant_id="tenant_test_001",
actor_id="user_test_001",
actor_role=role,
)
# ── Privacy tier tests ────────────────────────────────────────────────────────
def test_junior_broker_denied_restricted(svc):
plan = {"dataset": "lead_contacts", "privacyTier": "restricted", "rowLimit": 50, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("junior_broker"))
assert not result.passed
assert any("POLICY_PRIVACY_TIER_ESCALATION" in e for e in result.errors)
def test_senior_broker_allowed_restricted_with_redaction(svc):
plan = {"dataset": "lead_contacts", "privacyTier": "restricted", "rowLimit": 50, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("senior_broker"))
assert result.passed
assert result.redaction_policy == "aggregate_only"
def test_junior_broker_denied_sensitive(svc):
plan = {"dataset": "pii_records", "privacyTier": "sensitive", "rowLimit": 10, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("junior_broker"))
assert not result.passed
def test_data_steward_allowed_sensitive(svc):
plan = {"dataset": "pii_records", "privacyTier": "sensitive", "rowLimit": 100, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("data_steward"))
assert result.passed
# ── Row limit tests ───────────────────────────────────────────────────────────
def test_row_limit_capped_for_junior_broker(svc):
plan = {"dataset": "leads", "privacyTier": "standard", "rowLimit": 5000, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("junior_broker"))
assert result.passed
assert result.effective_row_limit == 100
assert any("ROW_LIMIT_CAPPED" in w for w in result.warnings)
def test_row_limit_respected_for_admin(svc):
plan = {"dataset": "leads", "privacyTier": "standard", "rowLimit": 5000, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("platform_admin"))
assert result.passed
assert result.effective_row_limit == 5000
# ── Cross-tenant join tests ───────────────────────────────────────────────────
def test_cross_tenant_join_denied(svc):
plan = {
"dataset": "global_lead_market",
"privacyTier": "standard",
"rowLimit": 50,
"joins": [],
}
result = svc.validate_retrieval_plan(plan, _ctx("sales_director"))
assert not result.passed
assert any("POLICY_CROSS_TENANT_JOIN_DENIED" in e for e in result.errors)
def test_explicit_cross_tenant_join_denied(svc):
plan = {
"dataset": "deals",
"privacyTier": "standard",
"rowLimit": 50,
"joins": [{"tenantId": "tenant_other_999"}],
}
result = svc.validate_retrieval_plan(plan, _ctx("sales_director"))
assert not result.passed
# ── Tenant predicate enforcement ──────────────────────────────────────────────
def test_enforce_tenant_predicate_overrides(svc):
params = {"tenant_id": "attacker_tenant", "limit": 100}
ctx = _ctx("sales_director")
enforced = svc.enforce_tenant_predicate(params, ctx)
assert enforced["tenant_id"] == "tenant_test_001"
assert enforced["limit"] == 100
# ── Component access control ──────────────────────────────────────────────────
def test_component_access_granted_for_allowed_role(svc):
ac = {"allowedRoles": ["sales_director", "senior_broker"], "visibilityScope": "private"}
assert svc.validate_component_access(ac, _ctx("sales_director")) is True
def test_component_access_denied_for_wrong_role(svc):
ac = {"allowedRoles": ["data_steward"], "visibilityScope": "private"}
assert svc.validate_component_access(ac, _ctx("junior_broker")) is False
# ── Redaction tests ───────────────────────────────────────────────────────────
def test_redact_full():
svc = PolicyService()
rows = [{"name": "Alice", "email": "alice@test.com", "deal": 1000}]
redacted = svc.redact(rows, "full_redact")
assert redacted == [{"__redacted__": True, "count": 1}]
def test_redact_aggregate_only():
svc = PolicyService()
rows = [{"name": "Alice", "count": 5, "stage": "Qualified", "email": "alice@test.com"}]
redacted = svc.redact(rows, "aggregate_only")
assert len(redacted) == 1
assert "email" not in redacted[0]
assert "name" not in redacted[0]
assert redacted[0].get("count") == 5
assert redacted[0].get("stage") == "Qualified"
def test_redact_none_passes_through():
svc = PolicyService()
rows = [{"name": "Alice", "value": 999}]
result = svc.redact(rows, "none")
assert result == rows

View File

@@ -0,0 +1,143 @@
"""
test_prompt_orchestrator.py — Unit tests for PromptOrchestrator (demo/fallback mode).
"""
import asyncio
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@pytest.fixture(autouse=True)
def clear_demo_stores():
from oracle import canvas_service as _cs
from oracle import collaboration_service as _col
_cs._DEMO_PAGES.clear()
_cs._DEMO_REVISIONS.clear()
_cs._DEMO_COMPONENTS.clear()
_col._DEMO_MRS.clear()
yield
@pytest.fixture
def page():
"""Create a demo canvas page and return it."""
from oracle.canvas_service import canvas_service
return run(canvas_service.create_page(
tenant_id="tenant_test",
owner_id="user_test",
title="Test Oracle Page",
))
def test_pipeline_prompt_produces_components(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Show me an active pipeline view by stage",
client_request_id="cli_test_001",
))
assert result["status"] == "completed"
assert len(result["componentsCreated"]) > 0
assert result["summary"]
def test_geo_map_prompt_produces_geo_component(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Show me a map of whale leads by Dubai district",
client_request_id="cli_test_002",
))
assert result["status"] == "completed"
vp = result.get("visualizationPlan", {}).get("components", [])
assert any(c["type"] == "geoMap" for c in vp)
def test_broker_table_prompt(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Give me a table of brokers ranked by performance",
client_request_id="cli_test_003",
))
assert result["status"] == "completed"
vp = result.get("visualizationPlan", {}).get("components", [])
assert any(c["type"] == "table" for c in vp)
def test_policy_denial_on_restricted_for_junior_broker(page):
"""Junior broker should get warnings/denial on restricted tier dataset."""
from oracle.prompt_orchestrator import PromptOrchestrator, _build_demo_retrieval_plan
from oracle.policy_service import PolicyService, PolicyContext
plan = {
"components": [{
"suggestedType": "table",
"dataset": "pii_leads",
"privacyTier": "sensitive",
"rowLimit": 500,
"joins": [],
}]
}
svc = PolicyService()
ctx = PolicyContext(tenant_id="tenant_test", actor_id="user_junior", actor_role="junior_broker")
for comp_plan in plan["components"]:
result = svc.validate_retrieval_plan(comp_plan, ctx)
assert not result.passed
def test_idempotency_key_prevents_double_execution(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
prompt_kwargs = dict(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Pipeline view",
client_request_id="cli_idempotent_key",
)
result1 = run(orch.execute(**prompt_kwargs))
result2 = run(orch.execute(**prompt_kwargs))
# Both should succeed; canvas should not double-create components
assert result1["status"] == "completed"
assert result2["status"] == "completed"
def test_kpi_prompt_type(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="How many total leads do we have?",
client_request_id="cli_test_kpi",
))
vp = result.get("visualizationPlan", {}).get("components", [])
assert any(c["type"] == "kpiTile" for c in vp)