forked from sagnik/Velocity-OS
Initial commit: Velocity-OS migration
This commit is contained in:
529
core/api/api/routes_admin_surface.py
Normal file
529
core/api/api/routes_admin_surface.py
Normal 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"}
|
||||
512
core/api/api/routes_catalyst.py
Normal file
512
core/api/api/routes_catalyst.py
Normal 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"),
|
||||
})
|
||||
588
core/api/api/routes_comms.py
Normal file
588
core/api/api/routes_comms.py
Normal 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
1389
core/api/api/routes_crm.py
Normal file
File diff suppressed because it is too large
Load Diff
1521
core/api/api/routes_crm_imports.py
Normal file
1521
core/api/api/routes_crm_imports.py
Normal file
File diff suppressed because it is too large
Load Diff
403
core/api/api/routes_inventory.py
Normal file
403
core/api/api/routes_inventory.py
Normal 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"}
|
||||
682
core/api/api/routes_mobile_edge.py
Normal file
682
core/api/api/routes_mobile_edge.py
Normal 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()}
|
||||
24
core/api/api/routes_observability.py
Normal file
24
core/api/api/routes_observability.py
Normal 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),
|
||||
},
|
||||
}
|
||||
|
||||
146
core/api/api/routes_oracle.py
Normal file
146
core/api/api/routes_oracle.py
Normal 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}
|
||||
404
core/api/api/routes_oracle_templates.py
Normal file
404
core/api/api/routes_oracle_templates.py
Normal 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"]),
|
||||
}
|
||||
140
core/api/api/routes_runtime_llm.py
Normal file
140
core/api/api/routes_runtime_llm.py
Normal 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)}}
|
||||
0
core/api/api/routes_weaver.py
Normal file
0
core/api/api/routes_weaver.py
Normal file
Reference in New Issue
Block a user