Initial commit: Velocity-OS migration

This commit is contained in:
2026-05-01 12:32:19 +05:30
commit 407af828d4
283 changed files with 207782 additions and 0 deletions

View File

@@ -0,0 +1,529 @@
"""
routes_admin_surface.py
───────────────────────
Admin Control Plane API
Roles: Only 'admin' or 'superadmin' may access these endpoints.
Endpoints:
GET /admin-surface/health — system health overview
GET /admin-surface/queues — queue depth snapshot
GET /admin-surface/installs — surface session / install overview
POST /admin-surface/actions — submit an admin action
GET /admin-surface/actions — list admin action history
GET /admin-surface/actions/{id} — get a specific action
GET /admin-surface/logs — recent audit event log
GET /admin-surface/templates — template catalog summary (admin view)
POST /admin-surface/templates/{id}/publish — publish a template
POST /admin-surface/templates/{id}/archive — archive a template
GET /admin-surface/synthetic-jobs — list synthetic generation jobs
POST /admin-surface/synthetic-jobs/{id}/cancel — cancel a synthetic job
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.admin_surface")
router = APIRouter()
# ── RBAC guard ────────────────────────────────────────────────────────────────
ADMIN_ROLES = {"admin", "superadmin", "ADMIN", "SUPERADMIN"}
def require_admin(user=Depends(get_current_user)):
normalized_role = user.role.upper()
if normalized_role not in {"ADMIN", "SUPERADMIN"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required.",
)
return user
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
# ── Pydantic Models ───────────────────────────────────────────────────────────
VALID_ACTION_TYPES = {
"user_create", "user_deactivate", "user_role_change",
"tenant_config_update", "inventory_batch_approve", "inventory_batch_reject",
"template_publish", "template_archive",
"synthetic_job_trigger", "synthetic_job_cancel",
"system_health_check", "queue_drain", "debug_event_export",
"install_register", "install_deregister",
}
class AdminActionRequest(BaseModel):
action_type: str
target_type: str
target_id: str
payload: dict = Field(default_factory=dict)
idempotency_key: Optional[str] = None
# ── System Health ─────────────────────────────────────────────────────────────
@router.get("/health", summary="System health overview")
async def get_health(
request: Request,
admin=Depends(require_admin),
):
"""
Returns an aggregated health snapshot covering DB pool, queue depths,
and basic surface session counts.
"""
pool = _pool(request)
async with pool.acquire() as conn:
# DB round-trip latency
import time
t0 = time.monotonic()
await conn.fetchval("SELECT 1")
db_latency_ms = round((time.monotonic() - t0) * 1000, 2)
# Pending jobs
pending_transcriptions = await conn.fetchval(
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE status='pending'"
)
pending_synthetic_jobs = await conn.fetchval(
"SELECT COUNT(*) FROM oracle_synthetic_generation_jobs WHERE status IN ('pending','running')"
)
pending_admin_actions = await conn.fetchval(
"SELECT COUNT(*) FROM admin_action_events WHERE status='pending'"
)
pending_inventory_batches = await conn.fetchval(
"SELECT COUNT(*) FROM inventory_import_batches WHERE status IN ('pending','validating','processing')"
)
# Active surface sessions (last 30 min)
active_sessions = await conn.fetchval(
"SELECT COUNT(*) FROM surface_sessions WHERE last_active_at > NOW() - INTERVAL '30 minutes'"
)
# Surface breakdown
surface_breakdown = await conn.fetch(
"""
SELECT surface_type, COUNT(*) as count
FROM surface_sessions
WHERE last_active_at > NOW() - INTERVAL '30 minutes'
GROUP BY surface_type
"""
)
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
"database": {
"connected": True,
"latency_ms": db_latency_ms,
},
"queues": {
"pending_transcriptions": pending_transcriptions,
"pending_synthetic_jobs": pending_synthetic_jobs,
"pending_admin_actions": pending_admin_actions,
"pending_inventory_batches": pending_inventory_batches,
},
"active_sessions": {
"total": active_sessions,
"by_surface": {r["surface_type"]: r["count"] for r in surface_breakdown},
},
}
# ── Queue Visibility ──────────────────────────────────────────────────────────
@router.get("/queues", summary="Queue depth snapshot")
async def get_queues(
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
transcription_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM edge_transcription_jobs
GROUP BY status ORDER BY status
"""
)
synthetic_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM oracle_synthetic_generation_jobs
GROUP BY status ORDER BY status
"""
)
inventory_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM inventory_import_batches
GROUP BY status ORDER BY status
"""
)
admin_queue = await conn.fetch(
"""
SELECT status, COUNT(*) as count
FROM admin_action_events
GROUP BY status ORDER BY status
"""
)
return {
"transcription_jobs": {r["status"]: r["count"] for r in transcription_queue},
"synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue},
"inventory_batches": {r["status"]: r["count"] for r in inventory_queue},
"admin_actions": {r["status"]: r["count"] for r in admin_queue},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# ── Install / Surface Overview ────────────────────────────────────────────────
@router.get("/installs", summary="Surface session and install overview")
async def get_installs(
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT surface_type, app_version, COUNT(*) as session_count,
MAX(last_active_at) as last_seen
FROM surface_sessions
GROUP BY surface_type, app_version
ORDER BY surface_type, app_version
"""
)
return {
"installs": [dict(r) for r in rows],
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# ── Admin Actions ─────────────────────────────────────────────────────────────
@router.post("/actions", status_code=status.HTTP_201_CREATED, summary="Submit an admin action")
async def submit_action(
request: Request,
body: AdminActionRequest,
admin=Depends(require_admin),
):
"""
Submit a bounded admin action. All actions are persisted with full audit trail.
Supported action_types are enumerated in VALID_ACTION_TYPES.
Actions are not auto-executed — they transition to 'pending' and must be
processed by the appropriate backend job or confirmed by a second admin.
(This prevents destructive mass-actions from running unreviewed.)
"""
if body.action_type not in VALID_ACTION_TYPES:
raise HTTPException(400, f"Invalid action_type. Valid: {sorted(VALID_ACTION_TYPES)}")
action_id = body.idempotency_key or str(uuid.uuid4())
pool = _pool(request)
async with pool.acquire() as conn:
try:
row = await conn.fetchrow(
"""
INSERT INTO admin_action_events (
tenant_id, action_id, action_type, target_type, target_id,
requested_by, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)
RETURNING action_event_id, status, created_at
""",
admin.role, action_id, body.action_type, body.target_type,
body.target_id, admin.user_id, json.dumps(body.payload),
)
except Exception as exc:
if "unique" in str(exc).lower():
raise HTTPException(409, "Action with this idempotency key already exists")
raise
logger.info(
"Admin action submitted: %s by %s%s/%s",
body.action_type, admin.user_id, body.target_type, body.target_id,
)
return {
"action_event_id": str(row["action_event_id"]),
"action_id": action_id,
"status": row["status"],
"created_at": str(row["created_at"]),
}
@router.get("/actions", summary="List admin action history")
async def list_actions(
request: Request,
action_type: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
where = "WHERE tenant_id = $1"
params: list[Any] = [admin.role]
idx = 2
if action_type:
where += f" AND action_type = ${idx}"; params.append(action_type); idx += 1
if status_filter:
where += f" AND status = ${idx}"; params.append(status_filter); idx += 1
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT action_event_id, action_id, action_type, target_type, target_id,
requested_by, status, result_message, executed_at, created_at
FROM admin_action_events
{where}
ORDER BY created_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM admin_action_events {where}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "actions": [dict(r) for r in rows]}
@router.get("/actions/{action_event_id}", summary="Get a specific admin action")
async def get_action(
action_event_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM admin_action_events WHERE action_event_id=$1 AND tenant_id=$2",
action_event_id, admin.role,
)
if not row:
raise HTTPException(404, "Admin action not found")
return dict(row)
# ── Audit Log ─────────────────────────────────────────────────────────────────
@router.get("/logs", summary="Recent Oracle audit events")
async def get_audit_logs(
request: Request,
entity_type: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
if entity_type:
rows = await conn.fetch(
"""
SELECT audit_event_id, entity_type, entity_id, action, actor_id,
actor_type, correlation_id, details, created_at
FROM oracle_audit_events
WHERE tenant_id=$1 AND entity_type=$2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
""",
admin.role, entity_type, limit, offset,
)
else:
rows = await conn.fetch(
"""
SELECT audit_event_id, entity_type, entity_id, action, actor_id,
actor_type, correlation_id, details, created_at
FROM oracle_audit_events
WHERE tenant_id=$1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
admin.role, limit, offset,
)
return {"logs": [dict(r) for r in rows]}
# ── Template Administration ───────────────────────────────────────────────────
@router.get("/templates", summary="Template catalog admin view")
async def get_templates_admin(
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
where = "WHERE tenant_id = $1"
params: list[Any] = [admin.role]
idx = 2
if status_filter:
where += f" AND status = ${idx}"; params.append(status_filter); idx += 1
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.template_id, t.name, t.category, t.status, t.origin,
t.version, t.use_count, t.chapter_id, t.subchapter_id,
ch.name as chapter_name, sub.name as subchapter_name,
t.created_at, t.updated_at
FROM oracle_component_templates t
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
{where}
ORDER BY t.updated_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM oracle_component_templates {where}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]}
@router.post("/templates/{template_id}/publish", summary="Publish a template")
async def publish_template(
template_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE oracle_component_templates
SET status='catalog_active', updated_at=NOW()
WHERE template_id=$1 AND tenant_id=$2
""",
template_id, admin.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Template not found")
logger.info("Template %s published by admin %s", template_id, admin.user_id)
return {"status": "published"}
@router.post("/templates/{template_id}/archive", summary="Archive a template")
async def archive_template(
template_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE oracle_component_templates
SET status='archived', updated_at=NOW()
WHERE template_id=$1 AND tenant_id=$2
""",
template_id, admin.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Template not found")
logger.info("Template %s archived by admin %s", template_id, admin.user_id)
return {"status": "archived"}
# ── Template Chapter Admin ────────────────────────────────────────────────────
@router.get("/template-chapters", summary="List template chapters (admin view)")
async def list_chapters_admin(
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active,
COUNT(sub.subchapter_id) as subchapter_count
FROM oracle_template_chapters ch
LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id
WHERE ch.tenant_id=$1
GROUP BY ch.chapter_id
ORDER BY ch.sort_order ASC
""",
admin.role,
)
return {"chapters": [dict(r) for r in rows]}
# ── Synthetic Jobs Admin ──────────────────────────────────────────────────────
@router.get("/synthetic-jobs", summary="List synthetic generation jobs")
async def list_synthetic_jobs(
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
if status_filter:
rows = await conn.fetch(
"""
SELECT job_id, template_id, model, status, requested_count,
accepted_count, created_by, started_at, completed_at, created_at
FROM oracle_synthetic_generation_jobs
WHERE tenant_id=$1 AND status=$2
ORDER BY created_at DESC LIMIT $3 OFFSET $4
""",
admin.role, status_filter, limit, offset,
)
else:
rows = await conn.fetch(
"""
SELECT job_id, template_id, model, status, requested_count,
accepted_count, created_by, started_at, completed_at, created_at
FROM oracle_synthetic_generation_jobs
WHERE tenant_id=$1
ORDER BY created_at DESC LIMIT $2 OFFSET $3
""",
admin.role, limit, offset,
)
return {"jobs": [dict(r) for r in rows]}
@router.post("/synthetic-jobs/{job_id}/cancel", summary="Cancel a synthetic generation job")
async def cancel_synthetic_job(
job_id: str,
request: Request,
admin=Depends(require_admin),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE oracle_synthetic_generation_jobs
SET status='cancelled', updated_at=NOW()
WHERE job_id=$1 AND tenant_id=$2 AND status IN ('pending','running')
""",
job_id, admin.role,
)
if result == "UPDATE 0":
raise HTTPException(404, "Job not found or already in terminal state")
return {"status": "cancelled"}

View File

@@ -0,0 +1,512 @@
"""
routes_catalyst.py
Meta Marketing API wrappers for The Catalyst module.
Routes:
POST /api/catalyst/campaigns/create — Bulk campaign creation
POST /api/catalyst/creative/sync — Upload ComfyUI assets to Meta
GET /api/catalyst/insights/realtime — Poll Ads Insights API
POST /api/catalyst/audiences/lookalike — Push CRM leads → Meta Custom Audience
POST /api/catalyst/auth/meta — OAuth token acquisition
"""
import os
import uuid
import hashlib
import logging
from typing import Any
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.services.ad_network_service import (
AdInsight,
BidStrategyUpdate,
BudgetUpdate,
Platform,
ad_network_service,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_sdk() -> tuple[Any, str]:
"""
Initialise the facebook-business SDK lazily.
Returns (FacebookAdsApi instance, ad_account_id).
Raises HTTPException 503 if credentials are missing or SDK init fails.
"""
try:
from facebook_business.api import FacebookAdsApi # type: ignore
access_token = os.getenv("META_ACCESS_TOKEN", "")
app_id = os.getenv("META_APP_ID", "")
app_secret = os.getenv("META_APP_SECRET", "")
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
if not access_token or access_token.startswith("PLACEHOLDER"):
raise ValueError("META_ACCESS_TOKEN is not configured.")
if not account_id or account_id.startswith("PLACEHOLDER"):
raise ValueError("META_AD_ACCOUNT_ID is not configured.")
FacebookAdsApi.init(app_id, app_secret, access_token)
return FacebookAdsApi.get_default_api(), account_id
except ImportError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="facebook-business SDK not installed. Run: pip install facebook-business",
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
def _get_supabase():
"""Initialise the Supabase client lazily."""
try:
from supabase import create_client # type: ignore
url = os.getenv("SUPABASE_URL", "")
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
if not url or url.startswith("PLACEHOLDER"):
raise ValueError("SUPABASE_URL is not configured.")
return create_client(url, key)
except ImportError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="supabase SDK not installed. Run: pip install supabase",
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
def _ok(data: Any, meta: dict | None = None) -> dict:
return {"status": "ok", "data": data, "meta": meta or {}}
def _sha256_hash(value: str) -> str:
"""SHA-256 hash an email for Meta's hashed audience upload."""
return hashlib.sha256(value.strip().lower().encode()).hexdigest()
# ── Request / Response Models ─────────────────────────────────────────────────
class CampaignCreateRequest(BaseModel):
name: str = Field(..., description="Campaign display name")
platform: Platform = Field(default=Platform.META, description="Target ad network platform")
objective: str = Field("OUTCOME_LEADS", description="Meta campaign objective enum")
budget_daily: int = Field(..., gt=0, description="Daily budget in cents (AED × 100)")
status: str = Field("PAUSED", description="Initial campaign status — start PAUSED for review")
special_ad_categories: list[str] = Field(default_factory=list)
class CampaignCreateResponse(BaseModel):
campaign_id: str
name: str
status: str
created_at: str
class CreativeSyncRequest(BaseModel):
asset_url: str = Field(..., description="Public URL of the ComfyUI-rendered image or video")
asset_name: str = Field(..., description="Human-readable asset name")
asset_type: str = Field(..., description="'image' or 'video'")
ad_account_id: str | None = Field(None, description="Override ad account ID (optional)")
class LookalikeAudienceRequest(BaseModel):
country: str = Field("AE", description="ISO 3166-1 alpha-2 country code for lookalike")
ratio: float = Field(0.01, ge=0.01, le=0.20, description="Lookalike ratio (1%20%)")
crm_filter_status: str = Field("Closed/Won", description="Supabase lead status to filter on")
class MetaAuthRequest(BaseModel):
short_lived_token: str = Field(..., description="Short-lived user access token from Meta OAuth")
@router.get("/campaigns", summary="List unified campaign summaries for the Catalyst marketing tab")
async def list_campaigns(platform: Platform | None = Query(default=None)) -> dict:
campaigns = await ad_network_service.list_campaigns(platform=platform)
insights = await ad_network_service.get_insights(platform=platform, days=7)
rollup: dict[str, dict[str, float]] = {}
for insight in insights:
insight_campaign_id = insight.campaign_id if isinstance(insight, AdInsight) else insight.get("campaign_id")
if not insight_campaign_id:
continue
spent = insight.spend if isinstance(insight, AdInsight) else float(insight.get("spend", 0))
impressions = insight.impressions if isinstance(insight, AdInsight) else int(insight.get("impressions", 0))
clicks = insight.clicks if isinstance(insight, AdInsight) else int(insight.get("clicks", 0))
conversions = insight.conversions if isinstance(insight, AdInsight) else int(insight.get("conversions", 0))
slot = rollup.setdefault(
insight_campaign_id,
{
"spent": 0.0,
"impressions": 0.0,
"clicks": 0.0,
"conversions": 0.0,
},
)
slot["spent"] += spent
slot["impressions"] += impressions
slot["clicks"] += clicks
slot["conversions"] += conversions
data = [
{
"id": campaign.id,
"name": campaign.name,
"platform": campaign.platform.value,
"status": campaign.status.value,
"budget": campaign.daily_budget,
"spent": round(rollup.get(campaign.id, {}).get("spent", campaign.spent), 2),
"impressions": int(rollup.get(campaign.id, {}).get("impressions", 0)),
"clicks": int(rollup.get(campaign.id, {}).get("clicks", 0)),
"conversions": int(rollup.get(campaign.id, {}).get("conversions", 0)),
"objective": campaign.objective,
"bid_strategy": campaign.bid_strategy,
}
for campaign in campaigns
]
source = "ad_network_service_live" if platform else "ad_network_service_unified"
return _ok(data, meta={"count": len(data), "source": source})
# ── 1. POST /campaigns/create ─────────────────────────────────────────────────
@router.post("/campaigns/create", summary="Create Meta or Google marketing campaigns")
async def create_campaigns(
request: Request,
payload: CampaignCreateRequest,
) -> dict:
"""
Triggers `facebook_business.adobjects.campaign.Campaign` to create a campaign
under the configured Ad Account.
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
"""
if payload.platform == Platform.GOOGLE:
campaign_id = f"google-camp-{uuid.uuid4().hex[:8]}"
if hasattr(request.app.state, "broadcast_live_event"):
await request.app.state.broadcast_live_event(
"create",
f"Created Google Ads campaign '{payload.name}'.",
payload.name,
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
)
return _ok(
CampaignCreateResponse(
campaign_id=campaign_id,
name=payload.name,
status=payload.status,
created_at=datetime.utcnow().isoformat(),
).model_dump(),
meta={"platform": "google", "mode": "simulated_or_provider_managed"},
)
_api, account_id = _get_sdk()
try:
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
from facebook_business.adobjects.campaign import Campaign # type: ignore
account = AdAccount(account_id)
params = {
Campaign.Field.name: payload.name,
Campaign.Field.objective: payload.objective,
Campaign.Field.status: payload.status,
Campaign.Field.daily_budget: payload.budget_daily,
Campaign.Field.special_ad_categories: payload.special_ad_categories,
}
campaign = account.create_campaign(params=params)
# Broadcast live event via WebSocket
if hasattr(request.app.state, "broadcast_live_event"):
await request.app.state.broadcast_live_event(
"create",
f"Created campaign '{payload.name}' (objective: {payload.objective}).",
payload.name,
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
)
return _ok(
CampaignCreateResponse(
campaign_id=campaign["id"],
name=payload.name,
status=payload.status,
created_at=datetime.utcnow().isoformat(),
).model_dump(),
meta={"account_id": account_id},
)
except Exception as exc:
logger.error("Campaign creation failed: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
# ── 2. POST /creative/sync ────────────────────────────────────────────────────
@router.post("/creative/sync", summary="Upload ComfyUI asset to Meta Ad Library")
async def sync_creative(
request: Request,
payload: CreativeSyncRequest,
) -> dict:
"""
Uploads an image or video URL (from ComfyUI / Wan 2.2 / Qwen-Image 2512) to
the Meta Ad Library (Creative Hub) and returns the Meta Asset ID.
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
"""
_api, account_id = _get_sdk()
account_id = payload.ad_account_id or account_id
try:
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
from facebook_business.adobjects.advideo import AdVideo # type: ignore
from facebook_business.adobjects.adimage import AdImage # type: ignore
account = AdAccount(account_id)
if payload.asset_type == "video":
# Video upload via file_url
result = account.create_ad_video(params={
AdVideo.Field.name: payload.asset_name,
AdVideo.Field.file_url: payload.asset_url,
})
meta_asset_id = result["id"]
else:
# Image upload via url
result = account.create_ad_image(params={
"filename": payload.asset_name,
"url": payload.asset_url,
})
# AdImage returns a hash dict — extract hash key
meta_asset_id = list(result["images"].values())[0]["hash"] \
if "images" in result else result.get("id", "unknown")
return _ok({
"meta_asset_id": meta_asset_id,
"asset_name": payload.asset_name,
"asset_type": payload.asset_type,
"uploaded_at": datetime.utcnow().isoformat(),
})
except Exception as exc:
logger.error("Creative sync failed: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
# ── 3. GET /insights/realtime ─────────────────────────────────────────────────
@router.get("/insights/realtime", summary="Poll unified Meta and Google Ads insights")
async def get_realtime_insights(
campaign_id: str | None = None,
platform: Platform | None = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
) -> dict:
try:
insights = await ad_network_service.get_insights(campaign_id=campaign_id, platform=platform, days=days)
except Exception as exc:
logger.error("Insights fetch failed: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
data = [item.model_dump() if isinstance(item, AdInsight) else item for item in insights]
return _ok(data, meta={"count": len(data), "days": days, "platform": platform.value if platform else "all"})
@router.put("/budget", summary="Update Meta or Google Ads budget and campaign status")
async def update_campaign_budget(request: Request, payload: BudgetUpdate) -> dict:
try:
result = await ad_network_service.update_budget(payload)
if hasattr(request.app.state, "broadcast_live_event"):
await request.app.state.broadcast_live_event(
"budget_update",
f"Updated {payload.platform.value} budget for {payload.campaign_id}.",
payload.campaign_id,
f"daily={payload.daily_budget} lifetime={payload.lifetime_budget}",
)
return _ok(result)
except Exception as exc:
logger.error("Budget update failed: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
@router.put("/bid-strategy", summary="Apply Meta or Google Ads bid strategy changes")
async def update_bid_strategy(request: Request, payload: BidStrategyUpdate) -> dict:
try:
action = await ad_network_service.update_bid_strategy(payload)
if hasattr(request.app.state, "broadcast_live_event"):
await request.app.state.broadcast_live_event(
"bid_strategy_update",
f"Updated {payload.platform.value} bid strategy for {payload.campaign_id}.",
payload.campaign_id,
payload.strategy,
)
return _ok(action.model_dump())
except Exception as exc:
logger.error("Bid strategy update failed: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
# ── 4. POST /audiences/lookalike ──────────────────────────────────────────────
@router.post("/audiences/lookalike", summary="Push Supabase CRM leads → Meta Lookalike Audience")
async def create_lookalike_audience(
request: Request,
payload: LookalikeAudienceRequest,
) -> dict:
"""
1. Queries the Supabase `leads` table for rows matching `status = payload.crm_filter_status`.
2. SHA-256 hashes their email addresses.
3. Creates (or updates) a Meta Custom Audience with the hashed emails.
4. Creates a Lookalike Audience from that Custom Audience.
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
"""
_api, account_id = _get_sdk()
supabase = _get_supabase()
# ── Step 1: Fetch qualified leads from Supabase CRM ──
try:
response = supabase.table("leads") \
.select("id, email, name") \
.eq("status", payload.crm_filter_status) \
.execute()
leads = response.data or []
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Supabase query failed: {exc}")
if not leads:
return _ok({"message": f"No leads found with status '{payload.crm_filter_status}'."})
# ── Step 2: Hash emails ──
hashed_emails = [
_sha256_hash(lead["email"])
for lead in leads
if lead.get("email")
]
if not hashed_emails:
raise HTTPException(status_code=422, detail="No valid email addresses found in the filtered leads.")
# ── Step 3: Create / update Meta Custom Audience ──
try:
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
from facebook_business.adobjects.customaudience import CustomAudience # type: ignore
account = AdAccount(account_id)
audience_name = f"Velocity CRM — {payload.crm_filter_status} Leads"
# Create custom audience
custom_audience = account.create_custom_audience(params={
CustomAudience.Field.name: audience_name,
CustomAudience.Field.subtype: "CUSTOM",
CustomAudience.Field.description: f"Auto-generated from Velocity CRM — {len(hashed_emails)} leads",
"customer_file_source": "USER_PROVIDED_ONLY",
})
audience_id = custom_audience["id"]
# Add users via hashed emails
custom_audience.create_users_replace(params={
"payload": {
"schema": ["EMAIL_SHA256"],
"data": [[h] for h in hashed_emails],
}
})
# ── Step 4: Create Lookalike Audience ──
lookalike = account.create_lookalike_audience(params={
"name": f"Velocity Lookalike — {payload.crm_filter_status} ({int(payload.ratio * 100)}%)",
"origin_audience_id": audience_id,
"lookalike_spec": {
"type": "similarity",
"ratio": payload.ratio,
"country": payload.country,
},
})
# Broadcast live event
if hasattr(request.app.state, "broadcast_live_event"):
await request.app.state.broadcast_live_event(
"create",
f"Created Lookalike Audience from {len(hashed_emails)} CRM Closed/Won leads.",
None,
f"+{len(hashed_emails):,} leads",
)
return _ok({
"custom_audience_id": audience_id,
"lookalike_audience_id": lookalike["id"],
"leads_processed": len(hashed_emails),
"country": payload.country,
"ratio": payload.ratio,
})
except Exception as exc:
logger.error("Audience creation failed: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
# ── 5. POST /auth/meta ────────────────────────────────────────────────────────
@router.post("/auth/meta", summary="Exchange short-lived token for System User token")
async def meta_oauth(payload: MetaAuthRequest) -> dict:
"""
Exchanges a short-lived Meta user token for a long-lived token using the
`/oauth/access_token` endpoint, then stores it in Supabase for persistence.
Requires: META_APP_ID, META_APP_SECRET
"""
import httpx
app_id = os.getenv("META_APP_ID", "")
app_secret = os.getenv("META_APP_SECRET", "")
api_ver = os.getenv("META_API_VERSION", "v21.0")
if app_id.startswith("PLACEHOLDER") or app_secret.startswith("PLACEHOLDER"):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="META_APP_ID or META_APP_SECRET not configured.",
)
url = f"https://graph.facebook.com/{api_ver}/oauth/access_token"
params = {
"grant_type": "fb_exchange_token",
"client_id": app_id,
"client_secret": app_secret,
"fb_exchange_token": payload.short_lived_token,
}
async with httpx.AsyncClient() as client:
resp = await client.get(url, params=params, timeout=15.0)
if resp.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Meta OAuth error: {resp.text}",
)
token_data = resp.json()
long_lived_token = token_data.get("access_token")
if not long_lived_token:
raise HTTPException(status_code=502, detail="No access_token in Meta response.")
# Persist to Supabase (best-effort — don't block on failure)
try:
supabase = _get_supabase()
supabase.table("catalyst_settings").upsert({
"key": "META_ACCESS_TOKEN",
"value": long_lived_token,
"updated_at": datetime.utcnow().isoformat(),
}).execute()
except Exception as exc:
logger.warning("Could not persist Meta token to Supabase: %s", exc)
return _ok({
"access_token": long_lived_token,
"token_type": token_data.get("token_type", "bearer"),
"expires_in": token_data.get("expires_in"),
})

View File

@@ -0,0 +1,588 @@
"""
Velocity Conversations API.
Native WhatsApp-first communications surface for Velocity WebOS. The routes are
provider-abstracted and CRM-aware, while remaining safe to run in mock mode.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.services.comms_evolution_provider import EvolutionProvider
from backend.services.comms_ingest import ingest_inbound_message
from backend.services.comms_provider import MockProvider
from backend.services.comms_waha_provider import WahaProvider
router = APIRouter()
_SCHEMA_READY = False
class SendMessageBody(BaseModel):
messageType: str = "text"
body: str
mediaUrl: str | None = None
templateName: str | None = None
templateLanguage: str | None = None
class LinkPersonBody(BaseModel):
personId: str
class NoteBody(BaseModel):
content: str
class TaskBody(BaseModel):
title: str
dueAt: str | None = None
class SettingsPatch(BaseModel):
provider: str | None = None
providerBaseUrl: str | None = None
providerApiKey: str | None = None
instanceId: str | None = None
phoneNumberId: str | None = None
webhookCallbackUrl: str | None = None
webhookSecret: str | None = None
defaultAssignmentUserId: str | None = None
autoLinkByPhone: bool | None = None
createCrmInteractionOnInbound: bool | None = None
defaultCountryCode: str | None = None
transcriptionProvider: str | None = None
class TranscribeBody(BaseModel):
callId: str | None = None
recordingUrl: str | None = None
def _get_provider():
return _provider_from_config({})
def _provider_from_config(config: dict[str, Any], provider_override: str | None = None):
provider = (provider_override or config.get("provider") or os.getenv("COMMS_PROVIDER", "mock")).strip().lower()
base_url = (config.get("provider_base_url") or os.getenv("COMMS_PROVIDER_BASE_URL", "")).strip()
api_key = (config.get("provider_api_key") or os.getenv("COMMS_PROVIDER_API_KEY", "")).strip()
instance_id = (config.get("instance_id") or os.getenv("COMMS_INSTANCE_ID", "")).strip() or None
if provider == "waha":
return WahaProvider(base_url, api_key, instance_id)
if provider == "evolution":
return EvolutionProvider(base_url, api_key, instance_id)
return MockProvider("", "", "mock")
async def _load_config(pool) -> dict[str, Any]:
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
return _json_obj(row["value_json"]) if row else {}
async def _get_provider_for_pool(pool, provider_override: str | None = None):
return _provider_from_config(await _load_config(pool), provider_override)
def _camel_settings(config: dict[str, Any], updated_at: datetime | None = None) -> dict[str, Any]:
return {
"provider": config.get("provider", os.getenv("COMMS_PROVIDER", "mock")),
"providerBaseUrl": config.get("provider_base_url", os.getenv("COMMS_PROVIDER_BASE_URL", "")),
"providerApiKey": config.get("provider_api_key", ""),
"instanceId": config.get("instance_id", os.getenv("COMMS_INSTANCE_ID", "")),
"phoneNumberId": config.get("phone_number_id", ""),
"webhookCallbackUrl": config.get("webhook_callback_url", "/api/comms/webhooks/{provider}"),
"webhookSecretSet": bool(config.get("webhook_secret_hash") or config.get("webhook_secret_set")),
"defaultAssignmentUserId": config.get("default_assignment_user_id"),
"autoLinkByPhone": bool(config.get("auto_link_by_phone", True)),
"createCrmInteractionOnInbound": bool(config.get("create_crm_interaction_on_inbound", True)),
"defaultCountryCode": str(config.get("default_country_code", os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91"))),
"mediaStorageDir": config.get("media_storage_dir", os.getenv("COMMS_MEDIA_STORAGE_DIR", "/opt/dlami/nvme/assets/comms")),
"transcriptionProvider": config.get("transcription_provider", os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none")),
**({"updatedAt": updated_at.isoformat()} if updated_at else {}),
}
def _snake_settings(body: SettingsPatch) -> dict[str, Any]:
mapping = {
"provider": "provider",
"providerBaseUrl": "provider_base_url",
"providerApiKey": "provider_api_key",
"instanceId": "instance_id",
"phoneNumberId": "phone_number_id",
"webhookCallbackUrl": "webhook_callback_url",
"defaultAssignmentUserId": "default_assignment_user_id",
"autoLinkByPhone": "auto_link_by_phone",
"createCrmInteractionOnInbound": "create_crm_interaction_on_inbound",
"defaultCountryCode": "default_country_code",
"transcriptionProvider": "transcription_provider",
}
raw = body.model_dump(exclude_unset=True)
updates: dict[str, Any] = {}
for src, dst in mapping.items():
if src in raw:
updates[dst] = raw[src]
if body.webhookSecret is not None:
updates["webhook_secret_hash"] = hashlib.sha256(body.webhookSecret.encode()).hexdigest() if body.webhookSecret else ""
updates["webhook_secret_set"] = bool(body.webhookSecret)
return updates
def _json_obj(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _record_value(row: Any, key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
async def _ensure_schema(pool) -> None:
global _SCHEMA_READY
if _SCHEMA_READY:
return
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS comms_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL DEFAULT 'mock',
external_thread_id TEXT,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT,
channel TEXT NOT NULL DEFAULT 'whatsapp',
status TEXT NOT NULL DEFAULT 'open',
assigned_user_id UUID NULL,
last_message_at TIMESTAMPTZ,
unread_count INT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone_provider ON comms_threads(provider, phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_threads_person ON comms_threads(person_id) WHERE person_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_comms_threads_status ON comms_threads(status);
CREATE INDEX IF NOT EXISTS idx_comms_threads_last_message ON comms_threads(last_message_at DESC NULLS LAST);
CREATE TABLE IF NOT EXISTS comms_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES comms_threads(thread_id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'mock',
external_message_id TEXT,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound', 'system')),
message_type TEXT NOT NULL DEFAULT 'text',
body TEXT NOT NULL DEFAULT '',
media_url TEXT,
media_mime_type TEXT,
delivery_status TEXT NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_messages_thread ON comms_messages(thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comms_messages_external ON comms_messages(external_message_id) WHERE external_message_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS comms_call_logs (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'mock',
external_call_id TEXT,
phone_e164 TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
status TEXT NOT NULL DEFAULT 'completed',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
duration_seconds INT,
recording_url TEXT,
transcript_id UUID,
transcript_text TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_phone ON comms_call_logs(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_thread ON comms_call_logs(thread_id) WHERE thread_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS comms_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO comms_settings (key, value_json)
VALUES ('config', '{"provider":"mock","auto_link_by_phone":true,"create_crm_interaction_on_inbound":true,"default_country_code":"91","transcription_provider":"none"}'::jsonb)
ON CONFLICT (key) DO NOTHING;
"""
)
_SCHEMA_READY = True
async def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable")
await _ensure_schema(pool)
return pool
def _row_thread(row) -> dict[str, Any]:
return {
"threadId": str(row["thread_id"]),
"provider": row["provider"],
"externalThreadId": row["external_thread_id"],
"personId": str(row["person_id"]) if row["person_id"] else None,
"phoneE164": row["phone_e164"],
"displayName": row["display_name"],
"channel": row["channel"],
"status": row["status"],
"assignedUserId": str(row["assigned_user_id"]) if row["assigned_user_id"] else None,
"lastMessageAt": row["last_message_at"].isoformat() if row["last_message_at"] else None,
"unreadCount": row["unread_count"],
"metadataJson": _json_obj(row["metadata_json"]),
"createdAt": row["created_at"].isoformat(),
"updatedAt": row["updated_at"].isoformat(),
"lastMessagePreview": _record_value(row, "last_message_preview"),
"crmPerson": {
"id": str(row["person_id"]),
"fullName": row["crm_full_name"],
"primaryPhone": row["crm_primary_phone"],
"primaryEmail": row["crm_primary_email"],
"buyerType": row["crm_buyer_type"],
"leadStatus": row["crm_lead_status"],
"projectName": row["crm_project_name"],
} if row["person_id"] else None,
}
@router.get("/threads")
async def list_threads(
request: Request,
status: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
conditions = ["1=1"]
values: list[Any] = []
if status:
values.append(status)
conditions.append(f"t.status = ${len(values)}")
if search:
values.append(f"%{search}%")
conditions.append(f"(t.phone_e164 ILIKE ${len(values)} OR t.display_name ILIKE ${len(values)} OR p.full_name ILIKE ${len(values)} OR p.primary_email ILIKE ${len(values)})")
where_clause = " AND ".join(conditions)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.*,
p.full_name AS crm_full_name,
p.primary_email AS crm_primary_email,
p.primary_phone AS crm_primary_phone,
p.buyer_type AS crm_buyer_type,
COALESCE(l.status, '') AS crm_lead_status,
(
SELECT pi.project_name FROM crm_property_interests pi
WHERE pi.person_id = p.person_id
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
LIMIT 1
) AS crm_project_name,
(
SELECT m.body FROM comms_messages m
WHERE m.thread_id = t.thread_id
ORDER BY m.created_at DESC
LIMIT 1
) AS last_message_preview
FROM comms_threads t
LEFT JOIN crm_people p ON t.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT status FROM crm_leads l
WHERE l.person_id = p.person_id
ORDER BY l.updated_at DESC NULLS LAST
LIMIT 1
) l ON TRUE
WHERE {where_clause}
ORDER BY t.last_message_at DESC NULLS LAST, t.updated_at DESC
LIMIT ${len(values)+1} OFFSET ${len(values)+2}
""",
*values,
limit,
offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM comms_threads t LEFT JOIN crm_people p ON t.person_id = p.person_id WHERE {where_clause}",
*values,
)
unread = await conn.fetchval("SELECT COALESCE(SUM(unread_count),0)::int FROM comms_threads WHERE status = 'open'")
return {"threads": [_row_thread(row) for row in rows], "total": total or 0, "unreadTotal": unread or 0}
@router.get("/threads/{thread_id}")
async def get_thread(thread_id: str, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT t.*, p.full_name AS crm_full_name, p.primary_email AS crm_primary_email,
p.primary_phone AS crm_primary_phone, p.buyer_type AS crm_buyer_type,
COALESCE(l.status, '') AS crm_lead_status,
(
SELECT pi.project_name FROM crm_property_interests pi
WHERE pi.person_id = p.person_id
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
LIMIT 1
) AS crm_project_name,
NULL::text AS last_message_preview
FROM comms_threads t
LEFT JOIN crm_people p ON t.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT status FROM crm_leads l
WHERE l.person_id = p.person_id
ORDER BY l.updated_at DESC NULLS LAST
LIMIT 1
) l ON TRUE
WHERE t.thread_id = $1::uuid
""",
thread_id,
)
if not row:
raise HTTPException(status_code=404, detail="Thread not found")
return _row_thread(row)
@router.get("/threads/{thread_id}/messages")
async def list_messages(
thread_id: str,
request: Request,
limit: int = 100,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 200))
offset = max(0, offset)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM comms_messages
WHERE thread_id = $1::uuid
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
thread_id,
limit,
offset,
)
messages = [
{
"messageId": str(row["message_id"]),
"threadId": str(row["thread_id"]),
"provider": row["provider"],
"externalMessageId": row["external_message_id"],
"direction": row["direction"],
"messageType": row["message_type"],
"body": row["body"],
"mediaUrl": row["media_url"],
"mediaMimeType": row["media_mime_type"],
"deliveryStatus": row["delivery_status"],
"sentAt": row["sent_at"].isoformat() if row["sent_at"] else None,
"deliveredAt": row["delivered_at"].isoformat() if row["delivered_at"] else None,
"readAt": row["read_at"].isoformat() if row["read_at"] else None,
"rawPayload": _json_obj(row["raw_payload"]),
"createdAt": row["created_at"].isoformat(),
}
for row in reversed(rows)
]
return {"messages": messages, "thread": await get_thread(thread_id, request)}
@router.post("/threads/{thread_id}/messages")
async def send_message(
thread_id: str,
body: SendMessageBody,
request: Request,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
async with pool.acquire() as conn:
thread = await conn.fetchrow("SELECT * FROM comms_threads WHERE thread_id = $1::uuid", thread_id)
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
provider = await _get_provider_for_pool(pool)
result = await provider.send_message(
phone=thread["phone_e164"],
message=body.body,
message_type=body.messageType,
media_url=body.mediaUrl,
)
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages
(thread_id, provider, external_message_id, direction, message_type, body, media_url, delivery_status, sent_at)
VALUES ($1::uuid, $2, $3, 'outbound', $4, $5, $6, 'sent', NOW())
RETURNING message_id
""",
thread_id,
os.getenv("COMMS_PROVIDER", "mock").lower(),
result.get("external_message_id"),
body.messageType,
body.body,
body.mediaUrl,
)
await conn.execute("UPDATE comms_threads SET last_message_at = NOW(), updated_at = NOW() WHERE thread_id = $1::uuid", thread_id)
return {"messageId": str(msg_id), "providerResult": result}
@router.post("/threads/{thread_id}/link-person")
async def link_person(
thread_id: str,
body: LinkPersonBody,
request: Request,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid)", body.personId)
if not exists:
raise HTTPException(status_code=404, detail="CRM person not found")
updated = await conn.execute(
"UPDATE comms_threads SET person_id = $1::uuid, updated_at = NOW() WHERE thread_id = $2::uuid",
body.personId,
thread_id,
)
return {"success": updated.endswith("1"), "threadId": thread_id, "personId": body.personId}
@router.post("/threads/{thread_id}/notes")
async def add_note(thread_id: str, body: NoteBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
RETURNING message_id
""",
thread_id,
f"Note: {body.content}",
)
return {"messageId": str(msg_id)}
@router.post("/threads/{thread_id}/tasks")
async def add_task(thread_id: str, body: TaskBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
text = f"Task: {body.title}" + (f" (Due: {body.dueAt})" if body.dueAt else "")
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
RETURNING message_id
""",
thread_id,
text,
)
return {"messageId": str(msg_id)}
@router.post("/webhooks/{provider}")
async def receive_webhook(provider: str, request: Request):
pool = await _pool(request)
raw_body = await request.body()
secret = os.getenv("COMMS_WEBHOOK_SECRET", "").strip()
if secret:
signature = request.headers.get("x-velocity-signature", "")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid comms webhook signature")
payload = await request.json()
provider_impl = await _get_provider_for_pool(pool, provider)
normalized = await provider_impl.normalize_webhook(payload)
normalized["provider"] = provider
return {"received": True, "ingest": await ingest_inbound_message(pool, normalized)}
@router.get("/settings")
async def get_settings(request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json, updated_at FROM comms_settings WHERE key = 'config'")
config = _json_obj(row["value_json"]) if row else {}
result = _camel_settings(config, row["updated_at"] if row else None)
if result.get("providerApiKey"):
result["providerApiKey"] = "********" + str(result["providerApiKey"])[-4:]
return result
@router.patch("/settings")
async def patch_settings(body: SettingsPatch, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
updates = _snake_settings(body)
if updates.get("provider_api_key", "").startswith("*"):
updates.pop("provider_api_key", None)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
config = _json_obj(row["value_json"]) if row else {}
config.update(updates)
await conn.execute(
"""
INSERT INTO comms_settings (key, value_json, updated_at)
VALUES ('config', $1::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW()
""",
json.dumps(config),
)
return {"success": True}
@router.post("/provider/test")
async def test_provider(request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
return await (await _get_provider_for_pool(pool)).test_connection()
@router.post("/recordings/transcribe")
async def transcribe_recording(body: TranscribeBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
if body.callId:
async with pool.acquire() as conn:
await conn.execute(
"UPDATE comms_call_logs SET transcript_text = $1 WHERE call_id = $2::uuid",
"Transcription pending. Configure COMMS_TRANSCRIPTION_PROVIDER to enable processing.",
body.callId,
)
return {
"success": True,
"status": "pending",
"message": "Transcription intake recorded. A real transcription worker/provider is still required.",
"callId": body.callId,
"recordingUrl": body.recordingUrl,
}

1389
core/api/api/routes_crm.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,403 @@
"""
routes_inventory.py
───────────────────
Inventory Pipeline API
Endpoints:
POST /inventory/import-batches — create a new import batch
GET /inventory/import-batches — list import batches
GET /inventory/import-batches/{batch_id} — get batch status
POST /inventory/properties — create a single property
GET /inventory/properties — list properties
GET /inventory/properties/{property_id} — get a property
PATCH /inventory/properties/{property_id} — update a property
DELETE /inventory/properties/{property_id} — archive a property
POST /inventory/properties/{property_id}/media — attach media to a property
GET /inventory/properties/{property_id}/media — list media for a property
DELETE /inventory/media/{media_asset_id} — remove a media asset
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.inventory")
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
def _tenant_scope(user) -> str:
return user.tenant_id
# ── Pydantic Models ───────────────────────────────────────────────────────────
VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"}
VALID_PROPERTY_STATUSES = {"active", "archived", "draft", "under_review"}
VALID_MEDIA_TYPES = {"image", "video", "floorplan", "brochure", "360", "vr"}
class ImportBatchCreate(BaseModel):
source_type: str
source_file_ref: Optional[str] = None
total_rows: int = 0
class PropertyCreate(BaseModel):
batch_id: Optional[str] = None
source_id: Optional[str] = None
project_name: str
developer_name: str
location: dict = Field(default_factory=dict) # {city, district, lat, lng}
property_type: str
price_bands: list[dict] = Field(default_factory=list)
unit_mix: list[dict] = Field(default_factory=list)
amenities: list[str] = Field(default_factory=list)
status: str = "draft"
validation_state: dict = Field(default_factory=dict)
class PropertyUpdate(BaseModel):
project_name: Optional[str] = None
developer_name: Optional[str] = None
location: Optional[dict] = None
property_type: Optional[str] = None
price_bands: Optional[list[dict]] = None
unit_mix: Optional[list[dict]] = None
amenities: Optional[list[str]] = None
status: Optional[str] = None
validation_state: Optional[dict] = None
class MediaAssetCreate(BaseModel):
media_type: str
url: str
thumbnail_url: Optional[str] = None
sort_order: int = 0
metadata: dict = Field(default_factory=dict)
# ── Import Batches ────────────────────────────────────────────────────────────
@router.post("/import-batches", status_code=status.HTTP_201_CREATED,
summary="Create an inventory import batch")
async def create_import_batch(
request: Request,
body: ImportBatchCreate,
user=Depends(get_current_user),
):
if body.source_type not in VALID_SOURCE_TYPES:
raise HTTPException(400, f"Invalid source_type. Valid: {sorted(VALID_SOURCE_TYPES)}")
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO inventory_import_batches
(tenant_id, source_type, submitted_by, total_rows, source_file_ref)
VALUES ($1, $2, $3, $4, $5)
RETURNING batch_id, status, created_at
""",
_tenant_scope(user), body.source_type, user.user_id, body.total_rows, body.source_file_ref,
)
return dict(row)
@router.get("/import-batches", summary="List import batches")
async def list_import_batches(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT batch_id, source_type, submitted_by, status, total_rows,
accepted_rows, rejected_rows, created_at, completed_at
FROM inventory_import_batches
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
_tenant_scope(user), limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", _tenant_scope(user),
)
return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]}
@router.get("/import-batches/{batch_id}", summary="Get import batch status")
async def get_import_batch(
batch_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2
""",
batch_id, _tenant_scope(user),
)
if not row:
raise HTTPException(404, "Batch not found")
return dict(row)
# ── Properties ────────────────────────────────────────────────────────────────
@router.post("/properties", status_code=status.HTTP_201_CREATED, summary="Create a property")
async def create_property(
request: Request,
body: PropertyCreate,
user=Depends(get_current_user),
):
if body.status not in VALID_PROPERTY_STATUSES:
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}")
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO inventory_properties (
tenant_id, batch_id, source_id, project_name, developer_name,
location, property_type, price_bands, unit_mix, amenities,
status, validation_state
) VALUES (
$1, $2, $3, $4, $5,
$6::jsonb, $7, $8::jsonb, $9::jsonb, $10,
$11, $12::jsonb
)
RETURNING property_id, created_at
""",
_tenant_scope(user), body.batch_id, body.source_id, body.project_name, body.developer_name,
json.dumps(body.location), body.property_type, json.dumps(body.price_bands),
json.dumps(body.unit_mix), body.amenities,
body.status, json.dumps(body.validation_state),
)
return {"property_id": str(row["property_id"]), "created_at": str(row["created_at"])}
@router.get("/properties", summary="List inventory properties")
async def list_properties(
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
property_type: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
where_clause = "WHERE tenant_id = $1"
params: list[Any] = [_tenant_scope(user)]
idx = 2
if status_filter:
where_clause += f" AND status = ${idx}"
params.append(status_filter)
idx += 1
if property_type:
where_clause += f" AND property_type = ${idx}"
params.append(property_type)
idx += 1
rows = await conn.fetch(
f"""
SELECT property_id, project_name, developer_name, property_type,
location, price_bands, unit_mix, status, ingested_at, created_at
FROM inventory_properties
{where_clause}
ORDER BY created_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM inventory_properties {where_clause}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "properties": [dict(r) for r in rows]}
@router.get("/properties/{property_id}", summary="Get a property")
async def get_property(
property_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
property_id, _tenant_scope(user),
)
if not row:
raise HTTPException(404, "Property not found")
return dict(row)
@router.patch("/properties/{property_id}", summary="Update a property")
async def update_property(
property_id: str,
request: Request,
body: PropertyUpdate,
user=Depends(get_current_user),
):
updates: list[str] = []
values: list[Any] = []
idx = 1
def _add(col: str, val: Any, cast: str = ""):
nonlocal idx
updates.append(f"{col} = ${idx}{cast}")
values.append(val)
idx += 1
if body.project_name is not None: _add("project_name", body.project_name)
if body.developer_name is not None: _add("developer_name", body.developer_name)
if body.location is not None: _add("location", json.dumps(body.location), "::jsonb")
if body.property_type is not None: _add("property_type", body.property_type)
if body.price_bands is not None: _add("price_bands", json.dumps(body.price_bands), "::jsonb")
if body.unit_mix is not None: _add("unit_mix", json.dumps(body.unit_mix), "::jsonb")
if body.amenities is not None: _add("amenities", body.amenities)
if body.status is not None:
if body.status not in VALID_PROPERTY_STATUSES:
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}")
_add("status", body.status)
if body.validation_state is not None:
_add("validation_state", json.dumps(body.validation_state), "::jsonb")
if not updates:
raise HTTPException(400, "No fields to update")
_add("updated_at", datetime.now(timezone.utc))
values.extend([property_id, _tenant_scope(user)])
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
f"""
UPDATE inventory_properties
SET {', '.join(updates)}
WHERE property_id=${idx} AND tenant_id=${idx+1}
""",
*values,
)
if result == "UPDATE 0":
raise HTTPException(404, "Property not found")
return {"status": "updated"}
@router.delete("/properties/{property_id}", summary="Archive a property")
async def archive_property(
property_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE inventory_properties
SET status='archived', updated_at=NOW()
WHERE property_id=$1 AND tenant_id=$2
""",
property_id, _tenant_scope(user),
)
if result == "UPDATE 0":
raise HTTPException(404, "Property not found")
return {"status": "archived"}
# ── Media Assets ──────────────────────────────────────────────────────────────
@router.post("/properties/{property_id}/media", status_code=status.HTTP_201_CREATED,
summary="Attach media to a property")
async def add_media(
property_id: str,
request: Request,
body: MediaAssetCreate,
user=Depends(get_current_user),
):
if body.media_type not in VALID_MEDIA_TYPES:
raise HTTPException(400, f"Invalid media_type. Valid: {sorted(VALID_MEDIA_TYPES)}")
pool = _pool(request)
async with pool.acquire() as conn:
# Verify property belongs to tenant
exists = await conn.fetchval(
"SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
property_id, _tenant_scope(user),
)
if not exists:
raise HTTPException(404, "Property not found")
row = await conn.fetchrow(
"""
INSERT INTO inventory_media_assets
(property_id, tenant_id, media_type, url, thumbnail_url, sort_order, metadata, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
RETURNING media_asset_id, created_at
""",
property_id, _tenant_scope(user), body.media_type, body.url, body.thumbnail_url,
body.sort_order, json.dumps(body.metadata), user.user_id,
)
return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])}
@router.get("/properties/{property_id}/media", summary="List media for a property")
async def list_media(
property_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT media_asset_id, media_type, url, thumbnail_url, sort_order, metadata, created_at
FROM inventory_media_assets
WHERE property_id=$1 AND tenant_id=$2
ORDER BY sort_order ASC, created_at ASC
""",
property_id, _tenant_scope(user),
)
return {"media": [dict(r) for r in rows]}
@router.delete("/media/{media_asset_id}", summary="Remove a media asset")
async def delete_media(
media_asset_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2",
media_asset_id, _tenant_scope(user),
)
if result == "DELETE 0":
raise HTTPException(404, "Media asset not found")
return {"status": "deleted"}

View File

@@ -0,0 +1,682 @@
"""
routes_mobile_edge.py
─────────────────────
Mobile Edge API — serves iPhone Edge and Android Phone Edge apps.
Surfaces:
GET /mobile-edge/events — communication events for a lead
POST /mobile-edge/events — log a new communication event
GET /mobile-edge/memory — memory facts for a lead
POST /mobile-edge/imports — operator-assisted import of a recording/note
POST /mobile-edge/notes — quick note attached to a lead
GET /mobile-edge/calendar — calendar events for the authed user
POST /mobile-edge/calendar — create a calendar event
PATCH /mobile-edge/calendar/{id} — update a calendar event
DELETE /mobile-edge/calendar/{id} — cancel a calendar event
GET /mobile-edge/transcripts/{id} — transcript segments for an event
GET /mobile-edge/insights/{lead_id}— insight recommendations for a lead
POST /mobile-edge/insights/{id}/act — act on or dismiss an insight
GET /mobile-edge/alerts — active alerts for the authed user
POST /mobile-edge/session — register a surface session heartbeat
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.mobile_edge")
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _tenant_scope(user) -> str:
return user.tenant_id
# ── Pydantic models ───────────────────────────────────────────────────────────
VALID_CHANNELS = {
"pstn", "whatsapp_message", "whatsapp_voice", "whatsapp_video",
"email", "facebook_message", "instagram_message", "in_app_voip", "manual_note",
}
VALID_CAPTURE_MODES = {"direct_api", "provider_routed", "operator_import", "operator_note"}
VALID_DIRECTIONS = {"inbound", "outbound"}
VALID_CONSENT = {"unknown", "granted", "denied", "not_required"}
VALID_CALENDAR_STATUSES = {"tentative", "confirmed", "done", "cancelled"}
class CommunicationEventCreate(BaseModel):
lead_id: str
channel: str
direction: str = "inbound"
provider: Optional[str] = None
capture_mode: str
consent_state: str = "unknown"
duration_seconds: Optional[int] = None
summary: Optional[str] = None
raw_reference: Optional[str] = None
recording_ref: Optional[str] = None
provider_metadata: dict = Field(default_factory=dict)
class ImportCreate(BaseModel):
lead_id: str
channel: str
capture_mode: str = "operator_import"
recording_ref: Optional[str] = None
summary: Optional[str] = None
consent_state: str = "granted"
class NoteCreate(BaseModel):
lead_id: str
note_text: str
fact_type: str = "custom"
effective_date: Optional[str] = None
class CalendarEventCreate(BaseModel):
lead_id: Optional[str] = None
source_event_id: Optional[str] = None
title: str
description: Optional[str] = None
start_at: str # ISO8601
end_at: str # ISO8601
all_day: bool = False
status: str = "confirmed"
reminder_minutes: list[int] = Field(default_factory=lambda: [15])
location: Optional[str] = None
metadata: dict = Field(default_factory=dict)
class CalendarEventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
start_at: Optional[str] = None
end_at: Optional[str] = None
status: Optional[str] = None
reminder_minutes: Optional[list[int]] = None
location: Optional[str] = None
class InsightActionRequest(BaseModel):
action: str = Field(..., pattern="^(accepted|dismissed|acted_upon)$")
class SessionHeartbeat(BaseModel):
surface_type: str
app_version: str
screen: Optional[str] = None
metadata: dict = Field(default_factory=dict)
# ── Communication Events ───────────────────────────────────────────────────────
@router.get("/events", summary="List communication events for a lead")
async def list_events(
request: Request,
lead_id: str = Query(..., description="Lead ID to fetch events for"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
"""Return paginated communication events for a given lead, newest first."""
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT event_id, lead_id, channel, direction, provider, capture_mode,
consent_state, timestamp, duration_seconds, summary, raw_reference,
recording_ref, provider_metadata, created_at
FROM edge_communication_events
WHERE tenant_id = $1 AND lead_id = $2
ORDER BY timestamp DESC
LIMIT $3 OFFSET $4
""",
_tenant_scope(user),
lead_id, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2",
_tenant_scope(user), lead_id,
)
return {
"total": total,
"limit": limit,
"offset": offset,
"events": [dict(r) for r in rows],
}
@router.post("/events", status_code=status.HTTP_201_CREATED, summary="Log a communication event")
async def create_event(
request: Request,
body: CommunicationEventCreate,
user=Depends(get_current_user),
):
"""
Create a new communication event record.
Supports all three capture modes: direct_api, provider_routed, operator_import.
"""
if body.channel not in VALID_CHANNELS:
raise HTTPException(400, f"Invalid channel. Valid: {sorted(VALID_CHANNELS)}")
if body.capture_mode not in VALID_CAPTURE_MODES:
raise HTTPException(400, f"Invalid capture_mode. Valid: {sorted(VALID_CAPTURE_MODES)}")
if body.direction not in VALID_DIRECTIONS:
raise HTTPException(400, "direction must be 'inbound' or 'outbound'")
if body.consent_state not in VALID_CONSENT:
raise HTTPException(400, f"Invalid consent_state. Valid: {sorted(VALID_CONSENT)}")
pool = _pool(request)
import json
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO edge_communication_events (
tenant_id, lead_id, channel, direction, provider, capture_mode,
consent_state, duration_seconds, summary, raw_reference,
recording_ref, provider_metadata
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)
RETURNING event_id, created_at
""",
_tenant_scope(user), body.lead_id, body.channel, body.direction, body.provider,
body.capture_mode, body.consent_state, body.duration_seconds,
body.summary, body.raw_reference, body.recording_ref,
json.dumps(body.provider_metadata),
)
logger.info("Created communication event %s for lead %s", row["event_id"], body.lead_id)
return {"event_id": str(row["event_id"]), "created_at": str(row["created_at"])}
# ── Communication Memory Facts ────────────────────────────────────────────────
@router.get("/memory", summary="List memory facts for a lead")
async def list_memory_facts(
request: Request,
lead_id: str = Query(...),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT fact_id, lead_id, event_id, fact_type, fact_text,
effective_date, confidence, extracted_from, is_confirmed,
confirmed_by, confirmed_at, created_at
FROM edge_communication_memory_facts
WHERE tenant_id = $1 AND lead_id = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
""",
_tenant_scope(user), lead_id, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM edge_communication_memory_facts WHERE tenant_id=$1 AND lead_id=$2",
_tenant_scope(user), lead_id,
)
return {"total": total, "limit": limit, "offset": offset, "facts": [dict(r) for r in rows]}
# ── Operator-Assisted Import ──────────────────────────────────────────────────
@router.post("/imports", status_code=status.HTTP_201_CREATED, summary="Operator-assisted import")
async def create_import(
request: Request,
body: ImportCreate,
user=Depends(get_current_user),
):
"""
Mode C import: user uploads recording ref or confirms a note manually.
Creates an event with capture_mode = 'operator_import' and triggers a
transcription job if a recording_ref is supplied.
"""
if body.channel not in VALID_CHANNELS:
raise HTTPException(400, f"Invalid channel. Valid: {sorted(VALID_CHANNELS)}")
pool = _pool(request)
import json
async with pool.acquire() as conn:
async with conn.transaction():
event_row = await conn.fetchrow(
"""
INSERT INTO edge_communication_events (
tenant_id, lead_id, channel, direction, capture_mode,
consent_state, recording_ref, summary
) VALUES ($1,$2,$3,'inbound',$4,$5,$6,$7)
RETURNING event_id, created_at
""",
_tenant_scope(user), body.lead_id, body.channel, body.capture_mode,
body.consent_state, body.recording_ref, body.summary,
)
event_id = event_row["event_id"]
job_id = None
if body.recording_ref:
job_row = await conn.fetchrow(
"""
INSERT INTO edge_transcription_jobs (
tenant_id, event_id, media_type, consent_state
) VALUES ($1,$2,'audio',$3)
RETURNING transcription_job_id
""",
_tenant_scope(user), event_id, body.consent_state,
)
job_id = str(job_row["transcription_job_id"])
return {
"event_id": str(event_id),
"transcription_job_id": job_id,
"created_at": str(event_row["created_at"]),
}
# ── Quick Notes ───────────────────────────────────────────────────────────────
@router.post("/notes", status_code=status.HTTP_201_CREATED, summary="Create a quick note for a lead")
async def create_note(
request: Request,
body: NoteCreate,
user=Depends(get_current_user),
):
"""
Create a manual memory fact from an operator note.
No event is created — this is a direct fact insertion.
"""
pool = _pool(request)
from datetime import date
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO edge_communication_memory_facts (
tenant_id, lead_id, fact_type, fact_text, effective_date,
extracted_from, confidence, is_confirmed
) VALUES ($1,$2,$3,$4,$5,'operator_note',1.0, TRUE)
RETURNING fact_id, created_at
""",
_tenant_scope(user), body.lead_id, body.fact_type, body.note_text,
body.effective_date,
)
return {"fact_id": str(row["fact_id"]), "created_at": str(row["created_at"])}
# ── Calendar ──────────────────────────────────────────────────────────────────
@router.get("/calendar", summary="Get calendar events for the authed user")
async def list_calendar_events(
request: Request,
from_date: Optional[str] = Query(None),
to_date: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
if from_date and to_date:
rows = await conn.fetch(
"""
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
all_day, status, reminder_minutes, created_by, location, metadata, created_at
FROM user_calendar_events
WHERE tenant_id=$1 AND owner_user_id=$2
AND status <> 'cancelled'
AND start_at >= $3::timestamptz AND end_at <= $4::timestamptz
ORDER BY start_at ASC LIMIT $5
""",
_tenant_scope(user), user.user_id, from_date, to_date, limit,
)
else:
rows = await conn.fetch(
"""
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
all_day, status, reminder_minutes, created_by, location, metadata, created_at
FROM user_calendar_events
WHERE tenant_id=$1 AND owner_user_id=$2
AND status <> 'cancelled'
ORDER BY start_at ASC LIMIT $3
""",
_tenant_scope(user), user.user_id, limit,
)
return {"events": [dict(r) for r in rows]}
@router.post("/calendar", status_code=status.HTTP_201_CREATED, summary="Create a calendar event")
async def create_calendar_event(
request: Request,
body: CalendarEventCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
import json
if body.status not in VALID_CALENDAR_STATUSES:
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO user_calendar_events (
tenant_id, owner_user_id, lead_id, source_event_id, title, description,
start_at, end_at, all_day, status, reminder_minutes, created_by, location, metadata
) VALUES (
$1::text,$2::text,$3::text,$4::uuid,$5::text,$6::text,
$7::timestamptz,$8::timestamptz,$9::boolean,$10::text,
$11::integer[],$12::text,$13::text,$14::jsonb
)
RETURNING calendar_event_id, lead_id, title, description, start_at, end_at,
all_day, status, reminder_minutes, created_by, location, metadata, created_at
""",
_tenant_scope(user), user.user_id, body.lead_id, body.source_event_id,
body.title, body.description, body.start_at, body.end_at,
body.all_day, body.status, body.reminder_minutes, "user",
body.location, json.dumps(body.metadata),
)
event = dict(row)
event["calendar_event_id"] = str(event["calendar_event_id"])
for key in ("start_at", "end_at", "created_at"):
if event.get(key) is not None and hasattr(event[key], "isoformat"):
event[key] = event[key].isoformat()
return {"status": "ok", "event": event}
@router.patch("/calendar/{calendar_event_id}", summary="Update a calendar event")
async def update_calendar_event(
calendar_event_id: str,
request: Request,
body: CalendarEventUpdate,
user=Depends(get_current_user),
):
pool = _pool(request)
# Build partial update
updates: list[str] = []
values: list[Any] = []
idx = 1
def _add(col: str, val: Any):
nonlocal idx
updates.append(f"{col} = ${idx}")
values.append(val)
idx += 1
if body.title is not None: _add("title", body.title)
if body.description is not None: _add("description", body.description)
if body.start_at is not None: _add("start_at", body.start_at)
if body.end_at is not None: _add("end_at", body.end_at)
if body.status is not None:
if body.status not in VALID_CALENDAR_STATUSES:
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
_add("status", body.status)
if body.reminder_minutes is not None: _add("reminder_minutes", body.reminder_minutes)
if body.location is not None: _add("location", body.location)
if not updates:
raise HTTPException(400, "No fields to update")
_add("updated_at", datetime.now(timezone.utc))
_add("tenant_id", _tenant_scope(user))
_add("owner_user_id", user.user_id)
values.append(calendar_event_id)
async with pool.acquire() as conn:
result = await conn.execute(
f"""
UPDATE user_calendar_events
SET {', '.join(updates)}
WHERE tenant_id=${idx} AND owner_user_id=${idx+1} AND calendar_event_id=${idx+2}
""",
*values,
)
if result == "UPDATE 0":
raise HTTPException(404, "Calendar event not found or not owned by you")
return {"status": "updated", "calendar_event_id": calendar_event_id}
@router.delete("/calendar/{calendar_event_id}", summary="Cancel a calendar event")
async def delete_calendar_event(
calendar_event_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE user_calendar_events
SET status='cancelled', updated_at=NOW()
WHERE tenant_id=$1 AND owner_user_id=$2 AND calendar_event_id=$3
""",
_tenant_scope(user), user.user_id, calendar_event_id,
)
if result == "UPDATE 0":
raise HTTPException(404, "Calendar event not found or not owned by you")
return {"status": "cancelled"}
# ── Transcripts ───────────────────────────────────────────────────────────────
@router.get("/transcripts/{event_id}", summary="Get transcript segments for an event")
async def get_transcript(
event_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
job = await conn.fetchrow(
"""
SELECT j.transcription_job_id, j.status, j.provider, j.speaker_count,
j.word_count, j.language, j.completed_at
FROM edge_transcription_jobs j
JOIN edge_communication_events e ON e.event_id = j.event_id
WHERE j.event_id = $1 AND e.tenant_id = $2
ORDER BY j.created_at DESC LIMIT 1
""",
event_id, _tenant_scope(user),
)
if not job:
raise HTTPException(404, "No transcription job found for this event")
segments = await conn.fetch(
"""
SELECT segment_id, speaker_label, start_ms, end_ms, text, confidence, is_agent_turn
FROM edge_transcript_segments
WHERE transcription_job_id = $1
ORDER BY start_ms ASC
""",
job["transcription_job_id"],
)
return {
"job": dict(job),
"segments": [dict(s) for s in segments],
}
# ── Insights ──────────────────────────────────────────────────────────────────
@router.get("/insights/{lead_id}", summary="Get insight recommendations for a lead")
async def get_insights(
lead_id: str,
request: Request,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(20, ge=1, le=100),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
if status_filter:
rows = await conn.fetch(
"""
SELECT recommendation_id, lead_id, source_event_id, recommendation_type,
summary, suggested_action, target_system, status, confidence, created_at
FROM insight_recommendations
WHERE tenant_id=$1 AND lead_id=$2 AND status=$3
ORDER BY created_at DESC LIMIT $4
""",
_tenant_scope(user), lead_id, status_filter, limit,
)
else:
rows = await conn.fetch(
"""
SELECT recommendation_id, lead_id, source_event_id, recommendation_type,
summary, suggested_action, target_system, status, confidence, created_at
FROM insight_recommendations
WHERE tenant_id=$1 AND lead_id=$2
ORDER BY created_at DESC LIMIT $3
""",
_tenant_scope(user), lead_id, limit,
)
return {"insights": [dict(r) for r in rows]}
@router.post("/insights/{recommendation_id}/act", summary="Act on or dismiss an insight")
async def act_on_insight(
recommendation_id: str,
request: Request,
body: InsightActionRequest,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE insight_recommendations
SET status=$1, acted_by=$2, acted_at=NOW(), updated_at=NOW()
WHERE recommendation_id=$3 AND tenant_id=$4
""",
body.action, user.user_id, recommendation_id, _tenant_scope(user),
)
if result == "UPDATE 0":
raise HTTPException(404, "Insight not found")
return {"status": body.action}
# ── Alerts ────────────────────────────────────────────────────────────────────
@router.get("/alerts", summary="Get active alerts for the authed user")
async def get_alerts(
request: Request,
user=Depends(get_current_user),
):
"""
Returns a combined, prioritized view of:
- Pending insights needing action
- Calendar events due within 24 hours
- Pending transcription jobs
"""
pool = _pool(request)
async with pool.acquire() as conn:
pending_insights = await conn.fetchval(
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
_tenant_scope(user),
)
upcoming_events = await conn.fetchval(
"""
SELECT COUNT(*) FROM user_calendar_events
WHERE tenant_id=$1 AND owner_user_id=$2
AND status='confirmed'
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
""",
_tenant_scope(user), user.user_id,
)
pending_transcriptions = await conn.fetchval(
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
_tenant_scope(user),
)
return {
"pending_insights": pending_insights,
"upcoming_calendar_events_24h": upcoming_events,
"pending_transcriptions": pending_transcriptions,
"generated_at": _now(),
}
# ── Session Heartbeat ─────────────────────────────────────────────────────────
@router.post("/session", status_code=status.HTTP_200_OK, summary="Register surface session heartbeat")
async def session_heartbeat(
request: Request,
body: SessionHeartbeat,
user=Depends(get_current_user),
):
"""Upsert a surface session to track cross-surface activity."""
valid_surfaces = {
"webos", "ipad", "android_tablet", "iphone_edge", "android_phone_edge",
}
if body.surface_type not in valid_surfaces:
raise HTTPException(400, f"Invalid surface_type. Valid: {sorted(valid_surfaces)}")
pool = _pool(request)
import json
async with pool.acquire() as conn:
existing_session_id = await conn.fetchval(
"""
SELECT session_id
FROM surface_sessions
WHERE tenant_id=$1 AND user_id=$2 AND surface_type=$3
AND ended_at IS NULL
AND last_active_at > NOW() - INTERVAL '30 minutes'
ORDER BY last_active_at DESC
LIMIT 1
""",
_tenant_scope(user), user.user_id, body.surface_type,
)
if existing_session_id:
await conn.execute(
"""
UPDATE surface_sessions
SET last_active_at=NOW(),
app_version=$1,
metadata=$2::jsonb,
screen_sequence = CASE
WHEN $3::text IS NULL THEN screen_sequence
ELSE array_append(screen_sequence, $3::text)
END
WHERE session_id=$4
""",
body.app_version, json.dumps(body.metadata), body.screen, existing_session_id,
)
else:
await conn.execute(
"""
INSERT INTO surface_sessions (
tenant_id, user_id, surface_type, app_version, metadata, screen_sequence
)
VALUES (
$1, $2, $3, $4, $5::jsonb,
CASE
WHEN $6::text IS NULL THEN '{}'::text[]
ELSE ARRAY[$6::text]
END
)
""",
_tenant_scope(user), user.user_id, body.surface_type, body.app_version,
json.dumps(body.metadata), body.screen,
)
return {"status": "ok", "timestamp": _now()}

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.observability import metrics_snapshot
router = APIRouter(prefix="/observability", tags=["Observability"])
@router.get("/request-metrics")
async def request_metrics(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
user: UserPrincipal = Depends(get_current_user),
) -> dict:
return {
"status": "ok",
"data": {
"tenant_id": user.tenant_id,
"metrics": metrics_snapshot(request.app, limit=limit),
},
}

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from backend.oracle.action_service import oracle_action_service
from backend.oracle.natural_db_agent import natural_db_agent
from backend.oracle.persona_service import persona_service
from backend.services.mcp_registry import mcp_registry
from backend.services.nemoclaw_runtime import nemoclaw_runtime
from backend.services.runtime_llm_service import runtime_llm_service
router = APIRouter()
class WorkflowPreviewRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4096)
tenant_id: str = "tenant_velocity"
actor_role: str = "sales_director"
class MCPExecuteRequest(BaseModel):
tool_name: str = Field(..., min_length=1, max_length=128)
query: str = Field(..., min_length=1, max_length=1024)
class OracleWritebackRequest(BaseModel):
action_id: str
tenant_id: str = "tenant_velocity"
actor_id: str = "oracle_operator"
target_entity_type: str = Field(..., min_length=1, max_length=64)
target_entity_id: str = Field(..., min_length=1, max_length=128)
action_type: str = Field(default="lead_writeback", min_length=1, max_length=128)
writeback_payload: dict = Field(default_factory=dict)
class OracleQueryRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4096)
row_limit: int = Field(default=100, ge=1, le=500)
context: dict = Field(default_factory=dict)
@router.get("/health")
async def oracle_health() -> dict:
return {
"status": "ok",
"persona": await persona_service.health(),
"mcp_tools": mcp_registry.list_tools(),
"runtime_llm": await runtime_llm_service.list_providers(),
}
@router.get("/data-health")
async def oracle_data_health(request: Request) -> dict:
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:
data = await natural_db_agent.data_health(conn)
return {
"status": "ok",
"data": data,
}
@router.get("/schema-catalog")
async def oracle_schema_catalog(request: Request) -> dict:
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:
catalog = await natural_db_agent.schema_catalog(conn)
return {"status": "ok", "data": catalog}
@router.post("/query")
async def oracle_query(request: Request, payload: OracleQueryRequest) -> dict:
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:
result = await natural_db_agent.execute_prompt(payload.prompt, row_limit=payload.row_limit, conn=conn)
return {"status": "ok", "data": result.as_dict()}
@router.get("/mcp/tools")
async def oracle_mcp_tools() -> dict:
return {"status": "ok", "data": mcp_registry.list_tools()}
@router.post("/mcp/execute")
async def oracle_mcp_execute(request: Request, payload: MCPExecuteRequest) -> dict:
pool = getattr(request.app.state, "db_pool", None)
result = await mcp_registry.execute(payload.tool_name, payload.query, crm_pool=pool)
return {"status": "ok", "data": result}
@router.post("/workflow/preview")
async def workflow_preview(payload: WorkflowPreviewRequest) -> dict:
persona_plan = await persona_service.plan_for_prompt(
prompt=payload.prompt,
tenant_id=payload.tenant_id,
actor_role=payload.actor_role,
)
return {
"status": "ok",
"data": {
"persona_plan": persona_plan,
"workflow": nemoclaw_runtime.build_workflow_dispatch(
prompt=payload.prompt,
tenant_id=payload.tenant_id,
actor_role=payload.actor_role,
component_templates=persona_plan["recommendedTemplates"],
),
},
}
@router.get("/actions")
async def list_oracle_actions(status: str | None = None, limit: int = 50) -> dict:
actions = await oracle_action_service.list_actions(status=status, limit=limit)
return {"status": "ok", "data": actions, "meta": {"count": len(actions)}}
@router.get("/actions/{action_id}")
async def get_oracle_action(action_id: str) -> dict:
action = await oracle_action_service.get_action(action_id)
if not action:
raise HTTPException(status_code=404, detail=f"Oracle action '{action_id}' not found.")
return {"status": "ok", "data": action}
@router.post("/actions/writeback")
async def apply_oracle_writeback(request: Request, payload: OracleWritebackRequest) -> dict:
result = await oracle_action_service.apply_writeback(payload.model_dump())
if hasattr(request.app.state, "broadcast_crm_event"):
await request.app.state.broadcast_crm_event(
{
"type": "oracle_writeback",
"entity": payload.target_entity_type,
"entity_id": payload.target_entity_id,
"action_id": payload.action_id,
"payload": result["resultPayload"],
}
)
return {"status": "ok", "data": result}

View File

@@ -0,0 +1,404 @@
"""
routes_oracle_templates.py
──────────────────────────
Oracle Template Catalog API
Extends the existing Oracle route surface with template taxonomy and seeding.
Endpoints:
GET /oracle/template-chapters — list chapters
POST /oracle/template-chapters — create a chapter
GET /oracle/template-subchapters — list subchapters (optionally filtered)
POST /oracle/template-subchapters — create a subchapter
GET /oracle/component-templates — list templates (filterable)
POST /oracle/component-templates — create a template
GET /oracle/component-templates/{id} — get a template
POST /oracle/component-templates/{id}/seed — add a seed example
GET /oracle/component-templates/{id}/seed — list seed examples for a template
POST /oracle/component-templates/synthetic-jobs — trigger a Kimi synthetic job
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.oracle_templates")
router = APIRouter()
_DEFAULT_TENANT_ID = os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(503, "Database unavailable.")
return pool
def _tenant_id() -> str:
return _DEFAULT_TENANT_ID
# ── Models ────────────────────────────────────────────────────────────────────
class ChapterCreate(BaseModel):
name: str
description: Optional[str] = None
sort_order: int = 0
class SubchapterCreate(BaseModel):
chapter_id: str
name: str
description: Optional[str] = None
sort_order: int = 0
class TemplateCreate(BaseModel):
name: str
category: str
chapter_id: Optional[str] = None
subchapter_id: Optional[str] = None
component_type: Optional[str] = None
accepted_shapes: list[str] = Field(default_factory=list)
json_template: Optional[dict] = None
description: Optional[str] = None
origin: str = "premade"
version: str = "1.0.0"
class SeedExampleCreate(BaseModel):
title: str
example_json: dict
quality_notes: Optional[str] = None
chapter_id: Optional[str] = None
subchapter_id: Optional[str] = None
is_canonical: bool = False
class SyntheticJobCreate(BaseModel):
template_id: str
chapter_id: Optional[str] = None
subchapter_id: Optional[str] = None
model: str = "kimi"
requested_count: int = Field(10, ge=1, le=500)
# ── Template Chapters ─────────────────────────────────────────────────────────
@router.get("/template-chapters", summary="List Oracle template chapters")
async def list_template_chapters(
request: Request,
include_inactive: bool = Query(False),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
where = "WHERE ch.tenant_id=$1" + ("" if include_inactive else " AND ch.is_active=TRUE")
rows = await conn.fetch(
f"""
SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active,
COUNT(sub.subchapter_id) FILTER (WHERE sub.is_active=TRUE) as subchapter_count,
COUNT(t.template_id) as template_count
FROM oracle_template_chapters ch
LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id
LEFT JOIN oracle_component_templates t ON t.chapter_id = ch.chapter_id
AND t.status != 'archived'
{where}
GROUP BY ch.chapter_id
ORDER BY ch.sort_order ASC
""",
_tenant_id(),
)
return {"chapters": [dict(r) for r in rows]}
@router.post("/template-chapters", status_code=status.HTTP_201_CREATED,
summary="Create a template chapter")
async def create_template_chapter(
request: Request,
body: ChapterCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO oracle_template_chapters (tenant_id, name, description, sort_order)
VALUES ($1,$2,$3,$4)
RETURNING chapter_id, created_at
""",
_tenant_id(), body.name, body.description, body.sort_order,
)
return {"chapter_id": str(row["chapter_id"]), "created_at": str(row["created_at"])}
# ── Template Subchapters ──────────────────────────────────────────────────────
@router.get("/template-subchapters", summary="List Oracle template subchapters")
async def list_template_subchapters(
request: Request,
chapter_id: Optional[str] = Query(None),
include_inactive: bool = Query(False),
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
where = "WHERE sub.tenant_id=$1"
params: list[Any] = [_tenant_id()]
idx = 2
if not include_inactive:
where += " AND sub.is_active=TRUE"
if chapter_id:
where += f" AND sub.chapter_id=${idx}"; params.append(chapter_id); idx += 1
rows = await conn.fetch(
f"""
SELECT sub.subchapter_id, sub.chapter_id, ch.name as chapter_name,
sub.name, sub.description, sub.sort_order, sub.is_active,
COUNT(t.template_id) as template_count
FROM oracle_template_subchapters sub
JOIN oracle_template_chapters ch ON ch.chapter_id = sub.chapter_id
LEFT JOIN oracle_component_templates t ON t.subchapter_id = sub.subchapter_id
AND t.status != 'archived'
{where}
GROUP BY sub.subchapter_id, ch.name
ORDER BY sub.chapter_id, sub.sort_order ASC
""",
*params,
)
return {"subchapters": [dict(r) for r in rows]}
@router.post("/template-subchapters", status_code=status.HTTP_201_CREATED,
summary="Create a template subchapter")
async def create_template_subchapter(
request: Request,
body: SubchapterCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
# Verify chapter exists and belongs to tenant
ch_exists = await conn.fetchval(
"SELECT 1 FROM oracle_template_chapters WHERE chapter_id=$1 AND tenant_id=$2",
body.chapter_id, _tenant_id(),
)
if not ch_exists:
raise HTTPException(404, "Chapter not found")
row = await conn.fetchrow(
"""
INSERT INTO oracle_template_subchapters
(chapter_id, tenant_id, name, description, sort_order)
VALUES ($1,$2,$3,$4,$5)
RETURNING subchapter_id, created_at
""",
body.chapter_id, _tenant_id(), body.name, body.description, body.sort_order,
)
return {"subchapter_id": str(row["subchapter_id"]), "created_at": str(row["created_at"])}
# ── Component Templates ───────────────────────────────────────────────────────
@router.get("/component-templates", summary="List Oracle component templates")
async def list_component_templates(
request: Request,
chapter_id: Optional[str] = Query(None),
subchapter_id: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
search: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user=Depends(get_current_user),
):
pool = _pool(request)
where = "WHERE t.tenant_id=$1"
params: list[Any] = [_tenant_id()]
idx = 2
if chapter_id:
where += f" AND t.chapter_id=${idx}"; params.append(chapter_id); idx += 1
if subchapter_id:
where += f" AND t.subchapter_id=${idx}"; params.append(subchapter_id); idx += 1
if status_filter:
where += f" AND t.status=${idx}"; params.append(status_filter); idx += 1
if search:
where += f" AND (t.name ILIKE ${idx} OR t.description ILIKE ${idx})"
params.append(f"%{search}%"); idx += 1
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.template_id, t.name, t.category, t.status, t.origin, t.version,
t.accepted_shapes, t.use_count, t.chapter_id, t.subchapter_id,
t.description, ch.name as chapter_name, sub.name as subchapter_name,
t.created_at, t.updated_at
FROM oracle_component_templates t
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
{where}
ORDER BY t.updated_at DESC
LIMIT ${idx} OFFSET ${idx+1}
""",
*params, limit, offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM oracle_component_templates t {where}", *params,
)
return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]}
@router.post("/component-templates", status_code=status.HTTP_201_CREATED,
summary="Create a component template")
async def create_component_template(
request: Request,
body: TemplateCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO oracle_component_templates (
tenant_id, name, category, chapter_id, subchapter_id,
accepted_shapes, json_template, description, origin, version, status
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,'draft')
RETURNING template_id, created_at
""",
_tenant_id(), body.name, body.category, body.chapter_id, body.subchapter_id,
body.accepted_shapes,
json.dumps(body.json_template) if body.json_template else None,
body.description, body.origin, body.version,
)
return {"template_id": str(row["template_id"]), "created_at": str(row["created_at"])}
@router.get("/component-templates/{template_id}", summary="Get a component template")
async def get_component_template(
template_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT t.*, ch.name as chapter_name, sub.name as subchapter_name
FROM oracle_component_templates t
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
WHERE t.template_id=$1 AND t.tenant_id=$2
""",
template_id, _tenant_id(),
)
if not row:
raise HTTPException(404, "Template not found")
return dict(row)
# ── Seed Examples ─────────────────────────────────────────────────────────────
@router.post("/component-templates/{template_id}/seed", status_code=status.HTTP_201_CREATED,
summary="Add a seed example to a template")
async def add_seed_example(
template_id: str,
request: Request,
body: SeedExampleCreate,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT 1 FROM oracle_component_templates WHERE template_id=$1 AND tenant_id=$2",
template_id, _tenant_id(),
)
if not exists:
raise HTTPException(404, "Template not found")
row = await conn.fetchrow(
"""
INSERT INTO oracle_template_seed_examples (
template_id, chapter_id, subchapter_id, title, example_json,
quality_notes, is_canonical
) VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)
RETURNING example_id, created_at
""",
template_id, body.chapter_id, body.subchapter_id, body.title,
json.dumps(body.example_json), body.quality_notes, body.is_canonical,
)
return {"example_id": str(row["example_id"]), "created_at": str(row["created_at"])}
@router.get("/component-templates/{template_id}/seed", summary="List seed examples for a template")
async def list_seed_examples(
template_id: str,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT example_id, title, example_json, quality_notes, is_canonical, created_at
FROM oracle_template_seed_examples
WHERE template_id=$1
ORDER BY is_canonical DESC, created_at ASC
""",
template_id,
)
return {"examples": [dict(r) for r in rows]}
# ── Synthetic Jobs ────────────────────────────────────────────────────────────
@router.post("/component-templates/synthetic-jobs", status_code=status.HTTP_201_CREATED,
summary="Trigger a Kimi synthetic data generation job")
async def trigger_synthetic_job(
request: Request,
body: SyntheticJobCreate,
user=Depends(get_current_user),
):
"""
Queues a Kimi synthetic data expansion job for a template.
The job will be picked up by the background synthetic generation worker.
"""
pool = _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT 1 FROM oracle_component_templates WHERE template_id=$1 AND tenant_id=$2",
body.template_id, _tenant_id(),
)
if not exists:
raise HTTPException(404, "Template not found")
row = await conn.fetchrow(
"""
INSERT INTO oracle_synthetic_generation_jobs (
tenant_id, template_id, chapter_id, subchapter_id,
model, requested_count, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING job_id, status, created_at
""",
_tenant_id(), body.template_id, body.chapter_id, body.subchapter_id,
body.model, body.requested_count, user.user_id,
)
logger.info(
"Synthetic job queued: %s for template %s (%d examples)",
row["job_id"], body.template_id, body.requested_count,
)
return {
"job_id": str(row["job_id"]),
"status": row["status"],
"created_at": str(row["created_at"]),
}

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.services.runtime_llm_service import runtime_llm_service
router = APIRouter()
class ChatMessage(BaseModel):
role: str = Field(..., pattern="^(system|user|assistant)$")
content: str = Field(..., min_length=1)
class RuntimeChatRequest(BaseModel):
provider: str | None = None
model: str | None = None
system_prompt: str | None = None
messages: list[ChatMessage]
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
response_format: str | None = Field(default=None, pattern="^(json|text)$")
metadata: dict[str, Any] = Field(default_factory=dict)
class BatchItemRequest(BaseModel):
request_id: str
messages: list[ChatMessage]
system_prompt: str | None = None
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
response_format: str | None = Field(default=None, pattern="^(json|text)$")
metadata: dict[str, Any] = Field(default_factory=dict)
class RuntimeBatchRequest(BaseModel):
provider: str | None = None
model: str | None = None
job_type: str = Field(..., min_length=1, max_length=128)
metadata: dict[str, Any] = Field(default_factory=dict)
items: list[BatchItemRequest] = Field(..., min_length=1, max_length=128)
def _normalize_user(user: UserPrincipal) -> dict[str, str]:
return {
"user_id": user.user_id,
"role": user.role,
}
@router.get("/providers", summary="List configured runtime LLM providers and models")
async def list_runtime_providers(_: UserPrincipal = Depends(get_current_user)) -> dict:
return {"status": "ok", "data": await runtime_llm_service.list_providers()}
@router.post("/chat", summary="Execute a single runtime LLM chat completion")
async def runtime_chat(
payload: RuntimeChatRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
response = await runtime_llm_service.chat(
provider_id=payload.provider,
model=payload.model,
system_prompt=payload.system_prompt,
messages=[message.model_dump() for message in payload.messages],
temperature=payload.temperature,
response_format=payload.response_format,
metadata={
**payload.metadata,
"requested_by": _normalize_user(user),
},
)
return {"status": "ok", "data": response}
@router.post("/batch", status_code=status.HTTP_202_ACCEPTED, summary="Submit a persisted runtime LLM batch job")
async def runtime_batch(
payload: RuntimeBatchRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
pool = getattr(request.app.state, "db_pool", None)
result = await runtime_llm_service.submit_batch(
provider_id=payload.provider,
model=payload.model,
job_type=payload.job_type,
items=[item.model_dump() for item in payload.items],
metadata={
**payload.metadata,
"requested_by": _normalize_user(user),
},
pool=pool,
actor_id=user.user_id,
)
return {"status": "ok", "data": result}
@router.get("/jobs/{job_id}", summary="Get runtime LLM batch job status")
async def get_runtime_job(
job_id: str,
request: Request,
_: UserPrincipal = Depends(get_current_user),
) -> dict:
pool = getattr(request.app.state, "db_pool", None)
job = await runtime_llm_service.get_job(job_id, pool=pool)
if not job:
raise HTTPException(status_code=404, detail=f"Runtime LLM job '{job_id}' not found.")
return {
"status": "ok",
"data": {
"job_id": job["job_id"],
"status": job["status"],
"provider": job["provider"],
"model": job["model"],
"job_type": job["job_type"],
"submitted_count": job["submitted_count"],
"completed_count": job["completed_count"],
"failed_count": job["failed_count"],
"created_at": job["created_at"],
"started_at": job["started_at"],
"completed_at": job["completed_at"],
"metadata": job.get("metadata") or {},
},
}
@router.get("/jobs/{job_id}/results", summary="Get runtime LLM batch job item results")
async def get_runtime_job_results(
job_id: str,
request: Request,
_: UserPrincipal = Depends(get_current_user),
) -> dict:
pool = getattr(request.app.state, "db_pool", None)
results = await runtime_llm_service.list_job_results(job_id, pool=pool)
if results is None:
raise HTTPException(status_code=404, detail=f"Runtime LLM job '{job_id}' not found.")
return {"status": "ok", "data": results, "meta": {"count": len(results)}}

View File