forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
1070 lines
41 KiB
Python
1070 lines
41 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, 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 ───────────────────────────────────────────────────
|