555 lines
20 KiB
Python
555 lines
20 KiB
Python
"""
|
|
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, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from backend.auth.dependencies import UserPrincipal, get_current_user
|
|
from .canvas_service import canvas_service
|
|
from .collaboration_service import collaboration_service
|
|
from .action_service import oracle_action_service
|
|
from .persona_service import persona_service
|
|
from .prompt_orchestrator import prompt_orchestrator
|
|
from .policy_service import PolicyService, PolicyContext
|
|
from .codebook_service import codebook_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
policy_svc = PolicyService()
|
|
_DEFAULT_TENANT_ID = os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity")
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
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"])
|
|
|
|
|
|
# ── 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")
|
|
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("/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)
|
|
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"],
|
|
"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 ───────────────────────────────────────────────────
|
|
|