""" 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, Literal, Set import httpx from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status from pydantic import BaseModel, Field from backend.auth.dependencies import UserPrincipal, get_current_user from backend.services.colony_gateway import ColonyConfigurationError, ColonyGateway, ColonyGatewayError from backend.services.colony_repository import ColonyRepository from .canvas_service import canvas_service from .collaboration_service import collaboration_service from .action_service import oracle_action_service from .persona_service import persona_service from .prompt_orchestrator import prompt_orchestrator from .policy_service import PolicyService, PolicyContext from .codebook_service import codebook_service logger = logging.getLogger(__name__) router = APIRouter() policy_svc = PolicyService() _DEFAULT_TENANT_ID = os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity") # ── Helpers ─────────────────────────────────────────────────────────────────── 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 _normalize_oracle_role(role: str) -> str: mapping = { "JUNIOR_BROKER": "junior_broker", "SENIOR_BROKER": "senior_broker", "SALES_DIRECTOR": "sales_director", "ADMIN": "platform_admin", } return mapping.get(role.strip().upper(), "sales_director") def _build_user_profile( *, user_id: str, email: str, display_name: str, role: str, avatar_url: str | None, default_page_id: str, ) -> dict[str, Any]: return { "userId": user_id, "tenantId": _DEFAULT_TENANT_ID, "email": email, "displayName": display_name, "role": _normalize_oracle_role(role), "avatarUrl": avatar_url, "timezone": os.getenv("ORACLE_DEFAULT_TIMEZONE", "Asia/Dubai"), "locale": os.getenv("ORACLE_DEFAULT_LOCALE", "en-AE"), "defaultPageId": default_page_id, "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_profile(request: Request, user: UserPrincipal) -> dict[str, Any]: seed_page = await canvas_service.ensure_default_page( tenant_id=_DEFAULT_TENANT_ID, owner_id=user.user_id, title=os.getenv("ORACLE_DEFAULT_PAGE_TITLE", "Oracle Main Canvas"), ) pool = getattr(request.app.state, "db_pool", None) if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT COALESCE(full_name, split_part(email, '@', 1), id::text) AS display_name, COALESCE(email, id::text || '@velocity.local') AS email, avatar_url FROM users_and_roles WHERE id = $1::uuid """, user.user_id, ) return _build_user_profile( user_id=user.user_id, email=row["email"] if row else f"{user.user_id}@velocity.local", display_name=row["display_name"] if row else user.user_id, role=user.role, avatar_url=row["avatar_url"] if row else None, default_page_id=seed_page["pageId"], ) async def _ctx_from_request(request: Request, user: UserPrincipal) -> PolicyContext: me = await _get_current_user_profile(request, user) return PolicyContext( tenant_id=me["tenantId"], actor_id=me["userId"], actor_role=me["role"], ) async def _resolve_page_id(request: Request, user: UserPrincipal, page_id: str) -> str: normalized = (page_id or "").strip() if normalized and normalized.lower() != "main": return normalized me = await _get_current_user_profile(request, user) return str(me["defaultPageId"]) def _oracle_prompt_complexity(prompt: str, conversation_context: list[dict[str, str]] | None = None) -> tuple[str, list[str]]: text = prompt.lower() reasons: list[str] = [] complex_markers = ( "multi-round", "multi round", "coordinate", "orchestrate", "compare", "writeback", "write back", "approve", "crm", "catalyst", "campaign", "social", "inventory", "next best action", "next-best action", "follow-up plan", "strategy", "across", ) matched = [marker for marker in complex_markers if marker in text] if matched: reasons.append(f"complex markers: {', '.join(matched[:5])}") if len(prompt) > 700: reasons.append("long prompt") if conversation_context and len(conversation_context) >= 4: reasons.append("multi-turn context") return ("thinking" if reasons else "fast", reasons) def _next_canvas_order(components: list[dict[str, Any]]) -> int: highest = 0 for component in components: try: highest = max(highest, int((component.get("layout") or {}).get("orderIndex", 0))) except (TypeError, ValueError): continue return ((highest // 100) + 1) * 100 def _build_colony_status_component( *, execution_id: str, mission_id: str, prompt: str, actor_id: str, branch_id: str, mode: str, reasons: list[str], order_index: int, ) -> dict[str, Any]: reason_text = "; ".join(reasons) if reasons else "operator selected thinking mode" return { "componentId": str(uuid.uuid4()), "type": "textCanvas", "title": "Colony Mission Dispatched", "description": "Oracle routed a complex request to Colony orchestration.", "dataSourceDescriptor": { "descriptorId": str(uuid.uuid4()), "sourceType": "api", "connectorId": "velocity-colony", "dataset": "colony_mission", "authContextRef": f"authctx_{actor_id}_scope", "queryTemplate": f"/api/colony/missions/{mission_id}", "queryParameters": {"missionId": mission_id}, "rowLimit": 1, "privacyTier": "standard", "cachePolicy": {"mode": "none"}, }, "visualizationParameters": { "content": ( f"Oracle routed this request to Colony because it needs deeper orchestration.\n\n" f"Mission ID: {mission_id}\n" f"Mode: {mode}\n" f"Routing reason: {reason_text}\n\n" f"Original request: {prompt}\n\n" "Colony will coordinate specialist workers and return artifacts/writeback proposals for review." ), "widthMode": "full", "adjustableHeight": 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(), "colonyMissionId": mission_id, }, "renderingHints": {"estimatedHeightPx": 220, "skeletonVariant": "text", "virtualizationPriority": 5}, "layout": { "orderIndex": order_index, "sectionId": f"sec_colony_{execution_id.replace('-', '')[:12]}", "widthMode": "full", "minHeightPx": 220, "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}_colony_dispatch"], "dataRows": [{"missionId": mission_id, "mode": mode, "routingReason": reason_text}], } async def _dispatch_colony_from_oracle_prompt( *, request: Request, ctx: PolicyContext, page_id: str, payload: PromptSubmitRequest, resolved_mode: str, routing_reasons: list[str], ) -> dict[str, Any]: pool = getattr(request.app.state, "db_pool", None) if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") try: gateway = ColonyGateway() except ColonyConfigurationError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc repo = ColonyRepository(pool) mission_id = str(uuid.uuid4()) execution_id = str(uuid.uuid4()) mission = { "mission_id": mission_id, "mission_type": "oracle_advisory", "origin_surface": "oracle_canvas", "tenant_id": ctx.tenant_id, "actor_id": ctx.actor_id, "actor_role": ctx.actor_role, "risk_level": "medium", "sensitivity_class": "internal", "time_budget_ms": 120000, "token_budget": 24000, "user_goal": payload.prompt, "normalized_goal": payload.prompt, "context_refs": { "page_id": page_id, "branch_id": payload.branchId, "client_request_id": payload.clientRequestId, "execution_mode": payload.executionMode, "resolved_mode": resolved_mode, "target_lead_id": payload.targetLeadId, "conversation_context": payload.conversationContext, }, "requested_outputs": ["canvas_artifacts", "summary", "writeback_proposals"], "payload": { "planned_writeback": payload.plannedWriteback, "placement_mode": payload.placementMode, "routing_reasons": routing_reasons, }, } row = await repo.create_mission(mission) await repo.log_event( mission_id=mission_id, tenant_id=ctx.tenant_id, event_type="mission_created_from_oracle_canvas", actor=ctx.actor_id, detail={"page_id": page_id, "execution_id": execution_id, "resolved_mode": resolved_mode}, ) try: dispatch = await gateway.dispatch_mission(mission) except (ColonyGatewayError, httpx.HTTPError) as exc: await repo.update_status(mission_id, ctx.tenant_id, "dispatch_failed") await repo.log_event( mission_id=mission_id, tenant_id=ctx.tenant_id, event_type="mission_dispatch_failed", actor=ctx.actor_id, detail={"error": str(exc)}, ) raise HTTPException( status_code=502, detail={"message": str(exc), "mission_id": str(row["mission_id"])}, ) from exc queued = await repo.update_status(mission_id, ctx.tenant_id, "queued") await repo.log_event( mission_id=mission_id, tenant_id=ctx.tenant_id, event_type="mission_dispatched", actor=ctx.actor_id, detail={"dispatch": dispatch}, ) page = await canvas_service.get_page(page_id, ctx.tenant_id) existing_components = page.get("components", []) if page else [] component = _build_colony_status_component( execution_id=execution_id, mission_id=mission_id, prompt=payload.prompt, actor_id=ctx.actor_id, branch_id=payload.branchId, mode=resolved_mode, reasons=routing_reasons, order_index=_next_canvas_order(existing_components), ) revision = await canvas_service.commit_revision( page_id=page_id, tenant_id=ctx.tenant_id, actor_id=ctx.actor_id, commit_kind="prompt", commit_summary=f"Colony: {payload.prompt[:80]}", components=existing_components + [component], execution_id=execution_id, idempotency_key=payload.clientRequestId, ) execution = { "executionId": execution_id, "tenantId": ctx.tenant_id, "pageId": page_id, "branchId": payload.branchId, "actorId": ctx.actor_id, "prompt": payload.prompt, "intentClass": "mixed", "status": "executing", "modelRuntime": "colony_orchestrator", "semanticModelVersion": "oracle_colony_router_v2026_05_03", "retrievalPlan": {"route": "colony", "missionId": mission_id, "dispatch": dispatch}, "visualizationPlan": {"components": [component]}, "warnings": [], "summary": f"Colony mission {mission_id} queued for multi-agent orchestration.", "componentsCreated": [component["componentId"]], "clientRequestId": payload.clientRequestId, "createdAt": _now(), "completedAt": None, "workflowDispatch": {"type": "colony_mission", "missionId": mission_id, "status": (queued or row)["status"]}, } await prompt_orchestrator._persist_execution(execution) return {"execution": execution, "page": await canvas_service.get_page(page_id, ctx.tenant_id)} # ── 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") executionMode: Literal["auto", "fast", "thinking"] = "auto" targetLeadId: str | None = None plannedWriteback: dict[str, Any] = Field(default_factory=dict) 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 class PersonaRenderRequest(BaseModel): promptName: str = Field(..., pattern="^(qd_calculator|lead_tagger|cctv_profiler)$") variables: dict[str, Any] = Field(default_factory=dict) class PageCreateRequest(BaseModel): title: str = Field(default="Untitled Canvas", max_length=256) class PageUpdateRequest(BaseModel): title: str = Field(..., min_length=1, max_length=256) # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/mobile/team-performance", summary="Mobile Oracle team performance intelligence") async def mobile_team_performance( request: Request, limit: int = 12, user: UserPrincipal = Depends(get_current_user), ) -> dict: pool = getattr(request.app.state, "db_pool", None) if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") tenant_id = user.tenant_id or _DEFAULT_TENANT_ID safe_limit = max(1, min(limit, 50)) async with pool.acquire() as conn: rows = await conn.fetch( """ WITH team AS ( SELECT id, full_name, email, avatar_url FROM users_and_roles WHERE COALESCE(is_active, TRUE) = TRUE ), lead_rollup AS ( SELECT assigned_user_id, COUNT(*)::int AS assigned_leads, COUNT(*) FILTER (WHERE COALESCE(status::text, '') IN ('won', 'closed_won', 'booked'))::int AS won_leads FROM crm_leads WHERE tenant_id = $1 GROUP BY assigned_user_id ), opportunity_rollup AS ( SELECT cl.assigned_user_id, COUNT(co.opportunity_id)::int AS active_opportunities, COALESCE(SUM(co.value) FILTER (WHERE COALESCE(co.stage::text, '') NOT IN ('closed_lost')), 0)::float AS pipeline_value, COALESCE(SUM(co.value) FILTER (WHERE COALESCE(co.stage::text, '') IN ('closed_won', 'won')), 0)::float AS closed_won_value FROM crm_opportunities co INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id WHERE cl.tenant_id = $1 GROUP BY cl.assigned_user_id ), task_rollup AS ( SELECT cl.assigned_user_id, COUNT(ir.reminder_id) FILTER (WHERE COALESCE(ir.status, '') IN ('pending', 'open', 'scheduled', 'snoozed'))::int AS open_tasks, COUNT(ir.reminder_id) FILTER (WHERE COALESCE(ir.status, '') = 'done')::int AS done_tasks FROM intel_reminders ir LEFT JOIN crm_leads cl ON cl.lead_id = ir.lead_id WHERE COALESCE(ir.tenant_id, $1) = $1 GROUP BY cl.assigned_user_id ), activity_rollup AS ( SELECT cl.assigned_user_id, MAX(ii.happened_at) AS last_activity_at FROM intel_interactions ii LEFT JOIN crm_leads cl ON cl.lead_id = ii.lead_id WHERE COALESCE(ii.tenant_id, $1) = $1 GROUP BY cl.assigned_user_id ) SELECT t.id::text AS user_id, COALESCE(t.full_name, t.email, t.id::text) AS name, COALESCE(t.email, '') AS email, t.avatar_url, COALESCE(l.assigned_leads, 0)::int AS assigned_leads, COALESCE(tr.open_tasks, 0)::int AS open_tasks, COALESCE(tr.done_tasks, 0)::int AS done_tasks, COALESCE(o.active_opportunities, 0)::int AS active_opportunities, COALESCE(o.pipeline_value, 0)::float AS pipeline_value, COALESCE(o.closed_won_value, 0)::float AS closed_won_value, CASE WHEN COALESCE(l.assigned_leads, 0) = 0 THEN 0 ELSE ROUND((COALESCE(l.won_leads, 0)::numeric / NULLIF(l.assigned_leads, 0)) * 100, 1)::float END AS conversion_rate, a.last_activity_at FROM team t LEFT JOIN lead_rollup l ON l.assigned_user_id = t.id LEFT JOIN opportunity_rollup o ON o.assigned_user_id = t.id LEFT JOIN task_rollup tr ON tr.assigned_user_id = t.id LEFT JOIN activity_rollup a ON a.assigned_user_id = t.id WHERE COALESCE(l.assigned_leads, 0) > 0 OR COALESCE(o.active_opportunities, 0) > 0 OR COALESCE(tr.open_tasks, 0) > 0 ORDER BY COALESCE(o.closed_won_value, 0) DESC, COALESCE(o.pipeline_value, 0) DESC, COALESCE(l.assigned_leads, 0) DESC, name ASC LIMIT $2 """, tenant_id, safe_limit, ) performers = [ { "userId": row["user_id"], "name": row["name"], "email": row["email"], "avatarUrl": row["avatar_url"], "assignedLeads": row["assigned_leads"], "openTasks": row["open_tasks"], "doneTasks": row["done_tasks"], "activeOpportunities": row["active_opportunities"], "pipelineValue": row["pipeline_value"], "closedWonValue": row["closed_won_value"], "conversionRate": row["conversion_rate"], "lastActivityAt": row["last_activity_at"].isoformat() if row["last_activity_at"] else None, } for row in rows ] return _ok( { "summary": { "teamMembers": len(performers), "assignedLeads": sum(item["assignedLeads"] for item in performers), "openTasks": sum(item["openTasks"] for item in performers), "pipelineValue": sum(item["pipelineValue"] for item in performers), "closedWonValue": sum(item["closedWonValue"] for item in performers), }, "performers": performers, } ) @router.get("/mobile/lead-map", summary="Mobile Oracle lead geo-interest intelligence") async def mobile_lead_map( request: Request, limit: int = 24, user: UserPrincipal = Depends(get_current_user), ) -> dict: pool = getattr(request.app.state, "db_pool", None) if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") tenant_id = user.tenant_id or _DEFAULT_TENANT_ID safe_limit = max(1, min(limit, 100)) async with pool.acquire() as conn: has_rollup = await conn.fetchval("SELECT to_regclass('public.lead_geo_interest_rollup') IS NOT NULL") if has_rollup: rows = await conn.fetch( """ SELECT district AS label, district AS district, NULL::text AS city, lat::float AS latitude, lng::float AS longitude, x::float AS x, y::float AS y, COALESCE(lead_count, 0)::int AS lead_count, COALESCE(avg_qd_score, 0)::float AS avg_qd_score, 0::int AS hot_lead_count FROM lead_geo_interest_rollup WHERE tenant_id = $1 ORDER BY lead_count DESC, avg_qd_score DESC, district ASC LIMIT $2 """, tenant_id, safe_limit, ) else: rows = await conn.fetch( """ SELECT COALESCE(NULLIF(p.city, ''), 'Unknown') AS label, COALESCE(NULLIF(p.city, ''), 'Unknown') AS city, NULL::text AS district, NULL::float AS latitude, NULL::float AS longitude, ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC, COALESCE(NULLIF(p.city, ''), 'Unknown'))::float AS x, ROUND(AVG(COALESCE(q.current_value, 0.0))::numeric, 3)::float AS y, COUNT(DISTINCT p.person_id)::int AS lead_count, ROUND(AVG(COALESCE(q.current_value, 0.0))::numeric, 3)::float AS avg_qd_score, COUNT(DISTINCT p.person_id) FILTER (WHERE COALESCE(q.current_value, 0.0) >= 0.70)::int AS hot_lead_count FROM crm_people p LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = $1 LEFT JOIN LATERAL ( SELECT current_value FROM intel_qd_scores q WHERE q.person_id = p.person_id ORDER BY q.computed_at DESC LIMIT 1 ) q ON TRUE WHERE p.tenant_id = $1 GROUP BY COALESCE(NULLIF(p.city, ''), 'Unknown') ORDER BY lead_count DESC, avg_qd_score DESC, label ASC LIMIT $2 """, tenant_id, safe_limit, ) points = [ { "label": row["label"], "city": row["city"], "district": row["district"], "latitude": row["latitude"], "longitude": row["longitude"], "x": row["x"], "y": row["y"], "leadCount": row["lead_count"], "avgQdScore": row["avg_qd_score"], "hotLeadCount": row["hot_lead_count"], } for row in rows ] return _ok( { "summary": { "locations": len(points), "leadCount": sum(point["leadCount"] for point in points), "hotLeadCount": sum(point["hotLeadCount"] for point in points), }, "points": points, }, meta={"source": "lead_geo_interest_rollup" if has_rollup else "crm_people"}, ) @router.get("/me", summary="Get current user profile") async def get_me(request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: return _ok(await _get_current_user_profile(request, user)) @router.get("/canvas-pages", summary="List canvas pages for current user") async def list_canvas_pages( request: Request, search: str | None = None, limit: int = 50, user: UserPrincipal = Depends(get_current_user), ) -> dict: ctx = await _ctx_from_request(request, user) pages = await canvas_service.list_pages( tenant_id=ctx.tenant_id, owner_id=ctx.actor_id, search=search, limit=limit, ) return _ok(pages, meta={"count": len(pages)}) @router.post("/canvas-pages", summary="Create a new canvas page") async def create_canvas_page( payload: PageCreateRequest, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: ctx = await _ctx_from_request(request, user) page = await canvas_service.create_page( tenant_id=ctx.tenant_id, owner_id=ctx.actor_id, title=payload.title.strip() or "Untitled Canvas", ) return _ok(page) @router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID") async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) page = await canvas_service.get_page(page_id, ctx.tenant_id) if not page: raise HTTPException(status_code=404, detail=f"Canvas page '{page_id}' not found.") return _ok(page) @router.patch("/canvas-pages/{page_id}", summary="Rename a canvas page") async def rename_canvas_page( page_id: str, payload: PageUpdateRequest, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) try: page = await canvas_service.update_page_title( page_id=page_id, tenant_id=ctx.tenant_id, owner_id=ctx.actor_id, title=payload.title, ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _ok(page) @router.delete("/canvas-pages/{page_id}", summary="Delete a canvas page") async def delete_canvas_page( page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) try: await canvas_service.delete_page( page_id=page_id, tenant_id=ctx.tenant_id, owner_id=ctx.actor_id, ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _ok({"pageId": page_id, "deleted": True}) @router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components") async def submit_prompt( page_id: str, payload: PromptSubmitRequest, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) complexity_route, routing_reasons = _oracle_prompt_complexity(payload.prompt, payload.conversationContext) resolved_mode = payload.executionMode if payload.executionMode == "auto": resolved_mode = complexity_route if resolved_mode == "thinking": colony_result = await _dispatch_colony_from_oracle_prompt( request=request, ctx=ctx, page_id=page_id, payload=payload, resolved_mode=resolved_mode, routing_reasons=routing_reasons, ) execution = colony_result["execution"] page = colony_result["page"] action = await oracle_action_service.create_from_execution( execution=execution, target_entity_type="lead" if payload.targetLeadId else "canvas_page", target_entity_id=payload.targetLeadId or page_id, action_type="oracle_colony_mission_dispatch", writeback_payload={ **payload.plannedWriteback, "colonyMissionId": execution["workflowDispatch"]["missionId"], "executionMode": payload.executionMode, "resolvedMode": resolved_mode, }, ) return _ok({ "executionId": execution["executionId"], "actionId": action["actionId"], "status": execution["status"], "executionMode": payload.executionMode, "resolvedMode": resolved_mode, "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 [], "colonyMissionId": execution["workflowDispatch"]["missionId"], }) 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) action = await oracle_action_service.create_from_execution( execution=execution, target_entity_type="lead" if payload.targetLeadId else "canvas_page", target_entity_id=payload.targetLeadId or page_id, action_type="oracle_prompt_writeback_plan" if payload.targetLeadId else "oracle_canvas_generation", writeback_payload=payload.plannedWriteback, ) return _ok({ "executionId": execution["executionId"], "actionId": action["actionId"], "status": execution["status"], "executionMode": payload.executionMode, "resolvedMode": "fast", "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, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) page = await canvas_service.get_page(page_id, ctx.tenant_id) if not page: raise HTTPException(status_code=404, detail="Source page not found.") try: fork = await collaboration_service.create_fork( source_page=page, recipient_user_id=payload.recipientUserId, created_by=ctx.actor_id, visibility=payload.visibility, message=payload.message, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return _ok(fork) @router.post("/canvas-pages/{page_id}/rollback", summary="Rollback canvas to a prior revision") async def rollback_canvas( page_id: str, payload: RollbackRequest, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) result = await canvas_service.rollback( page_id=page_id, 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, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id) return _ok(revisions, meta={"count": len(revisions)}) @router.get("/component-templates", summary="List component templates") async def list_templates( category: str | None = None, status: str | None = None, search: str | None = None, limit: int = 50, offset: int = 0, ) -> dict: result = codebook_service.list_templates( category=category, status=status, search=search, limit=limit, offset=offset, ) return _ok(result["templates"], meta={"count": result["total"], "limit": limit, "offset": offset}) @router.post("/component-templates/synthesize", summary="Synthesize a new component template from a prompt") async def synthesize_template( payload: TemplateSynthesizeRequest, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: me = await _get_current_user_profile(request, user) template = codebook_service.synthesize_template( prompt=payload.prompt, data_shapes=payload.dataShape, ) template["tenantId"] = me["tenantId"] template.setdefault("createdAt", _now()) template.setdefault("updatedAt", _now()) return _ok(template) @router.get("/persona/health", summary="Health check for Oracle persona prompt loading") async def persona_health() -> dict: return _ok(await persona_service.health()) @router.post("/persona/render", summary="Render a subordinate Oracle persona prompt") async def persona_render(payload: PersonaRenderRequest) -> dict: try: rendered = await persona_service.render_prompt( prompt_name=payload.promptName, variables=payload.variables, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _ok(rendered) @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, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: ctx = await _ctx_from_request(request, user) source_page = await canvas_service.get_page(payload.sourcePageId, ctx.tenant_id) target_page = await canvas_service.get_page(payload.targetPageId, ctx.tenant_id) if not source_page or not target_page: 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, request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: ctx = await _ctx_from_request(request, user) mr = await collaboration_service.review_merge_request( mr_id=mr_id, decision=payload.decision, 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 ───────────────────────────────────────────────────