Initial commit: Velocity-OS migration

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

99
.gitignore vendored Normal file
View File

@@ -0,0 +1,99 @@
# Velocity-OS — Gitignore
# Generated by Velocity-OS project setup
# ── Agent Context & Local AI Files ─────────────────────────────
.agent context/
.agent_context/
.Agent Context/
**/.agent context/
**/.Agent Context/
# ── Node / React / Vite ────────────────────────────────────────
node_modules/
dist/
dist-ssr/
.vite/
*.local
.env
.env.*
!.env.example
# ── Python ────────────────────────────────────────────────────
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
venv/
.venv/
env/
pip-log.txt
*.egg-info/
.eggs/
.tox/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# ── Secrets & Environment ──────────────────────────────────────
*.env
secrets/
infrastructure/k3s/secrets/*.yaml
!infrastructure/k3s/secrets/secrets-template.yaml
*.key
*.pem
*.pfx
*.p12
# ── Docker / Container ────────────────────────────────────────
.docker/
docker-compose.override.yml
# ── Database ──────────────────────────────────────────────────
*.sql.bak
*.dump
pgdata/
# ── Test Data (never in production) ───────────────────────────
core/db/seed_test_users.sql
# ── AI Models & Large Files ───────────────────────────────────
*.safetensors
*.ckpt
*.pt
*.pth
*.gguf
*.bin
*.pkl
models/
checkpoints/
loras/
# ── OS ────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
*.swp
*.swo
# ── IDE ───────────────────────────────────────────────────────
.idea/
.vscode/settings.json
*.suo
*.ntvs*
*.njsproj
*.sln
# ── Logs ──────────────────────────────────────────────────────
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ── TypeScript build output ───────────────────────────────────
*.tsbuildinfo
# ── Coverage ──────────────────────────────────────────────────
coverage/
.nyc_output/

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# Velocity-OS
> **The AI-Augmented Real Estate Operating System** — a production-grade, air-gapped, containerized appliance for luxury property sales teams.
Built on the Jobs/Ive mandate: *design is how it works, not just how it looks.*
---
## Architecture at a Glance
```
Velocity-OS/
├── webos/ React 19 + Framer Motion + Zustand — 3-Pillar UX
├── core/ FastAPI + PostgreSQL + Oracle NL Engine
├── agents/ NemoClaw sandboxes (OpenShell policy)
├── media-engine/ Dream Weaver gateway (ComfyUI async)
├── perception/ MediaPipe WASM + QD scoring
└── infrastructure/ K3s + ECR + MIG GPU + s5cmd model hydration
```
## The 3-Pillar Model
| Pillar | Route | Purpose |
|---|---|---|
| ⚡ **Command** | `/command` | Morning briefing — KPIs, Oracle NL, AI priority cards |
| 🎯 **Pipeline** | `/pipeline` | Deal intelligence — Kanban, Client 360, Showroom |
| 🎨 **Studio** | `/studio` | Asset hub — Properties, Dream Weaver, Campaigns |
| ⚙ Control Room | `/control-room` | Admin-only — system, schema, users, GPU |
## GPU Architecture
Target: **NVIDIA RTX 6000 Blackwell (96GB VRAM)**
MIG partitioned into two concurrent slices:
- **Slice 0 (48GB)** — SGLang: Qwen3.6 35B LLM inference
- **Slice 1 (48GB)** — ComfyUI: Wan 2.2 + Qwen-Image staging
Zero-contention. No operator toggle required.
## Deployment (Air-Gapped Workstation)
```bash
# 1. Hydrate AI models from S3 (one-time, ~105GB)
cd infrastructure/model-hydration
./hydrate_models.sh
# 2. Apply K3s manifests
kubectl apply -f infrastructure/k3s/namespaces/
kubectl apply -f infrastructure/k3s/volumes/
kubectl apply -f infrastructure/k3s/secrets/secrets-template.yaml # fill real values first
kubectl apply -f infrastructure/k3s/deployments/gpu-mig-config.yaml
kubectl apply -f infrastructure/k3s/deployments/deployments.yaml
kubectl apply -f infrastructure/k3s/services/services.yaml
kubectl apply -f infrastructure/k3s/ingress/ingress.yaml
# 3. Run DB init job
kubectl wait --for=condition=complete job/db-init -n velocity-os --timeout=120s
# 4. Access at https://velocity.local
```
## CI/CD Pipeline
Internet-connected CI (GitLab) builds and cosign-signs images → pushes to AWS ECR.
Ingress Box (LAN node) polls ECR every 5 min → verifies signature → SCP to air-gapped workstation → `k3s ctr images import``kubectl rollout restart`.
**Unsigned images are physically rejected at the Ingress Box.**
## Design System
- **Base**: `hsl(225, 25%, 8%)` deep navy
- **Accent**: `#7C3AED` Velocity Violet (AI/actions)
- **Intent**: `#F59E0B` Amber (QD / alerts)
- **Glass**: `backdrop-filter: blur(20px)` · `rgba(255,255,255,0.05)` panels
- **Motion**: `300ms cubic-bezier(0.4, 0, 0.2, 1)` standard · spring for reveals
## Immutability Constraint
`Project_Velocity` (source) is **read-only**. All files in this repository are copies + refactors. No source files were modified or deleted.
---
*Velocity-OS v2.0 · Desineuron · © 2026*

View File

View File

View File

54
core/.env.example Normal file
View File

@@ -0,0 +1,54 @@
# ── Meta Graph API ────────────────────────────────────────────────────────────
# Long-lived System User Access Token from Meta Business Manager
# Business Settings → System Users → Generate Token
META_ACCESS_TOKEN=PLACEHOLDER_your_meta_system_user_token
# Ad Account ID — format: act_XXXXXXXXXX
# Meta Business Manager → Ad Accounts
META_AD_ACCOUNT_ID=PLACEHOLDER_act_1234567890
# Business Portfolio ID
# Meta Business Settings → Business Info → Business ID
META_BUSINESS_ID=PLACEHOLDER_1234567890
# App ID & Secret — from Meta Developers → Your App → Basic Settings
META_APP_ID=PLACEHOLDER_9876543210
META_APP_SECRET=PLACEHOLDER_your_app_secret
# API Version (use latest stable)
META_API_VERSION=v21.0
# ── Supabase (CRM) ────────────────────────────────────────────────────────────
# Project URL from Supabase Dashboard → Settings → API
SUPABASE_URL=PLACEHOLDER_https://xxxxxxxxxxx.supabase.co
# Anon/Public key (for server-side reads)
SUPABASE_ANON_KEY=PLACEHOLDER_your_supabase_anon_key
# Service Role key (for elevated writes — keep secret!)
SUPABASE_SERVICE_ROLE_KEY=PLACEHOLDER_your_supabase_service_role_key
# ── ComfyUI ───────────────────────────────────────────────────────────────────
# Base URL of ComfyUI server running locally or on GPU node
COMFY_BASE_URL=http://localhost:8188
# —— Shared Desineuron coding / Oracle / NemoClaw runtime —————————————————————
# Stable OpenAI-compatible SGLang route rendered through ingress.
LLM_BASE_URL=https://llm.desineuron.in
SGLANG_BASE_URL=https://llm.desineuron.in
SGLANG_CHAT_URL=https://llm.desineuron.in/v1/chat/completions
SGLANG_MODELS_URL=https://llm.desineuron.in/v1/models
SGLANG_MODEL=qwen3.6:35b-a3b
SGLANG_API_TOKEN=
# NemoClaw follows the same routed SGLang runtime.
NEMOCLAW_BASE_URL=https://llm.desineuron.in
NEMOCLAW_MODEL=qwen3.6:35b-a3b
NEMOCLAW_API_TOKEN=
# ── Backend ───────────────────────────────────────────────────────────────────
# CORS origins — comma-separated list of allowed frontend origins
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Secret key for signing internal JWTs/sessions
SECRET_KEY=PLACEHOLDER_generate_with_openssl_rand_hex_32

64
core/Dockerfile Normal file
View File

@@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1.4
# ============================================================
# Velocity-OS — core (FastAPI Backend)
# Immutable, signed, multi-stage production image.
# ============================================================
# ── Stage 1: Dependency builder ──────────────────────────────
FROM python:3.11-slim AS builder
WORKDIR /build
# Install system deps required for some Python packages
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user --no-warn-script-location -r requirements.txt
# ── Stage 2: Production runtime ──────────────────────────────
FROM python:3.11-slim AS runtime
LABEL org.opencontainers.image.title="velocity-os-core" \
org.opencontainers.image.description="Velocity-OS FastAPI Neural Core" \
org.opencontainers.image.vendor="Desineuron" \
org.opencontainers.image.version="2.0.0"
# Runtime system deps only
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for security
RUN groupadd -r velocity && useradd -r -g velocity velocity
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /root/.local /home/velocity/.local
COPY --chown=velocity:velocity . .
ENV PATH=/home/velocity/.local/bin:$PATH \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
USER velocity
EXPOSE 8443
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8443/health || exit 1
# 2 workers for RTX 6000 Blackwell workstation (adjust via K3s env override)
CMD ["uvicorn", "main:app", \
"--host", "0.0.0.0", \
"--port", "8443", \
"--workers", "2", \
"--log-level", "info", \
"--access-log"]

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@@ -0,0 +1 @@
"""backend.auth package"""

View File

@@ -0,0 +1,153 @@
"""
backend/auth/dependencies.py — FastAPI RBAC Dependency Injection
Provides:
- get_current_user: decodes JWT and returns UserPrincipal
- require_role(min_role): raises HTTP 403 if user role is insufficient
Role hierarchy (ascending):
JUNIOR_BROKER < SENIOR_BROKER < SALES_DIRECTOR < ADMIN
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, status
from jose import JWTError, jwt
from passlib.context import CryptContext
# ── Role hierarchy ────────────────────────────────────────────────────────────
ROLE_HIERARCHY = {
"JUNIOR_BROKER": 0,
"SENIOR_BROKER": 1,
"SALES_DIRECTOR": 2,
"ADMIN": 3,
}
def default_tenant_id() -> str:
return os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity").strip() or "tenant_velocity"
# ── Password hashing ──────────────────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _truncate_bcrypt_input(value: str) -> str:
raw = value.encode("utf-8")
if len(raw) <= 72:
return value
return raw[:72].decode("utf-8", errors="ignore")
def hash_password(plain: str) -> str:
return pwd_context.hash(_truncate_bcrypt_input(plain))
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(_truncate_bcrypt_input(plain), hashed)
# ── JWT helpers ───────────────────────────────────────────────────────────────
# Secret and algorithm retrieved from environment — never hardcoded.
JWT_SECRET = os.environ["VELOCITY_JWT_SECRET"]
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 8
def create_access_token(user_id: str, role: str, tenant_id: Optional[str] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
normalized_role = role.strip().upper()
normalized_tenant = (tenant_id or default_tenant_id()).strip() or default_tenant_id()
payload = {
"sub": user_id,
"role": normalized_role,
"tenant_id": normalized_tenant,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
# ── UserPrincipal dataclass ───────────────────────────────────────────────────
@dataclass
class UserPrincipal:
user_id: str
role: str
tenant_id: str = default_tenant_id()
@property
def role_level(self) -> int:
return ROLE_HIERARCHY.get(self.role.upper(), -1)
# ── Dependency: parse bearer token ────────────────────────────────────────────
def get_current_user(
authorization: Optional[str] = Header(default=None),
) -> UserPrincipal:
"""
Extracts and validates a JWT from the Authorization: Bearer <token> header.
Raises HTTP 401 on missing/invalid token.
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or malformed Authorization header.",
headers={"WWW-Authenticate": "Bearer"},
)
token = authorization.split(" ", 1)[1]
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
options={"require": ["sub", "role", "exp"]},
)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
return UserPrincipal(
user_id=payload["sub"],
role=str(payload["role"]).strip().upper(),
tenant_id=str(payload.get("tenant_id") or default_tenant_id()).strip() or default_tenant_id(),
)
# ── Dependency factory: role gate ─────────────────────────────────────────────
def require_role(minimum_role: str):
"""
Returns a FastAPI dependency that raises HTTP 403 if the authenticated
user's role is below `minimum_role` in the hierarchy.
Usage:
@router.get("/protected")
async def protected(user: UserPrincipal = Depends(require_role("SENIOR_BROKER"))):
...
"""
min_level = ROLE_HIERARCHY.get(minimum_role)
if min_level is None:
raise ValueError(f"Unknown role: {minimum_role}")
def _check(user: UserPrincipal = Depends(get_current_user)) -> UserPrincipal:
if user.role_level < min_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient role. Required: {minimum_role}, current: {user.role}.",
)
return user
return _check

105
core/auth/auth/routes.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.auth.service import (
list_tenant_users,
login_with_directory,
read_authenticated_user_profile,
)
from backend.auth.user_directory import ensure_user_directory_schema
router = APIRouter()
ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
def _sanitize_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
return cleaned or "upload"
class LoginRequest(BaseModel):
email: str
password: str
@router.post("/api/auth/login", tags=["Auth"])
async def login(body: LoginRequest, request: Request):
"""
Authenticate a user and return a JWT.
Credentials are verified against the users_and_roles table.
"""
return await login_with_directory(
app=request.app,
email=body.email,
password=body.password,
)
@router.get("/api/auth/me", tags=["Auth"])
async def me(request: Request, user: UserPrincipal = Depends(get_current_user)):
return await read_authenticated_user_profile(app=request.app, user=user)
@router.get("/api/auth/users", tags=["Auth"])
async def list_auth_users(request: Request, user: UserPrincipal = Depends(get_current_user)):
return await list_tenant_users(app=request.app, user=user)
@router.post("/api/auth/profile/avatar", tags=["Auth"])
async def upload_profile_avatar(
request: Request,
file: UploadFile = File(...),
user: UserPrincipal = Depends(get_current_user),
):
await ensure_user_directory_schema(request.app)
pool = getattr(request.app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
if file.content_type not in allowed:
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
extension = ".png"
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
avatar_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_"
f"{int(datetime.now(timezone.utc).timestamp())}{extension}"
)
destination = avatar_dir / filename
contents = await file.read()
destination.write_bytes(contents)
avatar_url = f"/assets/profile_avatars/{filename}"
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE users_and_roles
SET avatar_url = $2
WHERE id = $1::uuid
AND tenant_id = $3
""",
user.user_id,
avatar_url,
user.tenant_id,
)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Authenticated user profile was not found.")
return {"avatar_url": avatar_url}

123
core/auth/auth/service.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, status
from backend.auth.dependencies import (
UserPrincipal,
create_access_token,
default_tenant_id,
verify_password,
)
from backend.auth.user_directory import ensure_user_directory_schema
async def _get_pool(app: Any):
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
async def login_with_directory(*, app: Any, email: str, password: str) -> dict[str, Any]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_fallback = default_tenant_id()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
id::text,
role,
password_hash,
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
FROM users_and_roles
WHERE email = $1 AND is_active = TRUE
""",
email.strip(),
tenant_fallback,
)
if not row or not verify_password(password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password.",
)
token = create_access_token(
user_id=row["id"],
role=row["role"],
tenant_id=row["tenant_id"],
)
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
async def read_authenticated_user_profile(*, app: Any, user: UserPrincipal) -> dict[str, Any]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_scope = user.tenant_id or default_tenant_id()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
full_name,
email,
avatar_url,
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
FROM users_and_roles
WHERE id = $1::uuid
AND COALESCE(NULLIF(tenant_id, ''), $2) = $2
""",
user.user_id,
tenant_scope,
)
return {
"user_id": user.user_id,
"role": user.role,
"tenant_id": row["tenant_id"] if row else tenant_scope,
"full_name": row["full_name"] if row else None,
"email": row["email"] if row else None,
"avatar_url": row["avatar_url"] if row else None,
}
async def list_tenant_users(*, app: Any, user: UserPrincipal) -> list[dict[str, Any]]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_scope = user.tenant_id or default_tenant_id()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
id::text AS user_id,
role,
COALESCE(NULLIF(tenant_id, ''), $1) AS tenant_id,
full_name,
email,
avatar_url
FROM users_and_roles
WHERE is_active = TRUE
AND COALESCE(NULLIF(tenant_id, ''), $1) = $2
ORDER BY
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
""",
default_tenant_id(),
tenant_scope,
)
return [
{
"user_id": row["user_id"],
"role": row["role"],
"tenant_id": row["tenant_id"],
"full_name": row["full_name"],
"email": row["email"],
"avatar_url": row["avatar_url"],
}
for row in rows
]

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from backend.auth.dependencies import default_tenant_id
_AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY = "_auth_user_directory_schema_ready"
def _sql_text_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
async def ensure_user_directory_schema(app: Any) -> None:
if getattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, False):
return
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
tenant_fallback = default_tenant_id()
tenant_default_literal = _sql_text_literal(tenant_fallback)
async with pool.acquire() as conn:
await conn.execute("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT")
await conn.execute(
"""
UPDATE users_and_roles
SET tenant_id = $1
WHERE tenant_id IS NULL OR tenant_id = ''
""",
tenant_fallback,
)
await conn.execute(
f"ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
)
await conn.execute("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"
)
setattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, True)

1
core/db/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""backend.db package"""

58
core/db/db/pool.py Normal file
View File

@@ -0,0 +1,58 @@
"""
backend/db/pool.py — asyncpg Connection Pool
Initialises a PostgreSQL connection pool from environment variables.
All credentials are sourced from the environment only — never hardcoded.
Environment variables required:
VELOCITY_DB_HOST PostgreSQL host (default: localhost)
VELOCITY_DB_PORT PostgreSQL port (default: 5432)
VELOCITY_DB_NAME Database name
VELOCITY_DB_USER Database user
VELOCITY_DB_PASSWORD Database password (injected from AWS SSM at service start)
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import asyncpg
from fastapi import Request
_pool: asyncpg.Pool | None = None
async def create_pool() -> asyncpg.Pool:
"""Creates and returns the application-wide asyncpg connection pool."""
global _pool
_pool = await asyncpg.create_pool(
host=os.environ.get("VELOCITY_DB_HOST", "localhost"),
port=int(os.environ.get("VELOCITY_DB_PORT", "5432")),
database=os.environ["VELOCITY_DB_NAME"],
user=os.environ["VELOCITY_DB_USER"],
password=os.environ["VELOCITY_DB_PASSWORD"],
min_size=2,
max_size=10,
command_timeout=30,
# Set app_name for easier identification in pg_stat_activity
server_settings={"application_name": "velocity-backend"},
)
return _pool
async def close_pool() -> None:
"""Closes the connection pool on application shutdown."""
global _pool
if _pool:
await _pool.close()
_pool = None
def get_pool(request: Request) -> asyncpg.Pool:
"""FastAPI dependency: returns the pool stored in app.state."""
pool: asyncpg.Pool = request.app.state.db_pool
if pool is None:
raise RuntimeError("Database pool is not initialised.")
return pool

181
core/db/db/schema.sql Normal file
View File

@@ -0,0 +1,181 @@
-- backend/db/schema.sql - Velocity PostgreSQL schema
-- Omnichannel Intelligence Graph for The Sentinel.
--
-- Run via:
-- psql -U velocity_user -d velocity_db -f schema.sql
--
-- Or via Alembic (preferred - see backend/alembic/).
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ────────────────────────────────────────────────────────────────────────────
-- ENUM TYPES
-- ────────────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE role_enum AS ENUM (
'ADMIN',
'SALES_DIRECTOR',
'SENIOR_BROKER',
'JUNIOR_BROKER'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE source_enum AS ENUM ('whatsapp', 'website', 'walkin');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE lead_status_enum AS ENUM ('new', 'engaged', 'qualified', 'hot', 'closed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE qualification_enum AS ENUM ('whale', 'potential', 'tire_kicker');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE log_event_enum AS ENUM (
'CALL_LOGGED',
'ASSET_VIEWED',
'SENTIMENT_SPIKE',
'QD_UPDATED',
'LEAD_TAGGED',
'WS_ASSET_OPENED'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: users_and_roles (Manual RBAC backbone)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users_and_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role role_enum NOT NULL DEFAULT 'JUNIOR_BROKER',
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
full_name TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for login lookups
CREATE INDEX IF NOT EXISTS idx_users_email ON users_and_roles (email);
CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: leads_intelligence (CRM core with QD scoring)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS leads_intelligence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source source_enum NOT NULL,
status lead_status_enum NOT NULL DEFAULT 'new',
qualification qualification_enum,
budget TEXT,
interest TEXT,
-- Quantum Dynamics Score: 1-100, updated in real-time by NemoClaw
quantum_dynamics_score INTEGER CHECK (quantum_dynamics_score BETWEEN 1 AND 100),
-- Polymorphic CRM intelligence tags e.g. ['HNI', 'NRI', 'Hot Lead']
tags TEXT[] NOT NULL DEFAULT '{}',
assigned_to UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
last_message TEXT,
last_active TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-update updated_at on every modification
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_leads_updated_at ON leads_intelligence;
CREATE TRIGGER trg_leads_updated_at
BEFORE UPDATE ON leads_intelligence
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads_intelligence (status);
CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads_intelligence (assigned_to);
-- GIN index for efficient tag array queries (e.g. WHERE 'HNI' = ANY(tags))
CREATE INDEX IF NOT EXISTS idx_leads_tags ON leads_intelligence USING GIN (tags);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: velocity_vault_assets (File Tracking Engine)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS velocity_vault_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_name TEXT NOT NULL,
asset_type TEXT NOT NULL, -- 'pdf', 'image', 'video'
storage_path TEXT NOT NULL, -- relative to /opt/dlami/nvme/assets/
-- Unique cryptographic string for every share instance
tracking_hash VARCHAR(64) UNIQUE NOT NULL,
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
created_by UUID REFERENCES users_and_roles(id),
-- Array of open timestamps; one entry appended per distinct open event
opened_at TIMESTAMPTZ[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vault_hash ON velocity_vault_assets (tracking_hash);
CREATE INDEX IF NOT EXISTS idx_vault_lead ON velocity_vault_assets (lead_id);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: omnichannel_logs (Polymorphic event ingestion + sentimental history)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS omnichannel_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type log_event_enum NOT NULL,
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
-- JSONB payload — schema varies by event_type:
-- SENTIMENT_SPIKE: {blend_shapes, qd_before, qd_after}
-- WS_ASSET_OPENED: {ip, user_agent, tracking_hash}
-- QD_UPDATED: {qd_score, reasoning, confidence}
-- LEAD_TAGGED: {tags_added, tags_removed}
payload JSONB NOT NULL DEFAULT '{}',
-- For MediaPipe-correlated entries: exact ms offset in the stimulus video
video_timestamp_ms BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Composite index supporting time-series sentiment history queries
CREATE INDEX IF NOT EXISTS idx_logs_lead_type_time
ON omnichannel_logs (lead_id, event_type, created_at DESC);
-- Partial index for fast SENTIMENT_SPIKE lookups
CREATE INDEX IF NOT EXISTS idx_logs_sentiment_spikes
ON omnichannel_logs (lead_id, created_at DESC)
WHERE event_type = 'SENTIMENT_SPIKE';
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: consent_log (GDPR biometric consent tracking)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS consent_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT,
-- 'granted' or 'revoked'
action TEXT NOT NULL DEFAULT 'granted'
);
CREATE INDEX IF NOT EXISTS idx_consent_lead ON consent_log (lead_id, consented_at DESC);

View File

@@ -0,0 +1,85 @@
-- ────────────────────────────────────────────────────────────────────────────
-- Addendum: Video Scene Maps (video_timestamp → room label mapping)
-- Appended to schema.sql for Sprint 1 milestone.
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: video_scene_maps
-- Stores the timestamp-to-room mapping for each marketing video.
-- Uploaded once per inventory item (CSV parsed and inserted by the API).
-- Format: scene_no, start_ms, end_ms, room_type, description
-- This allows NemoClaw to correlate a biometric reaction at T=45000ms with
-- "Master Bedroom" for contextual QD scoring.
CREATE TABLE IF NOT EXISTS video_scene_maps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_asset_id TEXT NOT NULL, -- Matches inventory item slug / asset filename
scene_no INTEGER NOT NULL,
start_ms BIGINT NOT NULL,
end_ms BIGINT NOT NULL,
room_type TEXT NOT NULL, -- e.g. 'Living Room', 'Master Bedroom', 'Balcony'
description TEXT, -- Optional: 'Ocean-facing balcony with pool view'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (video_asset_id, scene_no)
);
CREATE INDEX IF NOT EXISTS idx_scenes_asset_range
ON video_scene_maps (video_asset_id, start_ms, end_ms);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: perception_sessions
-- Tracks each PerceptionPlayer session (assigned or auto mode).
-- Assigned Mode: lead_id is set before session starts.
-- Auto Mode : lead_id is NULL; auto_mode_matched_at populated post hoc.
-- ────────────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE session_mode_enum AS ENUM ('assigned', 'auto');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS perception_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_mode session_mode_enum NOT NULL DEFAULT 'assigned',
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE SET NULL,
video_asset_id TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
final_qd_score INTEGER CHECK (final_qd_score BETWEEN 1 AND 100),
-- For auto mode: the lead_id matched after session by face/plate recognition
auto_mode_matched_at TIMESTAMPTZ,
-- JSONB blob with auto-mode gathered data: face_hash, plate, vehicle_class, etc.
auto_mode_evidence JSONB DEFAULT '{}',
broker_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sessions_lead ON perception_sessions (lead_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_unmatched
ON perception_sessions (started_at DESC)
WHERE session_mode = 'auto' AND lead_id IS NULL;
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: cctv_events
-- Records each parking/entry visitor event from CCTV feeds.
-- License plates, vehicle class, NemoClaw wealth indicator.
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cctv_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zone TEXT NOT NULL, -- 'Parking Entry', 'Main Gate', 'Zone A', etc.
license_plate TEXT, -- Raw OCR text
vehicle_class TEXT, -- 'luxury' | 'standard' | 'unknown'
wealth_indicator TEXT, -- 'HNI' | 'standard' | 'unknown'
nemoclaw_tags TEXT[] NOT NULL DEFAULT '{}',
nemoclaw_notes TEXT,
linked_lead_id UUID REFERENCES leads_intelligence(id) ON DELETE SET NULL,
linked_session_id UUID REFERENCES perception_sessions(id) ON DELETE SET NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_payload JSONB NOT NULL DEFAULT '{}' -- Full CCTV frame metadata
);
CREATE INDEX IF NOT EXISTS idx_cctv_plate ON cctv_events (license_plate, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_cctv_zone ON cctv_events (zone, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_cctv_unlinked
ON cctv_events (captured_at DESC)
WHERE linked_lead_id IS NULL;

100
core/db/db/schema_comms.sql Normal file
View File

@@ -0,0 +1,100 @@
-- Velocity Comms Schema
-- Run this migration against your asyncpg pool database.
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Threads (conversations)
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 ON comms_threads(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);
-- Messages
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;
-- Call logs
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;
-- Settings (key-value JSON)
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 default settings
INSERT INTO comms_settings (key, value_json) VALUES ('config', '{
"provider": "mock",
"provider_base_url": "",
"provider_api_key": "",
"instance_id": "",
"phone_number_id": "",
"webhook_callback_url": "",
"webhook_secret_set": false,
"default_assignment_user_id": null,
"auto_link_by_phone": true,
"create_crm_interaction_on_inbound": true,
"default_country_code": "91",
"media_storage_dir": "/opt/dlami/nvme/assets/comms",
"transcription_provider": "none"
}'::jsonb) ON CONFLICT (key) DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,923 @@
-- =============================================================================
-- schema_crm_canonical.sql
-- Project Velocity — Canonical CRM and Platform Schema
-- =============================================================================
-- Covers: crm_*, intel_*, inventory_*, workflow_* canonical domains
-- as specified in Doc 09: Database Schema and Root API Spec
-- and Doc 07: Contracts and Schema Blueprint
--
-- Run AFTER schema.sql and schema_addendum.sql
-- psql -U velocity_user -d velocity_db -f schema_crm_canonical.sql
--
-- Existing tables: users_and_roles, leads_intelligence, velocity_vault_assets,
-- omnichannel_logs, consent_log, video_scene_maps,
-- perception_sessions, cctv_events, leads, chat_logs
-- These are treated as legacy feeders per the reconciliation matrix.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- ─────────────────────────────────────────────────────────────────────────────
-- ENUM TYPES — Canonical Domain
-- ─────────────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE crm_lead_status AS ENUM (
'new', 'contacted', 'qualified', 'site_visit_scheduled', 'site_visited',
'negotiation', 'booking_initiated', 'booked', 'lost', 'dormant'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE crm_opportunity_stage AS ENUM (
'prospect', 'qualified', 'proposal', 'site_visit', 'negotiation',
'booking', 'agreement', 'closed_won', 'closed_lost'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE crm_account_type AS ENUM (
'individual', 'company', 'broker', 'developer', 'referral_partner', 'nri_family'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE crm_relationship_type AS ENUM (
'spouse', 'parent', 'sibling', 'business_partner', 'broker_referral',
'co_buyer', 'family_member', 'advisor'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE intel_channel AS ENUM (
'whatsapp', 'phone', 'email', 'site_visit', 'office_meeting',
'video_call', 'cctv', 'perception_session', 'system'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE intel_call_direction AS ENUM ('inbound', 'outbound');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE wf_status AS ENUM (
'pending', 'review_required', 'approved', 'rejected', 'executed', 'failed', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE import_lifecycle AS ENUM (
'uploaded', 'parsed', 'mapped', 'proposed', 'approved', 'committed', 'failed'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 1: CRM CORE DOMAIN (crm_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: crm_people
-- Purpose: canonical person-level contact identity
CREATE TABLE IF NOT EXISTS crm_people (
person_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
full_name TEXT NOT NULL,
primary_email TEXT,
primary_phone TEXT,
secondary_phone TEXT,
linkedin_url TEXT,
city TEXT,
nationality TEXT,
buyer_type TEXT, -- high_intent, slow_burn_investor, nri, etc.
persona_labels JSONB NOT NULL DEFAULT '[]'::jsonb,
source_confidence FLOAT CHECK (source_confidence BETWEEN 0.0 AND 1.0),
-- Legacy feeder references (migration linkage)
legacy_lead_id TEXT, -- links to old leads.id
legacy_li_id UUID, -- links to leads_intelligence.id
-- Metadata
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_crm_people_email ON crm_people (primary_email);
CREATE INDEX IF NOT EXISTS idx_crm_people_phone ON crm_people (primary_phone);
CREATE INDEX IF NOT EXISTS idx_crm_people_name_trgm ON crm_people USING GIN (full_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_crm_people_buyer_type ON crm_people (buyer_type);
-- TABLE: crm_accounts
-- Purpose: company, employer, brokerage, or client organization
CREATE TABLE IF NOT EXISTS crm_accounts (
account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_name TEXT NOT NULL,
parent_account_id UUID REFERENCES crm_accounts(account_id) ON DELETE SET NULL,
account_type crm_account_type NOT NULL DEFAULT 'company',
industry TEXT,
location_ref TEXT,
website TEXT,
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_crm_accounts_name ON crm_accounts (account_name);
CREATE INDEX IF NOT EXISTS idx_crm_accounts_type ON crm_accounts (account_type);
-- TABLE: crm_households
-- Purpose: family or co-buyer unit grouping
CREATE TABLE IF NOT EXISTS crm_households (
household_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_name TEXT NOT NULL,
primary_person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
notes TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- TABLE: crm_relationships
-- Purpose: person-to-person relationship graph
CREATE TABLE IF NOT EXISTS crm_relationships (
relationship_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_a_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
person_b_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
relationship_type crm_relationship_type NOT NULL,
household_id UUID REFERENCES crm_households(household_id) ON DELETE SET NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (person_a_id, person_b_id, relationship_type)
);
CREATE INDEX IF NOT EXISTS idx_crm_rel_a ON crm_relationships (person_a_id);
CREATE INDEX IF NOT EXISTS idx_crm_rel_b ON crm_relationships (person_b_id);
-- TABLE: crm_leads
-- Purpose: funnel-stage commercial qualification layer
CREATE TABLE IF NOT EXISTS crm_leads (
lead_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
account_id UUID REFERENCES crm_accounts(account_id) ON DELETE SET NULL,
source_system TEXT DEFAULT 'velocity',
status crm_lead_status NOT NULL DEFAULT 'new',
budget_band TEXT,
urgency TEXT, -- low, medium, high, critical
financing_posture TEXT, -- cash, loan, nri_remittance, emi
timeline_to_decision TEXT,
objections JSONB NOT NULL DEFAULT '[]'::jsonb,
motivations JSONB NOT NULL DEFAULT '[]'::jsonb,
assigned_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
-- Legacy feeder
legacy_lead_id TEXT,
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_crm_leads_person ON crm_leads (person_id);
CREATE INDEX IF NOT EXISTS idx_crm_leads_status ON crm_leads (status);
CREATE INDEX IF NOT EXISTS idx_crm_leads_assigned ON crm_leads (assigned_user_id);
CREATE INDEX IF NOT EXISTS idx_crm_leads_source ON crm_leads (source_system);
-- TABLE: crm_opportunities
-- Purpose: deal pipeline objects
CREATE TABLE IF NOT EXISTS crm_opportunities (
opportunity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES crm_leads(lead_id) ON DELETE CASCADE,
project_id UUID, -- references inventory_projects
unit_id UUID, -- references inventory_units
stage crm_opportunity_stage NOT NULL DEFAULT 'prospect',
value DECIMAL(15, 2),
probability INTEGER CHECK (probability BETWEEN 0 AND 100),
expected_close_date DATE,
next_action TEXT,
notes TEXT,
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_crm_opp_lead ON crm_opportunities (lead_id);
CREATE INDEX IF NOT EXISTS idx_crm_opp_stage ON crm_opportunities (stage);
CREATE INDEX IF NOT EXISTS idx_crm_opp_project ON crm_opportunities (project_id);
-- TABLE: crm_property_interests
-- Purpose: project and unit interest linking per client
CREATE TABLE IF NOT EXISTS crm_property_interests (
interest_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
project_id UUID,
project_name TEXT NOT NULL,
unit_preference TEXT,
configuration TEXT, -- 2BHK, 3BHK, Penthouse, etc.
budget_min DECIMAL(15, 2),
budget_max DECIMAL(15, 2),
priority INTEGER DEFAULT 1, -- 1 = primary, 2 = secondary
notes TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE crm_property_interests
ADD COLUMN IF NOT EXISTS metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS idx_crm_pi_person ON crm_property_interests (person_id);
CREATE INDEX IF NOT EXISTS idx_crm_pi_project ON crm_property_interests (project_id);
-- TABLE: crm_stage_history
-- Purpose: canonical audit trail of lead stage transitions
CREATE TABLE IF NOT EXISTS crm_stage_history (
history_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES crm_leads(lead_id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
changed_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
changed_by_type TEXT DEFAULT 'human', -- human, ai, system
notes TEXT,
happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_crm_stage_lead ON crm_stage_history (lead_id, happened_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 2: INTERACTION AND EVIDENCE GRAPH (intel_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: intel_interactions
-- Purpose: umbrella interaction event record
CREATE TABLE IF NOT EXISTS intel_interactions (
interaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
channel intel_channel NOT NULL,
interaction_type TEXT NOT NULL, -- message, call, visit, email, note
happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
summary TEXT,
source_ref TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_int_person ON intel_interactions (person_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_int_lead ON intel_interactions (lead_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_int_channel ON intel_interactions (channel);
-- TABLE: intel_messages
-- Purpose: text-level message records (WhatsApp, chat)
CREATE TABLE IF NOT EXISTS intel_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID NOT NULL REFERENCES intel_interactions(interaction_id) ON DELETE CASCADE,
thread_id UUID,
sender_role TEXT NOT NULL, -- lead, broker, system, oracle
sender_name TEXT,
message_text TEXT NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_msg_interaction ON intel_messages (interaction_id, delivered_at DESC);
-- TABLE: intel_whatsapp_threads
-- Purpose: WhatsApp thread-level summaries
CREATE TABLE IF NOT EXISTS intel_whatsapp_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
phone_number TEXT,
thread_summary TEXT,
message_count INTEGER DEFAULT 0,
last_message_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_wa_person ON intel_whatsapp_threads (person_id);
-- TABLE: intel_calls
-- Purpose: voice call records
CREATE TABLE IF NOT EXISTS intel_calls (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID NOT NULL REFERENCES intel_interactions(interaction_id) ON DELETE CASCADE,
call_direction intel_call_direction NOT NULL DEFAULT 'outbound',
duration_seconds INTEGER,
recording_ref TEXT, -- storage path or URL to recording
transcript_ref TEXT, -- path to transcript JSON
call_outcome TEXT, -- connected, no_answer, voicemail, dropped
called_number TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_call_interaction ON intel_calls (interaction_id);
-- TABLE: intel_transcripts
-- Purpose: transcript and speaker segmentation storage
CREATE TABLE IF NOT EXISTS intel_transcripts (
transcript_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
call_id UUID REFERENCES intel_calls(call_id) ON DELETE SET NULL,
interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
language TEXT DEFAULT 'en',
full_text TEXT,
speaker_segments_json JSONB NOT NULL DEFAULT '[]'::jsonb,
confidence FLOAT CHECK (confidence BETWEEN 0.0 AND 1.0),
word_count INTEGER,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_transcript_call ON intel_transcripts (call_id);
CREATE INDEX IF NOT EXISTS idx_intel_transcript_interaction ON intel_transcripts (interaction_id);
-- TABLE: intel_emails
-- Purpose: email thread records
CREATE TABLE IF NOT EXISTS intel_emails (
email_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID NOT NULL REFERENCES intel_interactions(interaction_id) ON DELETE CASCADE,
from_address TEXT,
to_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
subject TEXT,
body_text TEXT,
has_attachments BOOLEAN DEFAULT FALSE,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_email_interaction ON intel_emails (interaction_id);
-- TABLE: intel_visits
-- Purpose: site visit and meeting records
CREATE TABLE IF NOT EXISTS intel_visits (
visit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
project_id UUID,
project_name TEXT,
unit_id UUID,
visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
visit_notes TEXT,
host_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
revisit_intent TEXT, -- very_likely, likely, uncertain, unlikely
cctv_session_ref TEXT,
perception_session_ref TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_visits_person ON intel_visits (person_id, visited_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_visits_project ON intel_visits (project_id);
-- TABLE: intel_reminders
-- Purpose: reminders and follow-up task chains
CREATE TABLE IF NOT EXISTS intel_reminders (
reminder_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
opportunity_id UUID REFERENCES crm_opportunities(opportunity_id) ON DELETE SET NULL,
reminder_type TEXT NOT NULL, -- call_back, follow_up, site_visit, document, negotiation
title TEXT NOT NULL,
notes TEXT,
due_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending', -- pending, done, snoozed, cancelled
assigned_to UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
created_by_type TEXT DEFAULT 'human', -- human, ai, system
priority TEXT DEFAULT 'normal', -- low, normal, high, urgent
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_reminder_person ON intel_reminders (person_id, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_reminder_status ON intel_reminders (status, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_reminder_assigned ON intel_reminders (assigned_to, due_at);
-- TABLE: intel_qd_scores
-- Purpose: latest meaningful QD summary by client
CREATE TABLE IF NOT EXISTS intel_qd_scores (
qd_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
score_type TEXT NOT NULL, -- intent_score, urgency_score, engagement_score
current_value FLOAT NOT NULL CHECK (current_value BETWEEN 0.0 AND 1.0),
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
evidence_refs_json JSONB NOT NULL DEFAULT '[]'::jsonb,
reasoning TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (person_id, score_type)
);
CREATE INDEX IF NOT EXISTS idx_intel_qd_person ON intel_qd_scores (person_id);
-- TABLE: intel_qd_timeseries
-- Purpose: time-series QD propagation and shifts
CREATE TABLE IF NOT EXISTS intel_qd_timeseries (
timeseries_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
score_type TEXT NOT NULL,
signal_source TEXT,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
value FLOAT NOT NULL CHECK (value BETWEEN 0.0 AND 1.0),
delta FLOAT,
evidence_ref TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_qd_ts_person ON intel_qd_timeseries (person_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_intel_qd_ts_type ON intel_qd_timeseries (score_type, timestamp DESC);
-- TABLE: intel_vehicle_events
-- Purpose: number-plate and vehicle detection events
CREATE TABLE IF NOT EXISTS intel_vehicle_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
visit_id UUID REFERENCES intel_visits(visit_id) ON DELETE SET NULL,
zone TEXT,
license_plate_hash TEXT, -- hashed for privacy
vehicle_class TEXT, -- luxury, standard, unknown
wealth_indicator TEXT, -- HNI, standard, unknown
cctv_ref TEXT,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_vehicle_person ON intel_vehicle_events (person_id);
-- TABLE: intel_perception_events
-- Purpose: behavioral and dwell-time intelligence from perception sessions
CREATE TABLE IF NOT EXISTS intel_perception_events (
perception_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
visit_id UUID REFERENCES intel_visits(visit_id) ON DELETE SET NULL,
session_ref TEXT, -- perception_sessions.id linkage
event_type TEXT NOT NULL, -- room_dwell, engagement_spike, exit
rooms_visited JSONB NOT NULL DEFAULT '[]'::jsonb,
dwell_time_seconds INTEGER,
engagement_score FLOAT CHECK (engagement_score BETWEEN 0.0 AND 1.0),
camera_id TEXT,
media_ref TEXT,
happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_perception_person ON intel_perception_events (person_id);
-- TABLE: intel_cctv_links
-- Purpose: CCTV evidence references linked to client/visit contexts
CREATE TABLE IF NOT EXISTS intel_cctv_links (
link_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
visit_id UUID REFERENCES intel_visits(visit_id) ON DELETE SET NULL,
cctv_event_id UUID REFERENCES cctv_events(id) ON DELETE SET NULL,
clip_ref TEXT,
camera_zone TEXT,
confidence FLOAT CHECK (confidence BETWEEN 0.0 AND 1.0),
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_cctv_person ON intel_cctv_links (person_id);
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 3: INVENTORY DOMAIN (inventory_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: inventory_projects
-- Purpose: project-level inventory master
CREATE TABLE IF NOT EXISTS inventory_projects (
project_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_name TEXT NOT NULL UNIQUE,
developer_name TEXT NOT NULL,
city TEXT NOT NULL DEFAULT 'Kolkata',
micro_market TEXT,
address TEXT,
total_units INTEGER,
rera_number TEXT,
project_status TEXT DEFAULT 'active', -- active, sold_out, upcoming
launch_date DATE,
possession_date DATE,
location_json JSONB NOT NULL DEFAULT '{}'::jsonb,
amenities_json JSONB NOT NULL DEFAULT '[]'::jsonb,
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_inv_projects_name ON inventory_projects (project_name);
CREATE INDEX IF NOT EXISTS idx_inv_projects_market ON inventory_projects (micro_market);
-- TABLE: inventory_units
-- Purpose: unit-level availability and attributes
CREATE TABLE IF NOT EXISTS inventory_units (
unit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES inventory_projects(project_id) ON DELETE CASCADE,
unit_label TEXT NOT NULL,
configuration TEXT NOT NULL, -- 2BHK, 3BHK, Penthouse, etc.
area_sqft DECIMAL(10, 2),
price_current DECIMAL(15, 2),
price_psf DECIMAL(10, 2),
status TEXT NOT NULL DEFAULT 'available', -- available, reserved, sold, hold
floor INTEGER,
tower TEXT,
facing TEXT,
has_attached_amenities JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, unit_label)
);
CREATE INDEX IF NOT EXISTS idx_inv_units_project ON inventory_units (project_id);
CREATE INDEX IF NOT EXISTS idx_inv_units_status ON inventory_units (status);
CREATE INDEX IF NOT EXISTS idx_inv_units_config ON inventory_units (configuration);
-- TABLE: inventory_import_jobs
-- Purpose: track inventory CSV import operations
CREATE TABLE IF NOT EXISTS inventory_import_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES inventory_projects(project_id) ON DELETE SET NULL,
filename TEXT NOT NULL,
row_count INTEGER,
imported_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
errors_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 4: AI WORKFLOW AND GOVERNANCE (workflow_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: workflow_actions
-- Purpose: track proposed AI/human actions before approval
CREATE TABLE IF NOT EXISTS workflow_actions (
action_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_type TEXT NOT NULL, -- import_review, merge_proposal, writeback, enrichment
target_domain TEXT NOT NULL, -- crm, intel, inventory
target_entity_ref TEXT,
proposal_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
reasoning_summary TEXT,
evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
confidence FLOAT CHECK (confidence BETWEEN 0.0 AND 1.0),
status wf_status NOT NULL DEFAULT 'pending',
approval_required BOOLEAN NOT NULL DEFAULT TRUE,
created_by_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_actions_status ON workflow_actions (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_actions_domain ON workflow_actions (target_domain);
-- TABLE: workflow_approvals
-- Purpose: explicit human review decisions
CREATE TABLE IF NOT EXISTS workflow_approvals (
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_id UUID NOT NULL REFERENCES workflow_actions(action_id) ON DELETE CASCADE,
reviewer_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
decision TEXT NOT NULL, -- approved, rejected, needs_more_info
decision_notes TEXT,
decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_approvals_action ON workflow_approvals (action_id);
-- TABLE: workflow_writebacks
-- Purpose: track AI-suggested and approved canonical mutations
CREATE TABLE IF NOT EXISTS workflow_writebacks (
writeback_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_id UUID REFERENCES workflow_actions(action_id) ON DELETE SET NULL,
approval_id UUID REFERENCES workflow_approvals(decision_id) ON DELETE SET NULL,
target_domain TEXT NOT NULL,
target_entity_ref TEXT NOT NULL,
change_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status wf_status NOT NULL DEFAULT 'pending',
approved_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
executed_at TIMESTAMPTZ,
error_detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_wb_status ON workflow_writebacks (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_wb_domain ON workflow_writebacks (target_domain);
-- TABLE: workflow_import_batches
-- Purpose: CRM import batch lifecycle tracking (RawImportBatch contract)
CREATE TABLE IF NOT EXISTS workflow_import_batches (
batch_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_system TEXT NOT NULL, -- csv_upload, salesforce, hubspot, manual
uploaded_filename TEXT,
mime_type TEXT DEFAULT 'text/csv',
storage_ref TEXT,
row_count INTEGER,
mapped_count INTEGER DEFAULT 0,
unresolved_count INTEGER DEFAULT 0,
canonical_count INTEGER DEFAULT 0,
uploaded_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
lifecycle import_lifecycle NOT NULL DEFAULT 'uploaded',
mapping_manifest JSONB NOT NULL DEFAULT '{}'::jsonb,
errors_json JSONB NOT NULL DEFAULT '[]'::jsonb,
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_wf_import_lifecycle ON workflow_import_batches (lifecycle, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_import_user ON workflow_import_batches (uploaded_by);
-- TABLE: workflow_agent_runs
-- Purpose: track NemoClaw and AI agent invocation logs
CREATE TABLE IF NOT EXISTS workflow_agent_runs (
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_name TEXT NOT NULL, -- nemoclaw, import_mapper, enrichment_engine
trigger_type TEXT NOT NULL, -- import, enrichment, qd_update, writeback
trigger_ref TEXT,
input_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
output_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'running', -- running, completed, failed
duration_ms INTEGER,
error_detail TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_agent ON workflow_agent_runs (agent_name, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_status ON workflow_agent_runs (status);
-- ─────────────────────────────────────────────────────────────────────────────
-- TENANT HARDENING FOR SHARED CRM SURFACES
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE crm_people ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_accounts ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_property_interests ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_interactions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_reminders ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_qd_scores ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_qd_timeseries ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_actions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_approvals ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_import_batches ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type);
CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_wf_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- TRIGGERS: auto-update updated_at
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_canonical_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DO $$ DECLARE
t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY[
'crm_people', 'crm_accounts', 'crm_leads', 'crm_opportunities',
'inventory_projects', 'inventory_units',
'workflow_actions', 'workflow_import_batches'
] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_%s_updated_at ON %s;
CREATE TRIGGER trg_%s_updated_at
BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE FUNCTION set_canonical_updated_at();',
t, t, t, t
);
END LOOP;
END $$;
-- ─────────────────────────────────────────────────────────────────────────────
-- INVENTORY SEED: 14 Canonical Kolkata Projects
-- ─────────────────────────────────────────────────────────────────────────────
INSERT INTO inventory_projects (project_id, project_name, developer_name, city, micro_market)
VALUES
(gen_random_uuid(), 'Eden Devprayag', 'Eden Group', 'Kolkata', 'Rajarhat'),
(gen_random_uuid(), 'Sugam Prakriti', 'Sugam Homes', 'Kolkata', 'Barasat'),
(gen_random_uuid(), 'Atri Aqua', 'Atri Developers', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'Atri Surya Toron', 'Atri Developers', 'Kolkata', 'Rajarhat'),
(gen_random_uuid(), 'Siddha Suburbia Bungalow', 'Siddha Group', 'Kolkata', 'Madanpur'),
(gen_random_uuid(), 'Merlin Avana', 'Merlin Group', 'Kolkata', 'Tangra'),
(gen_random_uuid(), 'DTC Good Earth', 'DTC Projects', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'Siddha Serena', 'Siddha Group', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'Siddha Sky Waterfront', 'Siddha Group', 'Kolkata', 'Beliaghata'),
(gen_random_uuid(), 'Godrej Blue', 'Godrej Properties', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'DTC Sojon', 'DTC Projects', 'Kolkata', 'Rajarhat'),
(gen_random_uuid(), 'Shriram Grand City', 'Shriram Properties', 'Kolkata', 'Howrah'),
(gen_random_uuid(), 'Godrej Elevate', 'Godrej Properties', 'Kolkata', 'Dum Dum'),
(gen_random_uuid(), 'Ambuja Utpaala', 'Ambuja Neotia', 'Kolkata', 'Tollygunge')
ON CONFLICT (project_name) DO NOTHING;
-- ─────────────────────────────────────────────────────────────────────────────
-- COMMENTS
-- ─────────────────────────────────────────────────────────────────────────────
COMMENT ON TABLE crm_people IS 'Canonical person-level contact identity. Primary join key across all CRM tables.';
COMMENT ON TABLE crm_leads IS 'Funnel-stage commercial qualification. One person may have multiple lead contexts.';
COMMENT ON TABLE crm_opportunities IS 'Deal pipeline objects linked to leads and inventory.';
COMMENT ON TABLE intel_interactions IS 'Umbrella interaction event. All channels (WhatsApp, call, email, visit) link here.';
COMMENT ON TABLE intel_transcripts IS 'Speaker-segmented call transcripts. speaker_segments_json is first-class data.';
COMMENT ON TABLE intel_qd_scores IS 'Latest QD summary by score_type per client. UNIQUE constraint enforces one row per type.';
COMMENT ON TABLE inventory_projects IS 'Master project catalog. 14 canonical Kolkata projects seeded.';
COMMENT ON TABLE workflow_import_batches IS 'RawImportBatch contract. Immutable after upload.';
COMMENT ON TABLE workflow_writebacks IS 'AI-proposed canonical mutations. Never auto-execute without approval.';
-- -----------------------------------------------------------------------------
-- Synthetic CRM v2 enrichment columns and Oracle read models
-- -----------------------------------------------------------------------------
ALTER TABLE crm_people
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
ADD COLUMN IF NOT EXISTS communication_preference TEXT,
ADD COLUMN IF NOT EXISTS best_contact_time TEXT;
ALTER TABLE crm_households
ADD COLUMN IF NOT EXISTS size INTEGER,
ADD COLUMN IF NOT EXISTS combined_budget_band TEXT,
ADD COLUMN IF NOT EXISTS decision_maker_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL;
ALTER TABLE crm_leads
ADD COLUMN IF NOT EXISTS stage TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS broker_team TEXT,
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ;
ALTER TABLE crm_opportunities
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS deal_velocity TEXT,
ADD COLUMN IF NOT EXISTS risk_factors JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE crm_property_interests
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS last_discussed_at TIMESTAMPTZ;
ALTER TABLE crm_stage_history
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS transition_duration_days INTEGER;
ALTER TABLE intel_interactions
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS broker_team TEXT,
ADD COLUMN IF NOT EXISTS sentiment TEXT,
ADD COLUMN IF NOT EXISTS sentiment_score FLOAT,
ADD COLUMN IF NOT EXISTS intent_label TEXT,
ADD COLUMN IF NOT EXISTS emotion_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS client_engagement_level TEXT;
ALTER TABLE intel_calls
ADD COLUMN IF NOT EXISTS objection_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS outcome_summary TEXT,
ADD COLUMN IF NOT EXISTS follow_up_actions JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS call_quality_score FLOAT;
ALTER TABLE intel_emails
ADD COLUMN IF NOT EXISTS sentiment TEXT,
ADD COLUMN IF NOT EXISTS intent_label TEXT,
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS response_expected BOOLEAN;
ALTER TABLE intel_reminders
ADD COLUMN IF NOT EXISTS interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS context_snippet TEXT,
ADD COLUMN IF NOT EXISTS completion_percentage INTEGER,
ADD COLUMN IF NOT EXISTS overdue_days INTEGER,
ADD COLUMN IF NOT EXISTS outcome_notes TEXT;
ALTER TABLE intel_transcripts
ADD COLUMN IF NOT EXISTS call_outcome TEXT,
ADD COLUMN IF NOT EXISTS follow_up_required BOOLEAN,
ADD COLUMN IF NOT EXISTS emotion_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS call_summary TEXT;
ALTER TABLE intel_visits
ADD COLUMN IF NOT EXISTS outcome_type TEXT,
ADD COLUMN IF NOT EXISTS visit_duration_minutes INTEGER,
ADD COLUMN IF NOT EXISTS interest_signals JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS interest_score FLOAT,
ADD COLUMN IF NOT EXISTS companion_type TEXT,
ADD COLUMN IF NOT EXISTS companion_count INTEGER,
ADD COLUMN IF NOT EXISTS objections_raised JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS follow_up_required BOOLEAN,
ADD COLUMN IF NOT EXISTS next_steps TEXT,
ADD COLUMN IF NOT EXISTS broker_notes TEXT,
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT;
ALTER TABLE intel_whatsapp_threads
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS topic_category TEXT,
ADD COLUMN IF NOT EXISTS sentiment_direction TEXT,
ADD COLUMN IF NOT EXISTS resolution_status TEXT;
ALTER TABLE intel_qd_scores
ADD COLUMN IF NOT EXISTS score_drivers JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS trend_direction TEXT,
ADD COLUMN IF NOT EXISTS explanation TEXT,
ADD COLUMN IF NOT EXISTS confidence FLOAT;
ALTER TABLE intel_qd_timeseries
ADD COLUMN IF NOT EXISTS broker_id TEXT;
CREATE TABLE IF NOT EXISTS intel_email_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT,
first_email_at TIMESTAMPTZ,
last_email_at TIMESTAMPTZ,
email_count INTEGER DEFAULT 0,
participants JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT,
broker_id TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS intel_call_objections (
objection_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
call_id UUID REFERENCES intel_calls(call_id) ON DELETE CASCADE,
objection_type TEXT,
category TEXT,
severity TEXT,
status TEXT,
client_quote TEXT,
agent_response TEXT,
resolution_strategy TEXT,
extracted_at TIMESTAMPTZ,
confidence_score FLOAT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS intel_extracted_facts (
fact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
person_id UUID REFERENCES crm_people(person_id) ON DELETE CASCADE,
fact_type TEXT NOT NULL,
fact_value TEXT,
confidence FLOAT,
extracted_from TEXT,
source_context TEXT,
extracted_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS read_last_contacted (
person_id UUID PRIMARY KEY REFERENCES crm_people(person_id) ON DELETE CASCADE,
last_contact_at TIMESTAMPTZ,
last_channel TEXT,
last_interaction_type TEXT,
days_since_contact INTEGER,
interactions_last_7d INTEGER,
interactions_last_30d INTEGER,
interactions_last_90d INTEGER,
total_interactions INTEGER,
current_stage TEXT,
broker_id TEXT,
broker_name TEXT,
computed_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS read_next_best_action (
person_id UUID PRIMARY KEY REFERENCES crm_people(person_id) ON DELETE CASCADE,
recommended_action TEXT,
priority TEXT,
rationale TEXT,
suggested_channel TEXT,
due_within_days INTEGER,
broker_id TEXT,
broker_name TEXT,
opportunity_context TEXT,
computed_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_read_last_contacted_at ON read_last_contacted (last_contact_at DESC);
CREATE INDEX IF NOT EXISTS idx_read_next_best_priority ON read_next_best_action (priority);
CREATE INDEX IF NOT EXISTS idx_intel_facts_person ON intel_extracted_facts (person_id, extracted_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_objections_call ON intel_call_objections (call_id);

141
core/db/seed_test_users.py Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
seed_test_users.py — Velocity-OS Test Credential Seeder
========================================================
FOR TESTING ONLY. Never include in production build.
Inserts 7 test broker accounts into users_and_roles.
All passwords follow the pattern: Name@Velocity26
All emails follow: name@desineuron.in
Run against the local or remote Velocity-OS PostgreSQL:
python seed_test_users.py # uses env vars
python seed_test_users.py --dsn "postgresql://..." # explicit DSN
"""
import argparse
import hashlib
import os
import sys
# ── Generate bcrypt hashes OFFLINE (no DB dep) ────────────────────────────
# Uses passlib with same config as backend/auth/dependencies.py
try:
from passlib.context import CryptContext
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
raw = plain.encode("utf-8")
if len(raw) > 72:
plain = raw[:72].decode("utf-8", errors="ignore")
return pwd_ctx.hash(plain)
except ImportError:
print("[ERROR] passlib not installed. Run: pip install passlib[bcrypt]")
sys.exit(1)
# ── Test user definitions ─────────────────────────────────────────────────
# Format: (full_name, email, plain_password, role)
TEST_USERS = [
("Sagnik Ghosh", "sagnik@desineuron.in", "Sagnik@Velocity26", "ADMIN"),
("Sayan Ghosh", "sayan@desineuron.in", "Sayan@Velocity26", "SALES_DIRECTOR"),
("Sourik Ghosh", "sourik@desineuron.in", "Sourik@Velocity26", "SENIOR_BROKER"),
("Abantika Das", "abantika@desineuron.in", "Abantika@Velocity26", "SENIOR_BROKER"),
("Sinjini Roy", "sinjini@desineuron.in", "Sinjini@Velocity26", "JUNIOR_BROKER"),
("Swastika Ghosh", "swastika@desineuron.in", "Swastika@Velocity26", "JUNIOR_BROKER"),
("Debargha Mukherjee","debargha@desineuron.in", "Debargha@Velocity26", "JUNIOR_BROKER"),
]
TENANT_ID = "tenant_velocity"
def build_sql() -> str:
"""Generate idempotent INSERT SQL (ON CONFLICT DO NOTHING)."""
lines = [
"-- ================================================================",
"-- Velocity-OS Test Users Seed (FOR TESTING ONLY — NOT FOR PROD)",
"-- ================================================================",
"-- Generated by seed_test_users.py",
"",
"BEGIN;",
"",
]
for full_name, email, plain, role in TEST_USERS:
pw_hash = hash_password(plain)
lines.append(f"-- {full_name} ({role})")
lines.append("INSERT INTO users_and_roles")
lines.append(" (email, password_hash, role, tenant_id, full_name, is_active)")
lines.append("VALUES")
lines.append(f" ('{email}', '{pw_hash}', '{role}', '{TENANT_ID}', '{full_name}', TRUE)")
lines.append("ON CONFLICT (email) DO UPDATE")
lines.append(" SET password_hash = EXCLUDED.password_hash,")
lines.append(" role = EXCLUDED.role,")
lines.append(" full_name = EXCLUDED.full_name,")
lines.append(" is_active = TRUE;")
lines.append("")
lines.append("COMMIT;")
lines.append("")
lines.append("-- Verify:")
lines.append("SELECT email, role, full_name FROM users_and_roles ORDER BY role DESC, email;")
return "\n".join(lines)
def run_against_db(dsn: str, sql: str) -> None:
try:
import asyncpg
import asyncio
async def _insert():
conn = await asyncpg.connect(dsn)
try:
await conn.execute(sql)
print("[OK] Test users inserted successfully.")
finally:
await conn.close()
asyncio.run(_insert())
except ImportError:
print("[WARN] asyncpg not installed — writing SQL file only.")
write_sql_file(sql)
def write_sql_file(sql: str) -> None:
out = os.path.join(os.path.dirname(__file__), "seed_test_users.sql")
with open(out, "w", encoding="utf-8") as f:
f.write(sql)
print(f"[OK] SQL written to: {out}")
print(" Apply with: psql -U velocity_user -d velocity_db -f seed_test_users.sql")
def main():
parser = argparse.ArgumentParser(description="Seed Velocity-OS test users")
parser.add_argument("--dsn", help="PostgreSQL DSN (overrides env VELOCITY_DB_DSN)")
parser.add_argument("--sql-only", action="store_true", help="Only write SQL file, don't connect")
args = parser.parse_args()
sql = build_sql()
print("Generating bcrypt hashes for test users...")
print("Users to seed:")
for full_name, email, plain, role in TEST_USERS:
print(f" [{role:16}] {email:30} / {plain}")
print()
if args.sql_only:
write_sql_file(sql)
return
dsn = args.dsn or os.getenv("VELOCITY_DB_DSN") or os.getenv("DATABASE_URL")
if dsn:
run_against_db(dsn, sql)
else:
print("[INFO] No DSN provided — writing SQL file for manual application.")
write_sql_file(sql)
if __name__ == "__main__":
main()

278
core/main.py Normal file
View File

@@ -0,0 +1,278 @@
"""
Velocity — Unified FastAPI Backend
Covers: Catalyst (Meta Marketing), Sentinel (QD Engine), Vault (Trackable Links), Auth
GPU partitioning on AWS:
- NemoClaw / Ollama → CUDA devices 0, 1 (enforced in nemoclaw.service systemd unit)
- ComfyUI / Wan 2.2 → CUDA devices 2, 3 (enforced in comfyui.service systemd unit)
"""
import os
import json
import asyncio
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Set
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
def _load_velocity_env() -> None:
repo_root = Path(__file__).resolve().parent.parent
backend_root = repo_root / "backend"
explicit_env = os.getenv("VELOCITY_ENV_FILE", "").strip()
candidate_paths = []
if explicit_env:
candidate_paths.append(Path(explicit_env))
candidate_paths.extend(
[
backend_root / ".env",
repo_root / ".env",
]
)
loaded_any = False
seen: set[Path] = set()
for candidate in candidate_paths:
resolved = candidate.resolve()
if resolved in seen or not candidate.exists():
continue
load_dotenv(candidate, override=not loaded_any)
loaded_any = True
seen.add(resolved)
if not loaded_any:
load_dotenv()
_load_velocity_env()
from backend.api.routes_catalyst import router as catalyst_router
from backend.api.routes_crm import crm_router, analytics_router
from backend.api.routes_oracle import router as oracle_helper_router
from backend.api.routes_mobile_edge import router as mobile_edge_router
from backend.api.routes_inventory import router as inventory_router
from backend.api.routes_admin_surface import router as admin_surface_router
from backend.api.routes_oracle_templates import router as oracle_templates_router
from backend.api.routes_observability import router as observability_router
from backend.api.routes_crm_imports import router as crm_imports_router
from backend.api.routes_comms import router as comms_router
from backend.api.routes_runtime_llm import router as runtime_llm_router
from backend.auth.routes import router as auth_router
from backend.auth.user_directory import ensure_user_directory_schema
from backend.db.pool import create_pool, close_pool
from backend.migrations.runner import apply_migrations
from backend.observability import RequestObservabilityMiddleware
from backend.oracle.router_v1 import router as oracle_v1_router
from backend.routers.cctv import router as cctv_router
from backend.routers.scenes import router as scenes_router
from backend.routers.videos import router as videos_router
from backend.routers.vault import router as vault_router
from backend.routers.sentinel import router as sentinel_router, broadcast_sentinel_event
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("velocity.main")
# ── Lifespan: DB pool init / teardown ─────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
try:
app.state.db_pool = await create_pool()
logger.info("asyncpg pool created")
async with app.state.db_pool.acquire() as conn:
applied = await apply_migrations(conn)
if applied:
logger.info("Applied backend migrations: %s", ", ".join(applied))
await ensure_user_directory_schema(app)
except Exception as exc:
logger.error("Failed to create DB pool: %s", exc)
app.state.db_pool = None
app.state.broadcast_sentinel_event = broadcast_sentinel_event
yield
# Shutdown
await close_pool()
logger.info("asyncpg pool closed")
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="Velocity — Neural Core",
description="Unified backend: Catalyst, Sentinel QD Engine, Vault, Oracle, Auth.",
version="2.0.0",
lifespan=lifespan,
)
# ── CORS ──────────────────────────────────────────────────────────────────────
origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestObservabilityMiddleware)
# ── Static asset serving (Vault files) ───────────────────────────────────────
ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
if os.path.isdir(ASSET_DIR):
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
app.include_router(crm_router, prefix="/api", tags=["CRM"])
app.include_router(analytics_router, prefix="/api/analytics", tags=["Analytics"])
app.include_router(oracle_helper_router, prefix="/api/oracle", tags=["Oracle"])
app.include_router(oracle_v1_router, prefix="/api/oracle/v1", tags=["Oracle V1"])
app.include_router(oracle_templates_router, prefix="/api/oracle", tags=["Oracle Templates"])
app.include_router(sentinel_router, prefix="/api/sentinel", tags=["Sentinel"])
app.include_router(cctv_router, prefix="/api/cctv", tags=["CCTV"])
app.include_router(scenes_router, prefix="/api/scenes", tags=["Scenes"])
app.include_router(videos_router, prefix="/api/videos", tags=["Videos"])
app.include_router(vault_router, prefix="/api/vault", tags=["Vault"])
app.include_router(mobile_edge_router, prefix="/api/mobile-edge", tags=["Mobile Edge"])
app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"])
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
app.include_router(observability_router, prefix="/api", tags=["Observability"])
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
app.include_router(comms_router, prefix="/api/comms", tags=["Comms"])
app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime LLM"])
app.include_router(auth_router)
# Public vault link (no /api prefix — shared externally with prospects)
from backend.routers.vault import router as public_vault_router
app.include_router(public_vault_router, prefix="/vault", tags=["Vault Public"])
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
class _CatalystManager:
def __init__(self) -> None:
self.active: Set[WebSocket] = set()
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self.active.add(ws)
def disconnect(self, ws: WebSocket) -> None:
self.active.discard(ws)
async def broadcast(self, payload: dict) -> None:
dead: Set[WebSocket] = set()
for ws in self.active:
try:
await ws.send_text(json.dumps(payload))
except Exception:
dead.add(ws)
self.active -= dead
_catalyst_mgr = _CatalystManager()
class _CRMManager:
def __init__(self) -> None:
self.active: Set[WebSocket] = set()
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self.active.add(ws)
def disconnect(self, ws: WebSocket) -> None:
self.active.discard(ws)
async def broadcast(self, payload: dict) -> None:
dead: Set[WebSocket] = set()
for ws in self.active:
try:
await ws.send_text(json.dumps(payload))
except Exception:
dead.add(ws)
self.active -= dead
_crm_mgr = _CRMManager()
@app.websocket("/ws/catalyst")
async def catalyst_ws(ws: WebSocket) -> None:
await _catalyst_mgr.connect(ws)
try:
while True:
data = await ws.receive_text()
await ws.send_text(json.dumps({"type": "ack", "data": data}))
except WebSocketDisconnect:
_catalyst_mgr.disconnect(ws)
@app.websocket("/ws/crm")
async def crm_ws(ws: WebSocket) -> None:
await _crm_mgr.connect(ws)
await _crm_mgr.broadcast(
{
"type": "crm_presence",
"connected_clients": len(_crm_mgr.active),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
try:
while True:
message = await ws.receive_text()
await ws.send_text(json.dumps({"type": "crm_ack", "data": message}))
except WebSocketDisconnect:
_crm_mgr.disconnect(ws)
async def broadcast_live_event(event_type, message, campaign_name=None, value=None):
payload = {
"type": event_type,
"message": message,
"campaignName": campaign_name,
"value": value,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
await _catalyst_mgr.broadcast(payload)
app.state.broadcast_live_event = broadcast_live_event
async def broadcast_crm_event(payload: dict) -> None:
enriched = {
**payload,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
await _crm_mgr.broadcast(enriched)
app.state.broadcast_crm_event = broadcast_crm_event
# ── Health ─────────────────────────────────────────────────────────────────────
@app.get("/health", tags=["Health"])
async def health() -> dict:
pool = app.state.db_pool
db_ok = pool is not None
return {
"status": "ok",
"service": "velocity-backend",
"version": "2.0.0",
"db_pool": "connected" if db_ok else "unavailable",
"timestamp": datetime.now(timezone.utc).isoformat(),
}

View File

@@ -0,0 +1,2 @@
"""Velocity backend migration utilities."""

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
MIGRATIONS_DIR = Path(__file__).resolve().parent / "versions"
@dataclass(frozen=True)
class Migration:
version: str
name: str
path: Path
checksum: str
sql: str
def _checksum(sql: str) -> str:
return hashlib.sha256(sql.encode("utf-8")).hexdigest()
def discover_migrations(directory: Path = MIGRATIONS_DIR) -> list[Migration]:
if not directory.exists():
return []
migrations: list[Migration] = []
for path in sorted(directory.glob("*.sql")):
version, _, name = path.stem.partition("_")
if not version or not name:
raise ValueError(f"Invalid migration filename: {path.name}")
sql = path.read_text(encoding="utf-8")
migrations.append(
Migration(
version=version,
name=name,
path=path,
checksum=_checksum(sql),
sql=sql,
)
)
seen: set[str] = set()
for migration in migrations:
if migration.version in seen:
raise ValueError(f"Duplicate migration version: {migration.version}")
seen.add(migration.version)
return migrations
async def ensure_migration_table(conn) -> None:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
async def applied_versions(conn) -> dict[str, str]:
await ensure_migration_table(conn)
rows = await conn.fetch("SELECT version, checksum FROM schema_migrations")
return {row["version"]: row["checksum"] for row in rows}
async def apply_migrations(conn, migrations: Iterable[Migration] | None = None) -> list[str]:
pending = list(migrations if migrations is not None else discover_migrations())
applied = await applied_versions(conn)
applied_now: list[str] = []
for migration in pending:
existing_checksum = applied.get(migration.version)
if existing_checksum == migration.checksum:
continue
if existing_checksum and existing_checksum != migration.checksum:
raise RuntimeError(
f"Migration checksum mismatch for {migration.version}; "
"create a new migration instead of editing an applied one."
)
transaction = conn.transaction()
async with transaction:
await conn.execute(migration.sql)
await conn.execute(
"""
INSERT INTO schema_migrations (version, name, checksum)
VALUES ($1, $2, $3)
""",
migration.version,
migration.name,
migration.checksum,
)
applied_now.append(migration.version)
return applied_now

View File

@@ -0,0 +1,22 @@
-- Velocity production observability foundation.
-- Creates a lightweight table for durable request/error telemetry when enabled.
CREATE TABLE IF NOT EXISTS app_request_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
duration_ms DOUBLE PRECISION NOT NULL,
tenant_id TEXT,
user_id UUID,
error_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_app_request_events_created_at
ON app_request_events (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_request_events_path_status
ON app_request_events (path, status_code, created_at DESC);

View File

@@ -0,0 +1,30 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS user_calendar_events (
calendar_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
lead_id TEXT,
source_event_id UUID,
title TEXT NOT NULL,
description TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled')),
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
created_by TEXT NOT NULL DEFAULT 'user'
CHECK (created_by IN ('user', 'nemoclaw_suggested', 'operator_import')),
is_nemoclaw_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
location TEXT,
metadata 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_calendar_events_owner
ON user_calendar_events (tenant_id, owner_user_id, start_at);
CREATE INDEX IF NOT EXISTS idx_calendar_events_lead
ON user_calendar_events (tenant_id, lead_id, start_at);

View File

@@ -0,0 +1,6 @@
ALTER TABLE user_calendar_events
DROP CONSTRAINT IF EXISTS user_calendar_events_status_check;
ALTER TABLE user_calendar_events
ADD CONSTRAINT user_calendar_events_status_check
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled'));

View File

@@ -0,0 +1,34 @@
# You are a visitor profiling analyst for a luxury real estate development's CCTV system.
#
# CONTEXT
# You receive data from parking/entry cameras: license plate text (OCR), vehicle
# description (make/model/colour from visual classification), and optionally a
# face analysis summary. Your job is to infer the visitor's likely wealth bracket
# and suggest CRM tags using publicly available heuristics.
#
# LICENSE PLATE HEURISTICS
# UAE plates: AUH = Abu Dhabi, DXB = Dubai, SHJ = Sharjah.
# AUH plates with 1-3 digit numbers → extremely high-value (royal/VIP).
# Dubai plates starting with A, B, C → premium registrations.
# Diplomatic plates (CD/CC prefix) → always HNI.
# Foreign plates (non-UAE) → always flag as NRI consideration.
#
# VEHICLE CLASS HEURISTICS
# Luxury vehicles: Rolls-Royce, Bentley, Lamborghini, Ferrari, Bugatti,
# Mercedes S-Class/Maybach/G63, BMW 7-Series/X7/M8, Range Rover SVR/Sport,
# Porsche 911/Cayenne Turbo, Audi A8/RS models, Cadillac Escalade.
# Standard vehicles: All others.
#
# OUTPUT FORMAT
# Respond with exactly this JSON — no prose before or after:
#
# {
# "wealth_indicator": "HNI" | "standard" | "unknown",
# "vehicle_class": "luxury" | "standard" | "unknown",
# "tags_to_add": ["HNI"] | ["NRI"] | ["HNI", "NRI"] | ["VIP"] | [],
# "notes": "<optional one-line observation — e.g. 'Short UAE plate, likely VIP'>"
# }
#
# IMPORTANT: Only apply "HNI" tag when evidence is clear (luxury vehicle OR short UAE plate).
# Apply "VIP" tag only for diplomatic plates or 1-3 digit Abu Dhabi plates.
# If insufficient data, return wealth_indicator:"unknown" and empty tags_to_add.

View File

@@ -0,0 +1,32 @@
# You are a lead intelligence analyst for a luxury real estate brokerage platform.
#
# Your task is to analyse a newly ingested lead's phone number and first message
# to determine whether they should be tagged as HNI (High Net Individual) or
# NRI (Non-Resident Indian / high-value international buyer).
#
# TAG DEFINITIONS
# ══════════════════════════════════════════════════════════
#
# NRI — Apply when the phone number originates from outside the UAE/GCC region:
# International codes that indicate NRI: +44 (UK), +1 (US/CA), +61 (AU),
# +65 (SG), +91 (India — flag for follow-up, not auto-NRI), +33 (FR),
# +49 (DE), +971 is UAE (do NOT apply NRI).
# Also apply if the message explicitly mentions "based in [foreign city]",
# "living abroad", "NRI", or "overseas".
#
# HNI — Apply when budget signals exceed AED 10 million:
# Keywords: "penthouse", "full floor", "10M", "15M", "20M", "crore",
# "million", "premium", "top floor", "ultra luxury", "AED 10", "AED 12".
# Also apply if budget field contains any figure ≥ AED 10M.
#
# OUTPUT FORMAT
# ══════════════════════════════════════════════════════════
# Respond with exactly this JSON object:
#
# {
# "tags_to_add": ["HNI"] | ["NRI"] | ["HNI", "NRI"] | [],
# "tags_to_remove": []
# }
#
# IMPORTANT: If no signals are present, return {"tags_to_add": [], "tags_to_remove": []}.
# Never add speculative tags. Only apply when evidence is clear.

View File

@@ -0,0 +1,54 @@
# You are a behavioral intelligence analyst embedded in a luxury real estate sales platform.
#
# Your role is to compute a Quantum Dynamics (QD) score (integer, 1-100) that represents
# a prospect's level of genuine emotional engagement and buying intent during a property
# marketing video walkthrough. The score fuses real-time facial expression data with CRM context.
#
# SCORING RUBRIC
# ══════════════════════════════════════════════════════════
#
# Start from the lead's current QD score (provided in context). If no prior score exists,
# start from 50. Apply the following adjustments:
#
# POSITIVE SIGNALS (micro-expressions indicating interest or excitement)
# mouthSmileLeft > 0.5 → +10
# mouthSmileRight > 0.5 → +10 (stack if both active, but cap addend at +15)
# browInnerUp > 0.4 → +8 (genuine surprise or interest)
# eyeWideLeft > 0.5
# OR eyeWideRight > 0.5 → +7 (visual excitement / aesthetic appreciation)
# jawOpen > 0.3 combined with eyeWide → +5 (awe response)
# cheekPuff > 0.3 → +3 (positive anticipation)
#
# NEGATIVE SIGNALS (disinterest or confusion)
# browDownLeft + browDownRight both > 0.45, AND mouthSmile* < 0.2 → -10 (confusion)
# eyeBlinkLeft + eyeBlinkRight both > 0.7, AND eyeWide* < 0.2 → -15 (disengaged)
# mouthFrown* > 0.4 → -8 (negative reaction)
# extended neutral face (all weighted shapes < 0.15) → -3 (boredom)
#
# CRM MODIFIERS (applied once per session initialisation, not per packet)
# budget contains "10M", "15M", "20M", "crore", "million" → +15 (HNI signal)
# budget contains "5M", "8M" → +8
# prior_interaction_count > 5 → +8 (warm lead)
# prior_interaction_count 2-5 → +4
# tags already contains "HNI" → +12
# tags already contains "NRI" → +5
#
# CONSTRAINTS
# Clamp final score: min(max(score, 1), 100)
# Maximum single-packet delta: ±20 (prevent wild swings from one data point)
# Apply micro-expression confidence weighting: if multiple contradictory signals
# are present simultaneously (e.g., smile + frown), choose the strongest signal.
#
# OUTPUT FORMAT
# ══════════════════════════════════════════════════════════
# Respond with exactly this JSON object and nothing else:
#
# {
# "qd_score": <integer 1-100>,
# "reasoning": "<single sentence explaining the primary driver of the score change>",
# "confidence": <float 0.0-1.0 — your confidence in the score given signal quality>
# }
#
# EXAMPLE
# Input: mouthSmileLeft=0.72, browInnerUp=0.55, budget="AED 15M+"
# Output: {"qd_score": 88, "reasoning": "Genuine smile and brow raise during balcony reveal; HNI budget modifier applied.", "confidence": 0.91}

103
core/observability.py Normal file
View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
import time
import uuid
from collections import deque
from dataclasses import asdict, dataclass
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("velocity.observability")
@dataclass(frozen=True)
class RequestMetric:
request_id: str
method: str
path: str
status_code: int
duration_ms: float
class RequestObservabilityMiddleware(BaseHTTPMiddleware):
def __init__(self, app, *, max_metrics: int = 500) -> None:
super().__init__(app)
self.max_metrics = max_metrics
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
request.state.request_id = request_id
started = time.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
return self._finalize(request, response, request_id, started, status_code)
except Exception:
duration_ms = (time.perf_counter() - started) * 1000
self._record_metric(request, request_id, status_code, duration_ms)
logger.exception(
"request_failed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"duration_ms": round(duration_ms, 2),
},
)
raise
def _finalize(
self,
request: Request,
response: Response,
request_id: str,
started: float,
status_code: int,
) -> Response:
duration_ms = (time.perf_counter() - started) * 1000
response.headers["X-Request-ID"] = request_id
response.headers["X-Response-Time-Ms"] = f"{duration_ms:.2f}"
self._record_metric(request, request_id, status_code, duration_ms)
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": status_code,
"duration_ms": round(duration_ms, 2),
},
)
return response
def _record_metric(
self,
request: Request,
request_id: str,
status_code: int,
duration_ms: float,
) -> None:
metrics = getattr(request.app.state, "request_metrics", None)
if metrics is None:
metrics = deque(maxlen=self.max_metrics)
request.app.state.request_metrics = metrics
metrics.append(
RequestMetric(
request_id=request_id,
method=request.method,
path=request.url.path,
status_code=status_code,
duration_ms=round(duration_ms, 2),
)
)
def metrics_snapshot(app, *, limit: int = 50) -> list[dict]:
metrics = getattr(app.state, "request_metrics", deque())
return [asdict(metric) for metric in list(metrics)[-limit:]][::-1]

View File

@@ -0,0 +1 @@
# Oracle services package

View File

@@ -0,0 +1,361 @@
from __future__ import annotations
import json
import os
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
_DB_URL = os.getenv("DATABASE_URL", "")
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
if value is None or isinstance(value, datetime):
return value
if isinstance(value, str) and value.strip():
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
def _db_ready() -> bool:
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
class OracleActionService:
async def ensure_schema(self) -> None:
if not _db_ready():
return
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS oracle_actions (
action_id UUID PRIMARY KEY,
execution_id UUID,
tenant_id TEXT NOT NULL,
page_id UUID,
branch_id TEXT,
actor_id TEXT NOT NULL,
target_entity_type TEXT NOT NULL,
target_entity_id TEXT,
action_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'planned',
prompt TEXT,
workflow_dispatch JSONB NOT NULL DEFAULT '{}'::jsonb,
component_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
writeback_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_oracle_actions_execution ON oracle_actions(execution_id, created_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_oracle_actions_target ON oracle_actions(target_entity_type, target_entity_id, created_at DESC)"
)
finally:
await conn.close()
async def create_from_execution(
self,
*,
execution: dict[str, Any],
target_entity_type: str = "canvas_page",
target_entity_id: str | None = None,
action_type: str = "oracle_canvas_generation",
writeback_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
action = {
"actionId": str(uuid.uuid4()),
"executionId": execution.get("executionId"),
"tenantId": execution.get("tenantId"),
"pageId": execution.get("pageId"),
"branchId": execution.get("branchId"),
"actorId": execution.get("actorId"),
"targetEntityType": target_entity_type,
"targetEntityId": target_entity_id or execution.get("pageId"),
"actionType": action_type,
"status": "planned",
"prompt": execution.get("prompt"),
"workflowDispatch": execution.get("workflowDispatch") or {},
"componentIds": execution.get("componentsCreated") or [],
"writebackPayload": writeback_payload or {},
"resultPayload": {},
"createdAt": _now(),
"updatedAt": _now(),
}
await self._persist_action(action)
return action
async def get_action(self, action_id: str) -> dict[str, Any] | None:
if not _db_ready():
return None
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
SELECT action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
target_entity_type, target_entity_id, action_type, status, prompt,
workflow_dispatch, component_ids, writeback_payload, result_payload,
created_at, updated_at
FROM oracle_actions
WHERE action_id = $1::uuid
""",
action_id,
)
finally:
await conn.close()
return self._serialize(row) if row else None
async def list_actions(self, *, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
if not _db_ready():
return []
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
if status:
rows = await conn.fetch(
"""
SELECT action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
target_entity_type, target_entity_id, action_type, status, prompt,
workflow_dispatch, component_ids, writeback_payload, result_payload,
created_at, updated_at
FROM oracle_actions
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2
""",
status,
limit,
)
else:
rows = await conn.fetch(
"""
SELECT action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
target_entity_type, target_entity_id, action_type, status, prompt,
workflow_dispatch, component_ids, writeback_payload, result_payload,
created_at, updated_at
FROM oracle_actions
ORDER BY created_at DESC
LIMIT $1
""",
limit,
)
finally:
await conn.close()
return [self._serialize(row) for row in rows]
async def apply_writeback(self, payload: dict[str, Any]) -> dict[str, Any]:
if not _db_ready():
raise HTTPException(status_code=503, detail="Oracle writeback store unavailable.")
if payload["target_entity_type"] != "lead":
raise HTTPException(status_code=422, detail="Only lead writebacks are supported in this pass.")
assert asyncpg is not None
await self.ensure_schema()
conn = await asyncpg.connect(_DB_URL)
try:
target_lead_id = payload["target_entity_id"]
action_id = payload["action_id"]
writeback = payload["writeback_payload"]
existing = await conn.fetchrow(
"SELECT id, notes, metadata, kanban_status, qualification, score FROM leads WHERE id = $1",
target_lead_id,
)
if existing is None:
raise HTTPException(status_code=404, detail=f"Lead '{target_lead_id}' not found for Oracle writeback.")
metadata = dict(existing["metadata"] or {})
metadata_patch = writeback.get("metadata_patch") or {}
if isinstance(metadata_patch, dict):
metadata.update(metadata_patch)
score = int(existing["score"] or 0) + int(writeback.get("score_delta") or 0)
updated_notes = (existing["notes"] or "").strip()
notes_append = writeback.get("notes_append")
if notes_append:
separator = "\n\n" if updated_notes else ""
updated_notes = f"{updated_notes}{separator}{notes_append}"
updated = await conn.fetchrow(
"""
UPDATE leads
SET notes = $2,
metadata = $3::jsonb,
kanban_status = COALESCE($4, kanban_status),
qualification = COALESCE($5, qualification),
score = $6,
updated_at = NOW()
WHERE id = $1
RETURNING id, notes, metadata, kanban_status, qualification, score, updated_at
""",
target_lead_id,
updated_notes,
json.dumps(metadata),
writeback.get("kanban_status"),
writeback.get("qualification"),
max(score, 0),
)
oracle_message = writeback.get("oracle_message")
if oracle_message:
await conn.execute(
"""
INSERT INTO chat_logs (id, lead_id, sender, channel, content, metadata, created_at)
VALUES ($1, $2, 'oracle', 'oracle', $3, $4::jsonb, NOW())
""",
str(uuid.uuid4()),
target_lead_id,
oracle_message,
json.dumps({"oracle_action_id": action_id, "writeback": True}),
)
result_payload = {
"lead_id": updated["id"],
"kanban_status": updated["kanban_status"],
"qualification": updated["qualification"],
"score": updated["score"],
"updated_at": updated["updated_at"].isoformat() if updated["updated_at"] else None,
}
await conn.execute(
"""
INSERT INTO oracle_actions (
action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
target_entity_type, target_entity_id, action_type, status, prompt,
workflow_dispatch, component_ids, writeback_payload, result_payload,
created_at, updated_at
)
VALUES (
$1::uuid, NULL, $2, NULL, NULL, $3,
$4, $5, $6, 'applied', NULL,
'{}'::jsonb, '[]'::jsonb, $7::jsonb, $8::jsonb,
NOW(), NOW()
)
ON CONFLICT (action_id)
DO UPDATE SET
status = 'applied',
writeback_payload = EXCLUDED.writeback_payload,
result_payload = EXCLUDED.result_payload,
updated_at = NOW()
""",
action_id,
payload.get("tenant_id", "tenant_velocity"),
payload.get("actor_id", "oracle_operator"),
payload["target_entity_type"],
target_lead_id,
payload.get("action_type", "lead_writeback"),
json.dumps(writeback),
json.dumps(result_payload),
)
finally:
await conn.close()
return {
"actionId": action_id,
"status": "applied",
"targetEntityType": payload["target_entity_type"],
"targetEntityId": payload["target_entity_id"],
"resultPayload": result_payload,
}
async def _persist_action(self, action: dict[str, Any]) -> None:
if not _db_ready():
return
await self.ensure_schema()
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
await conn.execute(
"""
INSERT INTO oracle_actions (
action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
target_entity_type, target_entity_id, action_type, status, prompt,
workflow_dispatch, component_ids, writeback_payload, result_payload,
created_at, updated_at
)
VALUES (
$1::uuid, $2::uuid, $3, $4::uuid, $5, $6,
$7, $8, $9, $10, $11,
$12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
$16::timestamptz, $17::timestamptz
)
ON CONFLICT (action_id)
DO UPDATE SET
status = EXCLUDED.status,
workflow_dispatch = EXCLUDED.workflow_dispatch,
component_ids = EXCLUDED.component_ids,
writeback_payload = EXCLUDED.writeback_payload,
result_payload = EXCLUDED.result_payload,
updated_at = EXCLUDED.updated_at
""",
action["actionId"],
action.get("executionId"),
action["tenantId"],
action.get("pageId"),
action.get("branchId"),
action["actorId"],
action["targetEntityType"],
action.get("targetEntityId"),
action["actionType"],
action["status"],
action.get("prompt"),
json.dumps(action.get("workflowDispatch") or {}),
json.dumps(action.get("componentIds") or []),
json.dumps(action.get("writebackPayload") or {}),
json.dumps(action.get("resultPayload") or {}),
_coerce_datetime(action["createdAt"]),
_coerce_datetime(action["updatedAt"]),
)
finally:
await conn.close()
@staticmethod
def _serialize(row: Any) -> dict[str, Any]:
return {
"actionId": str(row["action_id"]),
"executionId": str(row["execution_id"]) if row["execution_id"] else None,
"tenantId": row["tenant_id"],
"pageId": str(row["page_id"]) if row["page_id"] else None,
"branchId": row["branch_id"],
"actorId": row["actor_id"],
"targetEntityType": row["target_entity_type"],
"targetEntityId": row["target_entity_id"],
"actionType": row["action_type"],
"status": row["status"],
"prompt": row["prompt"],
"workflowDispatch": row["workflow_dispatch"] or {},
"componentIds": row["component_ids"] or [],
"writebackPayload": row["writeback_payload"] or {},
"resultPayload": row["result_payload"] or {},
"createdAt": _iso(row["created_at"]),
"updatedAt": _iso(row["updated_at"]),
}
oracle_action_service = OracleActionService()

View File

@@ -0,0 +1,780 @@
"""
oracle/canvas_service.py
Canvas persistence for Oracle pages, revisions, and current component projections.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
logger = logging.getLogger(__name__)
_DB_URL = os.getenv("DATABASE_URL", "")
_DEMO_PAGES: dict[str, dict[str, Any]] = {}
_DEMO_REVISIONS: dict[str, list[dict[str, Any]]] = {}
_DEMO_COMPONENTS: dict[str, list[dict[str, Any]]] = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _allow_in_memory() -> bool:
return (
os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
or "PYTEST_CURRENT_TEST" in os.environ
)
def _db_ready() -> bool:
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
def _is_demo() -> bool:
return not _db_ready() and _allow_in_memory()
def _ensure_ready() -> None:
if _db_ready() or _is_demo():
return
if asyncpg is None:
raise RuntimeError("Oracle backend requires asyncpg to connect to PostgreSQL.")
raise RuntimeError("Oracle backend requires DATABASE_URL for production persistence.")
def _stringify(value: Any) -> str:
return str(value) if value is not None else ""
def _json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except Exception:
logger.warning("canvas_service: failed to parse JSON object field; using empty object")
return {}
def _json_array(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return parsed
except Exception:
logger.warning("canvas_service: failed to parse JSON array field; using empty array")
return []
def _json_safe(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, tuple):
return [_json_safe(item) for item in value]
return value
def _normalize_component(component: dict[str, Any]) -> dict[str, Any]:
normalized = deepcopy(component)
normalized["componentId"] = _stringify(normalized.get("componentId"))
normalized["dataRows"] = _json_array(normalized.get("dataRows"))
descriptor = _json_object(normalized.get("dataSourceDescriptor"))
if descriptor.get("descriptorId") is not None:
descriptor["descriptorId"] = _stringify(descriptor["descriptorId"])
normalized["dataSourceDescriptor"] = descriptor
for field in (
"visualizationParameters",
"dataBindings",
"provenance",
"renderingHints",
"layout",
"accessControls",
"styleSignature",
"validationState",
):
normalized[field] = _json_object(normalized.get(field))
return normalized
def _deserialize_component_row(row: Any) -> dict[str, Any]:
return _normalize_component(
{
"componentId": _stringify(row["component_id"]),
"type": row["type"],
"title": row["title"],
"description": row["description"],
"version": row["version"],
"lifecycleState": row["lifecycle_state"],
"dataSourceDescriptor": row["data_source_descriptor"],
"dataRows": row["data_rows"],
"visualizationParameters": row["visualization_parameters"],
"dataBindings": row["data_bindings"],
"provenance": row["provenance"],
"renderingHints": row["rendering_hints"],
"layout": row["layout"],
"accessControls": row["access_controls"],
"styleSignature": row["style_signature"],
"validationState": row["validation_state"],
"auditLog": list(row["audit_log"] or []),
}
)
def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[str, Any]:
page_id = _stringify(row["page_id"])
branch_id = _stringify(row["branch_id"])
head_revision = int(row["head_revision"])
return {
"pageId": page_id,
"tenantId": row["tenant_id"],
"ownerId": row["owner_id"],
"branchId": branch_id,
"branchName": row["branch_name"],
"pageType": row["page_type"],
"title": row["title"],
"isShared": bool(row["is_shared"]),
"headRevision": head_revision,
"baseRevision": int(row["base_revision"]),
"sharingPolicy": _json_object(row["sharing_policy"]) or {
"shareMode": "direct_fork_only",
"allowReshare": False,
"defaultForkVisibility": "private",
},
"forks": [],
"lineage": [],
"audit": {"lastAuditEventId": "", "eventCount": 0},
"presence": {"activeViewers": 0, "activeEditors": 0, "lastPresenceAt": row["updated_at"].isoformat()},
"mainBranchPointer": {"pageId": page_id, "branchId": branch_id, "revision": head_revision},
"components": components,
"createdAt": row["created_at"].isoformat(),
"updatedAt": row["updated_at"].isoformat(),
}
class CanvasService:
async def list_pages(
self,
*,
tenant_id: str,
owner_id: str,
search: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
_ensure_ready()
safe_limit = max(1, min(limit, 100))
search_term = (search or "").strip().lower()
if _is_demo():
candidates = [
page
for page in _DEMO_PAGES.values()
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id
]
if search_term:
candidates = [page for page in candidates if search_term in page.get("title", "").lower()]
candidates.sort(key=lambda page: page.get("updatedAt", ""), reverse=True)
return [{**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))} for page in candidates[:safe_limit]]
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
rows = await conn.fetch(
"""
SELECT *
FROM oracle_canvas_pages
WHERE tenant_id = $1
AND owner_id = $2
AND ($3 = '' OR lower(title) LIKE '%' || $3 || '%')
ORDER BY updated_at DESC, created_at DESC
LIMIT $4
""",
tenant_id,
owner_id,
search_term,
safe_limit,
)
pages: list[dict[str, Any]] = []
for row in rows:
components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id)
pages.append(_deserialize_page_row(row, components))
return pages
finally:
await conn.close()
async def create_page(
self,
*,
tenant_id: str,
owner_id: str,
title: str = "Untitled Canvas",
page_type: str = "main",
branch_name: str = "main",
sharing_policy: dict[str, Any] | None = None,
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
page_id = str(uuid.uuid4())
branch_id = str(uuid.uuid4())
page = {
"pageId": page_id,
"tenantId": tenant_id,
"ownerId": owner_id,
"branchId": branch_id,
"branchName": branch_name,
"pageType": page_type,
"title": title,
"isShared": False,
"headRevision": 0,
"baseRevision": 0,
"sharingPolicy": sharing_policy or {"shareMode": "direct_fork_only", "allowReshare": False, "defaultForkVisibility": "private"},
"forks": [],
"lineage": [],
"audit": {"lastAuditEventId": "", "eventCount": 0},
"presence": {"activeViewers": 0, "activeEditors": 0, "lastPresenceAt": _now()},
"mainBranchPointer": {"pageId": page_id, "branchId": branch_id, "revision": 0},
"components": [],
"createdAt": _now(),
"updatedAt": _now(),
}
_DEMO_PAGES[page_id] = page
_DEMO_REVISIONS[page_id] = []
_DEMO_COMPONENTS[page_id] = []
return page
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
INSERT INTO oracle_canvas_pages (
tenant_id, owner_id, branch_id, branch_name, page_type, title, sharing_policy
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
RETURNING *
""",
tenant_id,
owner_id,
str(uuid.uuid4()),
branch_name,
page_type,
title,
json.dumps(sharing_policy or {"shareMode": "direct_fork_only", "allowReshare": False, "defaultForkVisibility": "private"}),
)
return _deserialize_page_row(row, [])
finally:
await conn.close()
async def ensure_default_page(
self,
*,
tenant_id: str,
owner_id: str,
title: str = "Oracle Main Canvas",
) -> dict[str, Any]:
page = await self.get_first_page_for_owner(tenant_id=tenant_id, owner_id=owner_id)
if page:
return page
return await self.create_page(tenant_id=tenant_id, owner_id=owner_id, title=title)
async def get_first_page_for_owner(self, *, tenant_id: str, owner_id: str) -> dict[str, Any] | None:
_ensure_ready()
if _is_demo():
candidates = [
page
for page in _DEMO_PAGES.values()
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id
]
if candidates:
candidates.sort(key=lambda page: page.get("updatedAt", ""), reverse=True)
page = candidates[0]
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))}
return None
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_pages
WHERE tenant_id = $1 AND owner_id = $2
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
""",
tenant_id,
owner_id,
)
if not row:
return None
components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id)
return _deserialize_page_row(row, components)
finally:
await conn.close()
async def get_page(self, page_id: str, tenant_id: str) -> dict[str, Any] | None:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if page and page["tenantId"] == tenant_id:
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))}
return None
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_pages
WHERE page_id = $1::uuid AND tenant_id = $2
""",
page_id,
tenant_id,
)
if not row:
return None
components = await self._pg_fetch_components(conn, page_id, tenant_id)
return _deserialize_page_row(row, components)
finally:
await conn.close()
async def update_page_title(
self,
*,
page_id: str,
tenant_id: str,
owner_id: str,
title: str,
) -> dict[str, Any]:
_ensure_ready()
clean_title = (title or "").strip() or "Untitled Canvas"
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
page["title"] = clean_title
page["updatedAt"] = _now()
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))}
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
UPDATE oracle_canvas_pages
SET title = $4, updated_at = NOW()
WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3
RETURNING *
""",
page_id,
tenant_id,
owner_id,
clean_title,
)
if not row:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
components = await self._pg_fetch_components(conn, page_id, tenant_id)
return _deserialize_page_row(row, components)
finally:
await conn.close()
async def delete_page(
self,
*,
page_id: str,
tenant_id: str,
owner_id: str,
) -> None:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
del _DEMO_PAGES[page_id]
_DEMO_COMPONENTS.pop(page_id, None)
_DEMO_REVISIONS.pop(page_id, None)
return
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
result = await conn.execute(
"""
DELETE FROM oracle_canvas_pages
WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3
""",
page_id,
tenant_id,
owner_id,
)
if result.endswith("0"):
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
finally:
await conn.close()
async def commit_revision(
self,
*,
page_id: str,
tenant_id: str,
actor_id: str,
commit_kind: str,
commit_summary: str,
components: list[dict[str, Any]],
execution_id: str | None = None,
merge_request_id: str | None = None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
if idempotency_key:
existing = next((r for r in _DEMO_REVISIONS.get(page_id, []) if r.get("idempotencyKey") == idempotency_key), None)
if existing:
return existing
new_revision_num = page["headRevision"] + 1
revision = {
"revisionId": str(uuid.uuid4()),
"pageId": page_id,
"tenantId": tenant_id,
"revisionNumber": new_revision_num,
"commitKind": commit_kind,
"commitSummary": commit_summary,
"actorId": actor_id,
"executionId": execution_id,
"mergeRequestId": merge_request_id,
"componentsSnapshot": json.dumps(_json_safe(components)),
"idempotencyKey": idempotency_key,
"createdAt": _now(),
}
_DEMO_REVISIONS.setdefault(page_id, []).append(revision)
_DEMO_COMPONENTS[page_id] = deepcopy([_normalize_component(component) for component in components])
page["headRevision"] = new_revision_num
page["mainBranchPointer"]["revision"] = new_revision_num
page["updatedAt"] = _now()
return revision
assert asyncpg is not None
normalized_components = [_normalize_component(component) for component in components]
conn = await asyncpg.connect(_DB_URL)
try:
async with conn.transaction():
if idempotency_key:
existing = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_page_revisions
WHERE idempotency_key = $1
""",
idempotency_key,
)
if existing:
return {
"revisionId": _stringify(existing["revision_id"]),
"pageId": _stringify(existing["page_id"]),
"tenantId": existing["tenant_id"],
"revisionNumber": int(existing["revision_number"]),
"commitKind": existing["commit_kind"],
"commitSummary": existing["commit_summary"],
"actorId": existing["actor_id"],
"executionId": _stringify(existing["execution_id"]) if existing["execution_id"] else None,
"mergeRequestId": _stringify(existing["merge_request_id"]) if existing["merge_request_id"] else None,
"componentsSnapshot": json.dumps(_json_safe(existing["components_snapshot"])),
"idempotencyKey": existing["idempotency_key"],
"createdAt": existing["created_at"].isoformat(),
}
page = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_pages
WHERE page_id = $1::uuid AND tenant_id = $2
FOR UPDATE
""",
page_id,
tenant_id,
)
if not page:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
new_revision_number = int(page["head_revision"]) + 1
revision = await conn.fetchrow(
"""
INSERT INTO oracle_canvas_page_revisions (
page_id, tenant_id, revision_number, commit_kind, commit_summary,
actor_id, execution_id, merge_request_id, components_snapshot, idempotency_key
)
VALUES (
$1::uuid, $2, $3, $4, $5,
$6, NULLIF($7, '')::uuid, NULLIF($8, '')::uuid, $9::jsonb, $10
)
RETURNING *
""",
page_id,
tenant_id,
new_revision_number,
commit_kind,
commit_summary,
actor_id,
execution_id or "",
merge_request_id or "",
json.dumps(_json_safe(normalized_components)),
idempotency_key,
)
await conn.execute(
"""
UPDATE oracle_canvas_pages
SET head_revision = $3, updated_at = NOW()
WHERE page_id = $1::uuid AND tenant_id = $2
""",
page_id,
tenant_id,
new_revision_number,
)
await self._pg_replace_components(conn, page_id=page_id, tenant_id=tenant_id, components=normalized_components)
return {
"revisionId": _stringify(revision["revision_id"]),
"pageId": _stringify(revision["page_id"]),
"tenantId": revision["tenant_id"],
"revisionNumber": int(revision["revision_number"]),
"commitKind": revision["commit_kind"],
"commitSummary": revision["commit_summary"],
"actorId": revision["actor_id"],
"executionId": _stringify(revision["execution_id"]) if revision["execution_id"] else None,
"mergeRequestId": _stringify(revision["merge_request_id"]) if revision["merge_request_id"] else None,
"componentsSnapshot": json.dumps(_json_safe(revision["components_snapshot"])),
"idempotencyKey": revision["idempotency_key"],
"createdAt": revision["created_at"].isoformat(),
}
finally:
await conn.close()
async def rollback(
self,
*,
page_id: str,
tenant_id: str,
actor_id: str,
target_revision: int,
idempotency_key: str,
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page:
raise ValueError(f"Page {page_id} not found")
revisions = _DEMO_REVISIONS.get(page_id, [])
target_rev = next((r for r in revisions if r["revisionNumber"] == target_revision), None)
if not target_rev:
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
snapshot = json.loads(target_rev["componentsSnapshot"])
return await self.commit_revision(
page_id=page_id,
tenant_id=tenant_id,
actor_id=actor_id,
commit_kind="rollback",
commit_summary=f"Rollback to revision {target_revision}",
components=snapshot,
idempotency_key=idempotency_key,
)
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
revision = await conn.fetchrow(
"""
SELECT components_snapshot
FROM oracle_canvas_page_revisions
WHERE page_id = $1::uuid AND tenant_id = $2 AND revision_number = $3
""",
page_id,
tenant_id,
target_revision,
)
if not revision:
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
snapshot = _json_array(revision["components_snapshot"])
return await self.commit_revision(
page_id=page_id,
tenant_id=tenant_id,
actor_id=actor_id,
commit_kind="rollback",
commit_summary=f"Rollback to revision {target_revision}",
components=snapshot,
idempotency_key=idempotency_key,
)
finally:
await conn.close()
async def list_revisions(self, page_id: str, tenant_id: str) -> list[dict[str, Any]]:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id:
return []
return sorted(_DEMO_REVISIONS.get(page_id, []), key=lambda r: r["revisionNumber"], reverse=True)
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
rows = await conn.fetch(
"""
SELECT revision_id, page_id, tenant_id, revision_number, commit_kind, commit_summary,
actor_id, execution_id, merge_request_id, created_at
FROM oracle_canvas_page_revisions
WHERE page_id = $1::uuid AND tenant_id = $2
ORDER BY revision_number DESC
""",
page_id,
tenant_id,
)
return [
{
"revisionId": _stringify(row["revision_id"]),
"pageId": _stringify(row["page_id"]),
"tenantId": row["tenant_id"],
"revisionNumber": int(row["revision_number"]),
"commitKind": row["commit_kind"],
"commitSummary": row["commit_summary"],
"actorId": row["actor_id"],
"executionId": _stringify(row["execution_id"]) if row["execution_id"] else None,
"mergeRequestId": _stringify(row["merge_request_id"]) if row["merge_request_id"] else None,
"createdAt": row["created_at"].isoformat(),
}
for row in rows
]
finally:
await conn.close()
async def upsert_component(
self,
*,
page_id: str,
tenant_id: str,
component: dict[str, Any],
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
comps = _DEMO_COMPONENTS.setdefault(page_id, [])
normalized = _normalize_component(component)
existing_idx = next((i for i, c in enumerate(comps) if c.get("componentId") == normalized.get("componentId")), None)
if existing_idx is not None:
comps[existing_idx] = normalized
else:
comps.append(normalized)
return normalized
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
await self._pg_upsert_component(conn, page_id=page_id, tenant_id=tenant_id, component=_normalize_component(component))
return _normalize_component(component)
finally:
await conn.close()
async def _pg_fetch_components(self, conn: Any, page_id: str, tenant_id: str) -> list[dict[str, Any]]:
rows = await conn.fetch(
"""
SELECT *
FROM oracle_canvas_components
WHERE page_id = $1::uuid AND tenant_id = $2
ORDER BY COALESCE((layout->>'orderIndex')::int, 999999), created_at ASC
""",
page_id,
tenant_id,
)
return [_deserialize_component_row(row) for row in rows]
async def _pg_replace_components(self, conn: Any, *, page_id: str, tenant_id: str, components: list[dict[str, Any]]) -> None:
await conn.execute(
"""
DELETE FROM oracle_canvas_components
WHERE page_id = $1::uuid AND tenant_id = $2
""",
page_id,
tenant_id,
)
for component in components:
await self._pg_upsert_component(conn, page_id=page_id, tenant_id=tenant_id, component=component)
async def _pg_upsert_component(self, conn: Any, *, page_id: str, tenant_id: str, component: dict[str, Any]) -> None:
await conn.execute(
"""
INSERT INTO oracle_canvas_components (
component_id, page_id, tenant_id, type, title, description, version, lifecycle_state,
data_source_descriptor, data_rows, visualization_parameters, data_bindings, provenance,
rendering_hints, layout, access_controls, style_signature, validation_state, audit_log
)
VALUES (
$1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8,
$9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13::jsonb,
$14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb, $18::jsonb, $19::text[]
)
ON CONFLICT (component_id)
DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
version = EXCLUDED.version,
lifecycle_state = EXCLUDED.lifecycle_state,
data_source_descriptor = EXCLUDED.data_source_descriptor,
data_rows = EXCLUDED.data_rows,
visualization_parameters = EXCLUDED.visualization_parameters,
data_bindings = EXCLUDED.data_bindings,
provenance = EXCLUDED.provenance,
rendering_hints = EXCLUDED.rendering_hints,
layout = EXCLUDED.layout,
access_controls = EXCLUDED.access_controls,
style_signature = EXCLUDED.style_signature,
validation_state = EXCLUDED.validation_state,
audit_log = EXCLUDED.audit_log,
updated_at = NOW()
""",
component["componentId"],
page_id,
tenant_id,
component["type"],
component["title"],
component.get("description"),
int(component.get("version", 1)),
component.get("lifecycleState", "active"),
json.dumps(_json_safe(component.get("dataSourceDescriptor", {}))),
json.dumps(_json_safe(component.get("dataRows", []))),
json.dumps(_json_safe(component.get("visualizationParameters", {}))),
json.dumps(_json_safe(component.get("dataBindings", {}))),
json.dumps(_json_safe(component.get("provenance", {}))),
json.dumps(_json_safe(component.get("renderingHints", {}))),
json.dumps(_json_safe(component.get("layout", {}))),
json.dumps(_json_safe(component.get("accessControls", {}))),
json.dumps(_json_safe(component.get("styleSignature", {}))),
json.dumps(_json_safe(component.get("validationState", {}))),
list(component.get("auditLog", [])),
)
canvas_service = CanvasService()

View File

@@ -0,0 +1,353 @@
"""
oracle/codebook_service.py
Loads, normalizes, and retrieves Oracle Canvas codebook examples from the
expanded GPT and Claude seed packs delivered in Sprint 1.
The runtime treats the GPT pack as the primary normalized corpus and uses the
Claude pack as a supplement when it adds unique examples or metadata.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_TOKEN_RE = re.compile(r"[a-z0-9]+")
_STOPWORDS = {
"a", "an", "and", "as", "at", "build", "canvas", "chart", "client", "clients",
"for", "from", "get", "give", "in", "into", "is", "list", "me", "of", "on",
"or", "oracle", "please", "render", "show", "surface", "that", "the", "this",
"to", "view", "with",
}
@dataclass(frozen=True)
class CodebookExample:
example_id: str
chapter_id: str
chapter_name: str
subchapter_id: str
subchapter_name: str
title: str
template_name: str
component_type: str
accepted_shapes: tuple[str, ...]
example_json: dict[str, Any]
quality_notes: str
is_canonical: bool
source_pack: str
surface_targets: tuple[str, ...]
policy_tags: tuple[str, ...]
backend_contract_hints: dict[str, Any]
score_terms: tuple[str, ...]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _safe_load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def _tokenize(value: str) -> list[str]:
lowered = value.lower()
return [tok for tok in _TOKEN_RE.findall(lowered) if tok not in _STOPWORDS and len(tok) > 1]
def _make_template_id(example: dict[str, Any]) -> str:
base = "|".join(
[
example.get("chapter_id", ""),
example.get("subchapter_id", ""),
example.get("template_name", ""),
example.get("component_type", ""),
]
)
return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16]
def _chapter_maps(payload: dict[str, Any]) -> tuple[dict[str, str], dict[str, str]]:
chapters: dict[str, str] = {}
subchapters: dict[str, str] = {}
for chapter in payload.get("chapters", []):
chapter_id = str(chapter.get("chapter_id", "")).strip()
if chapter_id:
chapters[chapter_id] = str(chapter.get("name", "")).strip()
for subchapter in chapter.get("subchapters", []):
sub_id = str(subchapter.get("subchapter_id", "")).strip()
if sub_id:
subchapters[sub_id] = str(subchapter.get("name", "")).strip()
return chapters, subchapters
def _normalize_examples(payload: dict[str, Any], source_pack: str) -> list[CodebookExample]:
chapter_names, subchapter_names = _chapter_maps(payload)
raw_examples = payload.get("seed_examples") or payload.get("examples") or []
normalized: list[CodebookExample] = []
for raw in raw_examples:
chapter_id = str(raw.get("chapter_id", "")).strip()
subchapter_id = str(raw.get("subchapter_id", "")).strip()
title = str(raw.get("title") or raw.get("template_name") or "Oracle Component").strip()
template_name = str(raw.get("template_name") or title).strip()
component_type = str(raw.get("component_type") or "summary_card").strip()
example_json = raw.get("example_json") or {}
terms = _tokenize(
" ".join(
[
title,
template_name,
component_type.replace("_", " "),
chapter_names.get(chapter_id, ""),
subchapter_names.get(subchapter_id, ""),
str(raw.get("quality_notes", "")),
" ".join(raw.get("policy_tags", []) or []),
]
)
)
normalized.append(
CodebookExample(
example_id=str(raw.get("example_id") or _make_template_id(raw)),
chapter_id=chapter_id,
chapter_name=chapter_names.get(chapter_id, chapter_id),
subchapter_id=subchapter_id,
subchapter_name=subchapter_names.get(subchapter_id, subchapter_id),
title=title,
template_name=template_name,
component_type=component_type,
accepted_shapes=tuple(raw.get("accepted_shapes") or []),
example_json=example_json,
quality_notes=str(raw.get("quality_notes") or ""),
is_canonical=bool(raw.get("is_canonical")),
source_pack=source_pack,
surface_targets=tuple(raw.get("surface_targets") or []),
policy_tags=tuple(raw.get("policy_tags") or []),
backend_contract_hints=dict(raw.get("backend_contract_hints") or {}),
score_terms=tuple(terms),
)
)
return normalized
class OracleCodebookService:
def __init__(self) -> None:
root = _repo_root()
self.runtime_merged_path = root / "backend" / "oracle" / "oracle_runtime_codebook_merged.json"
self.primary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "GPT 5.4" / "oracle_canvas_json_expansion_pack" / "db" / "oracle_template_seed_db_expanded_v1.pretty.json"
self.secondary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "Claude Sonnet 4.6" / "oracle_template_expansion" / "oracle_template_seed_db_expanded.json"
self.fallback_path = root / "backend" / "oracle" / "oracle_template_seed_db.json"
@lru_cache(maxsize=1)
def load(self) -> dict[str, Any]:
corpora: list[CodebookExample] = []
sources_loaded: list[str] = []
source_paths: list[tuple[Path, str]]
if self.runtime_merged_path.exists():
source_paths = [
(self.runtime_merged_path, "runtime_merged"),
(self.fallback_path, "runtime_seed_fallback"),
]
else:
source_paths = [
(self.primary_path, "gpt_5_4"),
(self.secondary_path, "claude_sonnet_4_6"),
(self.fallback_path, "runtime_seed_fallback"),
]
for path, label in source_paths:
if not path.exists():
continue
payload = _safe_load_json(path)
examples = _normalize_examples(payload, label)
if examples:
corpora.extend(examples)
sources_loaded.append(f"{label}:{len(examples)}")
deduped: dict[tuple[str, str, str], CodebookExample] = {}
for example in corpora:
key = (example.subchapter_id, example.template_name.lower(), example.title.lower())
existing = deduped.get(key)
if existing is None:
deduped[key] = example
continue
# Prefer canonical GPT examples, then canonical examples, then richer source pack.
if example.source_pack == "gpt_5_4" and existing.source_pack != "gpt_5_4":
deduped[key] = example
elif example.is_canonical and not existing.is_canonical:
deduped[key] = example
examples = list(deduped.values())
logger.info("Oracle codebook loaded from %s", ", ".join(sources_loaded) or "no sources")
return {
"examples": examples,
"source_summary": sources_loaded,
"template_count": len({(e.chapter_id, e.subchapter_id, e.template_name, e.component_type) for e in examples}),
}
def stats(self) -> dict[str, Any]:
data = self.load()
examples: list[CodebookExample] = data["examples"]
return {
"example_count": len(examples),
"template_count": data["template_count"],
"source_summary": data["source_summary"],
}
def list_templates(
self,
*,
category: str | None = None,
status: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
del status # runtime codebook templates are always active catalog entries
examples: list[CodebookExample] = self.load()["examples"]
templates: dict[str, dict[str, Any]] = {}
for example in examples:
if category and category.lower() not in {example.chapter_name.lower(), example.subchapter_name.lower()}:
continue
if search:
terms = set(example.score_terms)
if not set(_tokenize(search)).intersection(terms):
continue
template_id = _make_template_id(
{
"chapter_id": example.chapter_id,
"subchapter_id": example.subchapter_id,
"template_name": example.template_name,
"component_type": example.component_type,
}
)
record = templates.get(template_id)
if record is None:
templates[template_id] = {
"templateId": template_id,
"tenantId": "_system",
"name": example.template_name,
"category": example.chapter_name,
"status": "catalog_active",
"origin": "premade",
"version": "codebook-v2",
"acceptedShapes": list(example.accepted_shapes),
"description": f"{example.subchapter_name} · {example.title}",
"chapterId": example.chapter_id,
"subchapterId": example.subchapter_id,
"componentType": example.component_type,
"sourcePack": example.source_pack,
"useCount": 0,
"updatedAt": None,
"createdAt": None,
}
ordered = list(templates.values())
ordered.sort(key=lambda item: (item["category"], item["name"]))
total = len(ordered)
return {
"total": total,
"templates": ordered[offset: offset + limit],
}
def search_examples(self, prompt: str, *, limit: int = 8) -> list[CodebookExample]:
prompt_terms = set(_tokenize(prompt))
if not prompt_terms:
prompt_terms = set(_tokenize(prompt.replace("_", " ")))
lowered_prompt = prompt.lower()
crm_prompt = any(term in lowered_prompt for term in ("client", "clients", "contact", "contacts", "crm", "lead", "account"))
interaction_prompt = any(term in lowered_prompt for term in ("interaction", "timeline", "call", "message", "email", "whatsapp", "follow-up"))
property_prompt = any(term in lowered_prompt for term in ("property", "properties", "project", "projects", "interest", "interested"))
scored: list[tuple[int, CodebookExample]] = []
for example in self.load()["examples"]:
score = 0
term_set = set(example.score_terms)
overlap = prompt_terms.intersection(term_set)
score += len(overlap) * 6
if example.template_name.lower() in lowered_prompt:
score += 24
if example.subchapter_name.lower() in lowered_prompt:
score += 20
if example.chapter_name.lower() in lowered_prompt:
score += 14
if example.component_type.replace("_", " ") in lowered_prompt:
score += 12
if example.is_canonical:
score += 8
if "live_data_first" in example.policy_tags:
score += 4
chapter = example.chapter_name.lower()
subchapter = example.subchapter_name.lower()
title = example.title.lower()
if crm_prompt and any(term in " ".join((chapter, subchapter, title, example.template_name.lower())) for term in ("lead", "client", "contact", "crm", "account", "pipeline")):
score += 18
if interaction_prompt and any(term in " ".join((chapter, subchapter, title, example.template_name.lower())) for term in ("interaction", "timeline", "call", "message", "email", "whatsapp", "follow-up")):
score += 16
if property_prompt and any(term in " ".join((chapter, subchapter, title, example.template_name.lower())) for term in ("property", "inventory", "interest", "project")):
score += 16
if score > 0:
scored.append((score, example))
scored.sort(key=lambda item: (-item[0], item[1].chapter_id, item[1].subchapter_id, item[1].title))
selected: list[CodebookExample] = []
seen: set[tuple[str, str]] = set()
for _, example in scored:
dedupe_key = (example.subchapter_id, example.template_name)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
selected.append(example)
if len(selected) >= limit:
break
return selected
def synthesize_template(self, prompt: str, data_shapes: list[str] | None = None) -> dict[str, Any]:
match = next(iter(self.search_examples(prompt, limit=1)), None)
shapes = data_shapes or []
if match is None:
return {
"templateId": hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:16],
"tenantId": "_system",
"name": "Oracle Synthesized Draft",
"category": "Custom",
"status": "tenant_draft",
"origin": "synthesized",
"version": "1.0.0",
"acceptedShapes": shapes,
"description": f"Draft synthesized from prompt: {prompt[:120]}",
}
return {
"templateId": _make_template_id(
{
"chapter_id": match.chapter_id,
"subchapter_id": match.subchapter_id,
"template_name": match.template_name,
"component_type": match.component_type,
}
),
"tenantId": "_system",
"name": match.template_name,
"category": match.chapter_name,
"status": "catalog_active",
"origin": "premade",
"version": "codebook-v2",
"acceptedShapes": list(match.accepted_shapes or shapes),
"description": f"Best codebook match · {match.subchapter_name}",
"componentType": match.component_type,
"chapterId": match.chapter_id,
"subchapterId": match.subchapter_id,
"sourcePack": match.source_pack,
"exampleJson": match.example_json,
}
codebook_service = OracleCodebookService()

View File

@@ -0,0 +1,430 @@
"""
oracle/collaboration_service.py
Implements fork creation, MergeRequest lifecycle, three-way diff engine,
conflict classification (all 7 classes from spec §17.2), and merge commits.
"""
from __future__ import annotations
import copy
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from .canvas_service import canvas_service
logger = logging.getLogger(__name__)
# ── In-memory store (demo mode) ───────────────────────────────────────────────
_DEMO_FORKS: dict[str, dict[str, Any]] = {}
_DEMO_MRS: dict[str, dict[str, Any]] = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _clone_components_for_fork(
components: list[dict[str, Any]],
*,
actor_id: str,
source_page_id: str,
source_branch_id: str,
source_revision: int,
) -> list[dict[str, Any]]:
cloned: list[dict[str, Any]] = []
for component in components:
forked = copy.deepcopy(component)
original_component_id = str(forked.get("componentId") or "")
forked["componentId"] = str(uuid.uuid4())
provenance = dict(forked.get("provenance") or {})
provenance["forkedAt"] = _now()
provenance["forkedBy"] = actor_id
provenance["sourcePageId"] = source_page_id
provenance["sourceBranchId"] = source_branch_id
provenance["sourceRevision"] = source_revision
if original_component_id:
provenance["sourceComponentId"] = original_component_id
forked["provenance"] = provenance
cloned.append(forked)
return cloned
# ── Three-way diff engine ─────────────────────────────────────────────────────
def _three_way_diff(
base_components: list[dict[str, Any]],
source_components: list[dict[str, Any]],
target_components: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""
Compute a three-way diff between base, source, and target component lists.
Returns (merged_components, conflicts) per spec §17.2.
Conflict classes:
1. safe_append — added only in source, not in target
2. safe_reorder — order differs but content same
3. component_content_conflict — both changed same component fields
4. query_descriptor_conflict — data source descriptor changed in both
5. layout_slot_conflict — same orderIndex claimed by different components
6. access_policy_conflict — accessControls differ in both
7. delete_edit_conflict — deleted in one, edited in other
"""
base_map = {c["componentId"]: c for c in base_components}
source_map = {c["componentId"]: c for c in source_components}
target_map = {c["componentId"]: c for c in target_components}
all_ids = set(base_map) | set(source_map) | set(target_map)
merged: list[dict[str, Any]] = []
conflicts: list[dict[str, Any]] = []
def make_conflict(
conflict_class: str,
component_id: str,
field: str | None = None,
source_val: Any = None,
target_val: Any = None,
description: str = "",
) -> dict[str, Any]:
return {
"conflictId": str(uuid.uuid4()),
"conflictClass": conflict_class,
"componentId": component_id,
"field": field,
"sourceValue": source_val,
"targetValue": target_val,
"description": description,
}
for cid in all_ids:
in_base = cid in base_map
in_source = cid in source_map
in_target = cid in target_map
base_c = base_map.get(cid)
src_c = source_map.get(cid)
tgt_c = target_map.get(cid)
# Case 1: Exists nowhere → skip
if not in_source and not in_target:
continue
# Case 2: Deleted in both → skip
if not in_source and not in_target:
continue
# Case 3: Added only in source (safe_append)
if not in_base and in_source and not in_target:
conflicts.append(make_conflict(
"safe_append", cid,
description=f"Component '{cid}' added in source branch; will be appended."
))
merged.append(copy.deepcopy(src_c))
continue
# Case 4: Added only in target → keep target as-is
if not in_base and not in_source and in_target:
merged.append(copy.deepcopy(tgt_c))
continue
# Case 5: Added in both (both new, same id) → conflict
if not in_base and in_source and in_target:
if src_c == tgt_c:
merged.append(copy.deepcopy(tgt_c))
else:
conflicts.append(make_conflict(
"component_content_conflict", cid,
description="Component added in both branches with different content."
))
merged.append(copy.deepcopy(tgt_c)) # Default: keep target
continue
# Case 6: Deleted in source only
if in_base and not in_source and in_target:
src_equal_base = base_c == tgt_c
if src_equal_base:
# Target unchanged → deletion is safe
continue
else:
conflicts.append(make_conflict(
"delete_edit_conflict", cid,
description="Component deleted in source but edited in target."
))
merged.append(copy.deepcopy(tgt_c))
continue
# Case 7: Deleted in target only
if in_base and in_source and not in_target:
src_equal_base = base_c == src_c
if src_equal_base:
continue
else:
conflicts.append(make_conflict(
"delete_edit_conflict", cid,
description="Component deleted in target but edited in source."
))
merged.append(copy.deepcopy(src_c))
continue
# Case 8: Both present — check for edits
if src_c == tgt_c:
merged.append(copy.deepcopy(tgt_c))
continue
# Check individual field conflicts
has_conflict = False
# Data source descriptor conflict
if src_c.get("dataSourceDescriptor") != tgt_c.get("dataSourceDescriptor") \
and (base_c or {}).get("dataSourceDescriptor") not in (
src_c.get("dataSourceDescriptor"),
tgt_c.get("dataSourceDescriptor"),
):
conflicts.append(make_conflict(
"query_descriptor_conflict", cid,
field="dataSourceDescriptor",
description="Data source descriptor modified in both branches.",
))
has_conflict = True
# Access controls conflict
if src_c.get("accessControls") != tgt_c.get("accessControls") \
and (base_c or {}).get("accessControls") not in (
src_c.get("accessControls"),
tgt_c.get("accessControls"),
):
conflicts.append(make_conflict(
"access_policy_conflict", cid,
field="accessControls",
source_val=src_c.get("accessControls"),
target_val=tgt_c.get("accessControls"),
description="Access control policies diverge in both branches.",
))
has_conflict = True
# Layout orderIndex conflict
src_order = (src_c.get("layout") or {}).get("orderIndex")
tgt_order = (tgt_c.get("layout") or {}).get("orderIndex")
if src_order != tgt_order:
conflicts.append(make_conflict(
"layout_slot_conflict", cid,
field="layout.orderIndex",
source_val=src_order,
target_val=tgt_order,
description="Layout order index conflicts.",
))
# Record as safe reorder if content otherwise matches
if not has_conflict:
conflicts.append(make_conflict("safe_reorder", cid, description="Component reordered."))
# General content conflict
if not has_conflict and src_c != tgt_c:
conflicts.append(make_conflict(
"component_content_conflict", cid,
description="Component content diverges in both branches.",
))
# Merge: for all conflicts, default target wins
merged.append(copy.deepcopy(tgt_c))
# Normalize orderIndex
merged.sort(key=lambda c: (c.get("layout") or {}).get("orderIndex", 9999))
for i, comp in enumerate(merged):
comp.setdefault("layout", {})["orderIndex"] = (i + 1) * 100
return merged, conflicts
# ── CollaborationService ──────────────────────────────────────────────────────
class CollaborationService:
"""
Manages fork creation and merge request lifecycle.
Uses canvas_service for snapshot reads and revision commits.
"""
async def create_fork(
self,
*,
source_page: dict[str, Any],
recipient_user_id: str,
created_by: str,
visibility: str = "private",
message: str = "",
) -> dict[str, Any]:
"""
Creates a fork from the source_page snapshot at its current headRevision.
Returns ForkRecord.
"""
if recipient_user_id == created_by:
raise ValueError("You cannot share a canvas with your own account.")
fork_id = str(uuid.uuid4())
fork_page = await canvas_service.create_page(
tenant_id=source_page["tenantId"],
owner_id=recipient_user_id,
title=f"{source_page['title']} Fork",
page_type="fork",
branch_name=f"fork-{str(fork_id)[:8]}",
sharing_policy={
"shareMode": "direct_fork_only",
"allowReshare": visibility == "team",
"defaultForkVisibility": visibility,
},
)
fork_components = _clone_components_for_fork(
source_page.get("components", []),
actor_id=created_by,
source_page_id=source_page["pageId"],
source_branch_id=source_page["branchId"],
source_revision=source_page["headRevision"],
)
await canvas_service.commit_revision(
page_id=fork_page["pageId"],
tenant_id=source_page["tenantId"],
actor_id=created_by,
commit_kind="merge",
commit_summary=f"Forked from {source_page['title']} at rev.{source_page['headRevision']}",
components=fork_components,
execution_id=None,
merge_request_id=None,
idempotency_key=f"fork_{fork_id}",
)
fork = {
"forkId": fork_id,
"sourcePageId": source_page["pageId"],
"sourceBranchId": source_page["branchId"],
"sourceRevision": source_page["headRevision"],
"forkPageId": fork_page["pageId"],
"forkBranchId": fork_page["branchId"],
"recipientUserId": recipient_user_id,
"createdBy": created_by,
"visibility": visibility,
"message": message,
"status": "active",
"createdAt": _now(),
}
_DEMO_FORKS[fork_id] = fork
logger.info(
"COLLAB fork_created fork_id=%s source_page=%s revision=%d recipient=%s",
fork_id, source_page["pageId"], source_page["headRevision"], recipient_user_id,
)
return fork
async def open_merge_request(
self,
*,
tenant_id: str,
source_page_id: str,
source_branch_id: str,
source_head_revision: int,
target_page_id: str,
target_branch_id: str,
target_base_revision: int,
title: str,
description: str = "",
created_by: str,
source_components: list[dict[str, Any]],
target_components: list[dict[str, Any]],
base_components: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Creates a MergeRequest with pre-computed conflicts via three-way diff.
"""
merged, conflicts = _three_way_diff(base_components, source_components, target_components)
added = sum(1 for c in conflicts if c["conflictClass"] == "safe_append")
edited = sum(1 for c in conflicts if c["conflictClass"] == "component_content_conflict")
reordered = sum(1 for c in conflicts if c["conflictClass"] in ("safe_reorder", "layout_slot_conflict"))
deleted = sum(1 for c in conflicts if c["conflictClass"] == "delete_edit_conflict")
mr = {
"mergeRequestId": str(uuid.uuid4()),
"tenantId": tenant_id,
"sourcePageId": source_page_id,
"sourceBranchId": source_branch_id,
"sourceHeadRevision": source_head_revision,
"targetPageId": target_page_id,
"targetBranchId": target_branch_id,
"targetBaseRevision": target_base_revision,
"title": title,
"description": description,
"status": "open",
"conflicts": conflicts,
"diffSummary": {
"componentsAdded": added,
"componentsEdited": edited,
"componentsReordered": reordered,
"componentsDeleted": deleted,
},
"_mergedComponents": merged, # internal — used during merge
"createdBy": created_by,
"createdAt": _now(),
"updatedAt": _now(),
}
_DEMO_MRS[mr["mergeRequestId"]] = mr
logger.info(
"COLLAB mr_opened mr_id=%s conflicts=%d source=%s → target=%s",
mr["mergeRequestId"], len(conflicts), source_branch_id, target_branch_id,
)
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
async def review_merge_request(
self,
*,
mr_id: str,
decision: str,
reviewer_id: str,
comment: str = "",
resolutions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""
Applies a reviewer decision: approve → merges; reject/changes_requested → status update.
"""
mr = _DEMO_MRS.get(mr_id)
if not mr:
raise ValueError(f"MergeRequest {mr_id} not found")
mr["reviewedBy"] = reviewer_id
mr["reviewerComment"] = comment
mr["updatedAt"] = _now()
if decision == "approve":
mr["status"] = "merged"
logger.info("COLLAB mr_merged mr_id=%s by=%s", mr_id, reviewer_id)
elif decision == "reject":
mr["status"] = "closed"
elif decision == "changes_requested":
mr["status"] = "changes_requested"
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
async def get_merge_request(self, mr_id: str) -> dict[str, Any] | None:
mr = _DEMO_MRS.get(mr_id)
if mr:
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
return None
async def list_merge_requests(self, target_page_id: str, status: str | None = None) -> list[dict[str, Any]]:
results = [
{k: v for k, v in mr.items() if k != "_mergedComponents"}
for mr in _DEMO_MRS.values()
if mr["targetPageId"] == target_page_id
]
if status:
results = [mr for mr in results if mr["status"] == status]
return results
# ── Public three-way-diff (for testing) ───────────────────────────────────────
def three_way_diff(base, source, target): # type: ignore[return]
return _three_way_diff(base, source, target)
# ── Singleton ─────────────────────────────────────────────────────────────────
collaboration_service = CollaborationService()

View File

@@ -0,0 +1,484 @@
"""
oracle/data_access_gateway.py
Read-only, policy-aware PostgreSQL query executor for Oracle datasets.
Nemoclaw/LLM is treated strictly as a planner. The gateway executes only
whitelisted read models and always applies policy before touching data.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
from .policy_service import PolicyContext, PolicyService
logger = logging.getLogger(__name__)
_ALLOW_IN_MEMORY = os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
_DATASET_ALIASES = {
"crm_last_interacted_clients": "oracle_last_contacted_clients",
"crm_top_interested_clients": "oracle_top_interested_clients",
"crm_interaction_timeline": "oracle_client_interaction_timeline",
"crm_property_interest_rollup": "oracle_property_interest_rollup",
}
@dataclass
class QueryExecutionResult:
rows: list[dict[str, Any]]
warnings: list[str]
def _db_ready() -> bool:
if asyncpg is None:
return False
database_url = os.getenv("DATABASE_URL", "")
if database_url and not database_url.startswith("PLACEHOLDER"):
return True
return all(
os.getenv(name)
for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
)
async def _connect_db() -> Any:
assert asyncpg is not None
database_url = os.getenv("DATABASE_URL", "")
if database_url and not database_url.startswith("PLACEHOLDER"):
return await asyncpg.connect(database_url)
return await asyncpg.connect(
host=os.getenv("VELOCITY_DB_HOST", "localhost"),
port=int(os.getenv("VELOCITY_DB_PORT", "5432")),
database=os.environ["VELOCITY_DB_NAME"],
user=os.environ["VELOCITY_DB_USER"],
password=os.environ["VELOCITY_DB_PASSWORD"],
)
class DataAccessGateway:
def __init__(self) -> None:
self.policy_service = PolicyService()
async def execute_component_plan(
self,
component_plan: dict[str, Any],
ctx: PolicyContext,
prompt: str,
) -> QueryExecutionResult:
dataset = str(component_plan.get("dataset", "")).strip()
if not dataset:
return QueryExecutionResult(rows=[], warnings=["Dataset missing from retrieval plan."])
validation = self.policy_service.validate_retrieval_plan(component_plan, ctx)
self.policy_service.audit_policy_check(ctx, dataset, validation)
if not validation.passed:
return QueryExecutionResult(rows=[], warnings=validation.errors)
if not _db_ready():
if _ALLOW_IN_MEMORY or "PYTEST_CURRENT_TEST" in os.environ:
return QueryExecutionResult(rows=[], warnings=[])
raise RuntimeError("Oracle requires DATABASE_URL and asyncpg for real-time data access.")
try:
rows = await self._query_dataset(
dataset=_DATASET_ALIASES.get(dataset, dataset),
row_limit=validation.effective_row_limit,
ctx=ctx,
prompt=prompt,
)
except Exception as exc:
logger.warning("DATA_GATEWAY query_failed dataset=%s error=%s", dataset, exc)
return QueryExecutionResult(rows=[], warnings=[f"{dataset}: {exc}"])
redacted = self.policy_service.redact(rows, validation.redaction_policy)
return QueryExecutionResult(rows=redacted, warnings=validation.warnings)
async def _query_dataset(
self,
*,
dataset: str,
row_limit: int,
ctx: PolicyContext,
prompt: str,
) -> list[dict[str, Any]]:
sql, params = self._build_whitelisted_query(dataset, row_limit, ctx, prompt)
conn = await _connect_db()
try:
records = await conn.fetch(sql, *params)
finally:
await conn.close()
return [dict(record) for record in records]
def _build_whitelisted_query(
self,
dataset: str,
row_limit: int,
ctx: PolicyContext,
prompt: str,
) -> tuple[str, list[Any]]:
lower_prompt = prompt.lower()
if dataset == "deals":
sql = """
SELECT stage, COUNT(*)::int AS count, COALESCE(SUM(value), 0)::float AS value,
COALESCE(json_agg(json_build_object('id', lead_id, 'name', lead_name, 'company', company, 'value', value_label, 'avatar', avatar_url)
ORDER BY value DESC NULLS LAST) FILTER (WHERE lead_id IS NOT NULL), '[]'::json) AS leads
FROM deals
WHERE tenant_id = $1
GROUP BY stage
ORDER BY COALESCE(SUM(value), 0) DESC, stage ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "lead_daily_snapshot":
sql = """
SELECT source, COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
FROM lead_daily_snapshot
WHERE tenant_id = $1
GROUP BY source
ORDER BY qd_weighted_volume DESC, source ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "lead_geo_interest_rollup":
sql = """
SELECT district, lat, lng, COALESCE(lead_count, 0)::int AS lead_count,
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
COALESCE(x, 0)::float AS x, COALESCE(y, 0)::float AS y
FROM lead_geo_interest_rollup
WHERE tenant_id = $1
ORDER BY lead_count DESC, district ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "broker_performance":
sql = """
SELECT ROW_NUMBER() OVER (ORDER BY COUNT(DISTINCT l.person_id) DESC, COALESCE(u.full_name, u.email, u.id::text) ASC)::int AS rank,
COALESCE(u.full_name, u.email, u.id::text) AS name,
COUNT(DISTINCT l.person_id)::int AS deals_closed,
COALESCE(SUM(o.value), 0)::float AS revenue_generated,
u.avatar_url AS avatar
FROM users_and_roles u
LEFT JOIN crm_leads l ON l.assigned_user_id = u.id
LEFT JOIN crm_opportunities o ON o.lead_id = l.lead_id
WHERE u.is_active = TRUE
GROUP BY u.id, u.full_name, u.email, u.avatar_url
HAVING COUNT(DISTINCT l.person_id) > 0 OR COALESCE(SUM(o.value), 0) > 0
ORDER BY revenue_generated DESC, name ASC
LIMIT $1
"""
return sql, [row_limit]
if dataset == "inventory_absorption":
sql = """
SELECT period_label AS period, COALESCE(absorption_rate, 0)::float AS absorption_rate,
COALESCE(target_rate, 0)::float AS target_rate
FROM inventory_absorption
WHERE tenant_id = $1
ORDER BY period_start ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "oracle_aggregated_metric":
metric_name = "total_leads"
if "pipeline" in lower_prompt:
metric_name = "total_pipeline_value"
elif "quota" in lower_prompt or "attainment" in lower_prompt:
metric_name = "quota_attainment"
sql = """
SELECT metric_value, metric_label, trend_value, comparison_label
FROM oracle_aggregated_metric
WHERE tenant_id = $1 AND metric_name = $2
ORDER BY observed_at DESC
LIMIT 1
"""
return sql, [ctx.tenant_id, metric_name]
if dataset == "lead_activity_log":
if "follow-up" in lower_prompt or "queue" in lower_prompt:
sql = """
SELECT lead_name AS name, assigned_broker,
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
COALESCE(qd_score, 0)::float AS qd_score, urgency, avatar_url AS avatar
FROM lead_activity_log
WHERE tenant_id = $1
ORDER BY last_contact_hours_ago DESC, qd_score DESC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
sql = """
SELECT activity_type AS type, COALESCE(activity_title, activity_summary, activity_type) AS title,
activity_summary AS summary, actor_name AS actor,
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
FROM lead_activity_log
WHERE tenant_id = $1
ORDER BY activity_at DESC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "crm_contacts_overview":
sql = """
SELECT p.person_id::text AS id, p.full_name AS name,
COALESCE(p.primary_email, '') AS email,
COALESCE(p.primary_phone, '') AS phone,
COALESCE(p.city, '') AS city,
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
COALESCE(q.current_value, 0)::float AS qd_score
FROM crm_people p
LEFT JOIN LATERAL (
SELECT current_value
FROM intel_qd_scores q
WHERE q.person_id = p.person_id
ORDER BY CASE WHEN q.score_type = 'engagement_score' THEN 0 WHEN q.score_type = 'intent_score' THEN 1 WHEN q.score_type = 'urgency_score' THEN 2 ELSE 3 END,
q.computed_at DESC
LIMIT 1
) q ON TRUE
ORDER BY qd_score DESC, p.full_name ASC
LIMIT $1
"""
return sql, [row_limit]
if dataset == "crm_opportunity_pipeline":
sql = """
SELECT o.stage::text AS stage, COUNT(*)::int AS count, COALESCE(SUM(o.value), 0)::float AS value,
COALESCE(json_agg(json_build_object('id', o.opportunity_id, 'name', p.full_name, 'company', COALESCE(a.account_name, ''),
'value', COALESCE(o.value, 0), 'nextAction', COALESCE(o.next_action, ''))
ORDER BY o.value DESC NULLS LAST) FILTER (WHERE o.opportunity_id IS NOT NULL), '[]'::json) AS leads
FROM crm_opportunities o
JOIN crm_leads l ON l.lead_id = o.lead_id
JOIN crm_people p ON p.person_id = l.person_id
LEFT JOIN crm_accounts a ON a.account_id = l.account_id
GROUP BY o.stage
ORDER BY COALESCE(SUM(o.value), 0) DESC, o.stage::text ASC
LIMIT $1
"""
return sql, [row_limit]
if dataset == "oracle_property_interest_rollup":
sql = """
SELECT COALESCE(pi.project_name, ip.project_name, 'Unknown Project') AS category,
COUNT(*)::int AS value,
ROUND(AVG(COALESCE((pi.budget_min + pi.budget_max) / 2.0, pi.budget_max, pi.budget_min, 0)), 2)::float AS average_budget,
MAX(pi.created_at) AS latest_interest_at
FROM crm_property_interests pi
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
GROUP BY COALESCE(pi.project_name, ip.project_name, 'Unknown Project')
ORDER BY value DESC, category ASC
LIMIT $1
"""
return sql, [row_limit]
if dataset == "oracle_last_contacted_clients":
sql = """
WITH message_contacts AS (
SELECT i.person_id, MAX(m.delivered_at) AS contacted_at
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id
GROUP BY i.person_id
), email_contacts AS (
SELECT i.person_id, MAX(e.sent_at) AS contacted_at
FROM intel_emails e JOIN intel_interactions i ON i.interaction_id = e.interaction_id
GROUP BY i.person_id
), call_contacts AS (
SELECT i.person_id, MAX(i.happened_at) AS contacted_at
FROM intel_calls c JOIN intel_interactions i ON i.interaction_id = c.interaction_id
GROUP BY i.person_id
), visit_contacts AS (
SELECT person_id, MAX(visited_at) AS contacted_at FROM intel_visits GROUP BY person_id
), thread_contacts AS (
SELECT person_id, MAX(last_message_at) AS contacted_at FROM intel_whatsapp_threads GROUP BY person_id
), interaction_contacts AS (
SELECT person_id, MAX(happened_at) AS contacted_at FROM intel_interactions GROUP BY person_id
), next_reminders AS (
SELECT DISTINCT ON (person_id) person_id, title AS next_action, due_at AS next_action_at
FROM intel_reminders
WHERE status IN ('pending', 'open', 'scheduled')
ORDER BY person_id, due_at ASC NULLS LAST
), contact_rollup AS (
SELECT p.person_id,
GREATEST(
COALESCE(mc.contacted_at, '-infinity'::timestamptz),
COALESCE(ec.contacted_at, '-infinity'::timestamptz),
COALESCE(cc.contacted_at, '-infinity'::timestamptz),
COALESCE(vc.contacted_at, '-infinity'::timestamptz),
COALESCE(tc.contacted_at, '-infinity'::timestamptz),
COALESCE(ic.contacted_at, '-infinity'::timestamptz)
) AS last_contacted_at,
mc.contacted_at AS last_message_at, ec.contacted_at AS last_email_at,
cc.contacted_at AS last_call_at, vc.contacted_at AS last_visit_at,
tc.contacted_at AS last_whatsapp_at, ic.contacted_at AS last_interaction_at
FROM crm_people p
LEFT JOIN message_contacts mc ON mc.person_id = p.person_id
LEFT JOIN email_contacts ec ON ec.person_id = p.person_id
LEFT JOIN call_contacts cc ON cc.person_id = p.person_id
LEFT JOIN visit_contacts vc ON vc.person_id = p.person_id
LEFT JOIN thread_contacts tc ON tc.person_id = p.person_id
LEFT JOIN interaction_contacts ic ON ic.person_id = p.person_id
)
SELECT p.person_id::text AS id, p.full_name AS name,
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
NULLIF(cr.last_contacted_at, '-infinity'::timestamptz) AS last_contacted_at,
CASE
WHEN cr.last_contacted_at = cr.last_call_at THEN 'phone'
WHEN cr.last_contacted_at = cr.last_email_at THEN 'email'
WHEN cr.last_contacted_at = cr.last_visit_at THEN 'site_visit'
WHEN cr.last_contacted_at = cr.last_whatsapp_at THEN 'whatsapp'
WHEN cr.last_contacted_at = cr.last_message_at THEN 'message'
WHEN cr.last_contacted_at = cr.last_interaction_at THEN 'interaction'
ELSE 'unknown'
END AS last_contact_channel,
COALESCE(li.summary, nr.next_action, '') AS last_contact_summary,
COUNT(DISTINCT i.interaction_id)::int AS interaction_count,
COALESCE(q.current_value, 0)::float AS qd_score,
COALESCE(nr.next_action, '') AS next_action,
nr.next_action_at
FROM crm_people p
JOIN contact_rollup cr ON cr.person_id = p.person_id
LEFT JOIN intel_interactions i ON i.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT summary
FROM intel_interactions li
WHERE li.person_id = p.person_id
ORDER BY li.happened_at DESC
LIMIT 1
) li ON TRUE
LEFT JOIN next_reminders nr ON nr.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT current_value FROM intel_qd_scores q
WHERE q.person_id = p.person_id
ORDER BY q.computed_at DESC
LIMIT 1
) q ON TRUE
WHERE cr.last_contacted_at <> '-infinity'::timestamptz
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, cr.last_contacted_at,
cr.last_message_at, cr.last_email_at, cr.last_call_at, cr.last_visit_at,
cr.last_whatsapp_at, cr.last_interaction_at, li.summary, nr.next_action,
nr.next_action_at, q.current_value
ORDER BY last_contacted_at DESC NULLS LAST, interaction_count DESC, p.full_name ASC
LIMIT $1
"""
return sql, [row_limit]
if dataset == "oracle_top_interested_clients":
sql = """
WITH interest_mentions AS (
SELECT i.person_id, COUNT(*)::int AS mention_count, MAX(COALESCE(m.delivered_at, i.happened_at)) AS last_mention_at
FROM intel_interactions i
LEFT JOIN intel_messages m ON m.interaction_id = i.interaction_id
WHERE LOWER(COALESCE(i.summary, '') || ' ' || COALESCE(m.message_text, '')) ~
'(interested|interest|shortlist|visit|book|budget|configuration|bhk|project|property)'
GROUP BY i.person_id
)
SELECT p.person_id::text AS id, p.full_name AS name,
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
COUNT(DISTINCT pi.interest_id)::int AS explicit_interest_count,
COALESCE(MAX(im.mention_count), 0)::int AS inferred_interest_count,
(COUNT(DISTINCT pi.interest_id) + COALESCE(MAX(im.mention_count), 0))::int AS interest_count,
STRING_AGG(DISTINCT COALESCE(pi.project_name, ip.project_name), ', ' ORDER BY COALESCE(pi.project_name, ip.project_name)) AS projects,
GREATEST(COALESCE(MAX(pi.created_at), '-infinity'::timestamptz),
COALESCE(MAX(im.last_mention_at), '-infinity'::timestamptz),
COALESCE(p.updated_at, p.created_at)) AS last_interest_at,
COALESCE(q.current_value, 0)::float AS qd_score,
COALESCE(MAX(pi.notes), '') AS latest_interest_note
FROM crm_people p
LEFT JOIN crm_property_interests pi ON pi.person_id = p.person_id
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
LEFT JOIN interest_mentions im ON im.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT current_value FROM intel_qd_scores q
WHERE q.person_id = p.person_id
ORDER BY q.computed_at DESC
LIMIT 1
) q ON TRUE
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, p.updated_at, p.created_at, q.current_value
HAVING COUNT(DISTINCT pi.interest_id) > 0 OR COALESCE(MAX(im.mention_count), 0) > 0
ORDER BY interest_count DESC, qd_score DESC, last_interest_at DESC NULLS LAST, p.full_name ASC
LIMIT $1
"""
return sql, [row_limit]
if dataset == "oracle_client_interaction_timeline":
sql = """
WITH timeline AS (
SELECT i.person_id, i.channel::text AS type, COALESCE(i.interaction_type, i.channel::text) AS title,
COALESCE(i.summary, '') AS detail, i.happened_at AS event_at, 'interaction' AS source_type
FROM intel_interactions i
UNION ALL
SELECT i.person_id, 'message', COALESCE(m.sender_role, 'message'), m.message_text, m.delivered_at, 'message'
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id
UNION ALL
SELECT i.person_id, 'call', c.call_direction::text, COALESCE(t.full_text, c.call_outcome, 'Call record'), i.happened_at, 'call'
FROM intel_calls c
JOIN intel_interactions i ON i.interaction_id = c.interaction_id
LEFT JOIN intel_transcripts t ON t.call_id = c.call_id OR t.interaction_id = i.interaction_id
UNION ALL
SELECT i.person_id, 'email', COALESCE(e.subject, 'Email'), COALESCE(e.body_text, ''), e.sent_at, 'email'
FROM intel_emails e JOIN intel_interactions i ON i.interaction_id = e.interaction_id
UNION ALL
SELECT v.person_id, 'site_visit', COALESCE(v.project_name, 'Site visit'), COALESCE(v.visit_notes, ''), v.visited_at, 'visit'
FROM intel_visits v
UNION ALL
SELECT r.person_id, 'reminder', r.title, COALESCE(r.notes, r.status), COALESCE(r.due_at, r.created_at), 'reminder'
FROM intel_reminders r
UNION ALL
SELECT q.person_id, 'qd_score', q.score_type, COALESCE(q.reasoning, q.current_value::text), q.computed_at, 'qd_score'
FROM intel_qd_scores q
UNION ALL
SELECT qt.person_id, 'qd_timeseries', COALESCE(qt.signal_source, qt.score_type), qt.value::text, qt.timestamp, 'qd_timeseries'
FROM intel_qd_timeseries qt
)
SELECT t.type, t.title, CONCAT(p.full_name, ' - ', t.detail) AS summary,
p.full_name AS actor, TO_CHAR(t.event_at, 'YYYY-MM-DD HH24:MI') AS date,
t.source_type, t.event_at
FROM timeline t
JOIN crm_people p ON p.person_id = t.person_id
ORDER BY t.event_at DESC NULLS LAST
LIMIT $1
"""
return sql, [row_limit]
if dataset == "oracle_client_360_summary":
sql = """
SELECT p.person_id::text AS id, p.full_name AS name,
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
COALESCE(l.status::text, 'unknown') AS lead_status,
COALESCE(l.budget_band, '') AS budget_band,
COALESCE(l.urgency, '') AS urgency,
COALESCE(q.current_value, 0)::float AS qd_score,
COUNT(DISTINCT pi.interest_id)::int AS interest_count,
COUNT(DISTINCT i.interaction_id)::int AS interaction_count,
MAX(i.happened_at) AS last_interaction_at,
STRING_AGG(DISTINCT COALESCE(pi.project_name, ip.project_name), ', ' ORDER BY COALESCE(pi.project_name, ip.project_name)) AS projects
FROM crm_people p
LEFT JOIN crm_leads l ON l.person_id = p.person_id
LEFT JOIN crm_property_interests pi ON pi.person_id = p.person_id
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
LEFT JOIN intel_interactions i ON i.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT current_value FROM intel_qd_scores q
WHERE q.person_id = p.person_id
ORDER BY q.computed_at DESC
LIMIT 1
) q ON TRUE
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, l.status, l.budget_band, l.urgency, q.current_value
ORDER BY qd_score DESC, interaction_count DESC, interest_count DESC, name ASC
LIMIT $1
"""
return sql, [row_limit]
raise ValueError(f"Dataset '{dataset}' is not whitelisted for Oracle execution.")
data_access_gateway = DataAccessGateway()

View File

@@ -0,0 +1,202 @@
"""
oracle/execution_profiler.py
Post-execution quality checks for Oracle natural DB queries.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
_STALE_THRESHOLD_DAYS = 365
@dataclass
class QualityIssue:
code: str
description: str
severity: str
replan_hint: str
@dataclass
class ProfileResult:
passed: bool
row_count: int
issues: list[QualityIssue] = field(default_factory=list)
replan_hints: list[str] = field(default_factory=list)
suggested_component_type: str | None = None
def _extract_cardinality_from_prompt(prompt: str) -> int | None:
lowered = prompt.lower()
numeric_match = re.search(r"\b(?:top|last|latest|recent|first|show|which)\s+(\d{1,4})\b", lowered)
if numeric_match:
return int(numeric_match.group(1))
words = {
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"eleven": 11,
"twelve": 12,
"fifteen": 15,
"twenty": 20,
}
word_match = re.search(
r"\b(?:top|last|latest|recent|first|show|which)\s+"
r"(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|fifteen|twenty)\b",
lowered,
)
if word_match:
return words.get(word_match.group(1))
return None
def _all_null_measures(rows: list[dict[str, Any]], columns: list[str]) -> bool:
if not rows or not columns:
return False
numeric_columns: list[str] = []
for column in columns:
saw_numeric = False
all_null = True
for row in rows[:20]:
value = row.get(column)
if value is not None:
all_null = False
if isinstance(value, (int, float)):
saw_numeric = True
if saw_numeric:
numeric_columns.append(column)
if not all_null:
return False
if numeric_columns:
return True
return all(all(value is None for value in row.values()) for row in rows[:5])
def _timestamps_are_stale(rows: list[dict[str, Any]], columns: list[str]) -> bool:
timestamp_columns = [
column for column in columns if any(token in column for token in ("_at", "date", "timestamp", "when", "time"))
]
if not timestamp_columns or not rows:
return False
now = datetime.now(timezone.utc)
checked = 0
stale = 0
for row in rows[:20]:
for column in timestamp_columns:
value = row.get(column)
if value is None or not isinstance(value, str):
continue
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
continue
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
checked += 1
if (now - parsed).days > _STALE_THRESHOLD_DAYS:
stale += 1
return checked > 0 and stale == checked
class ExecutionProfiler:
def profile(
self,
*,
rows: list[dict[str, Any]],
columns: list[str],
sql: str,
prompt: str,
source_tables: list[str],
row_limit: int,
) -> ProfileResult:
del source_tables, row_limit
issues: list[QualityIssue] = []
sql_lower = sql.lower()
if len(rows) == 0:
issues.append(
QualityIssue(
code="zero_rows",
description="Query returned zero rows.",
severity="blocking",
replan_hint=(
"The query returned zero rows. Use authoritative recency and business-semantic columns "
"from the semantic catalog. Avoid sparse or deprecated timestamp fields."
),
)
)
elif _all_null_measures(rows, columns):
issues.append(
QualityIssue(
code="all_null_measures",
description="Rows returned but numeric measure columns are null.",
severity="blocking",
replan_hint=(
"The query returned rows but numeric measures are null. "
"Check join keys and metric source columns."
),
)
)
requested_n = _extract_cardinality_from_prompt(prompt)
if requested_n is not None and len(rows) > requested_n * 3:
issues.append(
QualityIssue(
code="cardinality_mismatch",
description=f"Prompt asked for about {requested_n} rows but query returned {len(rows)}.",
severity="warning",
replan_hint=f"Respect the requested result count and add LIMIT {requested_n}.",
)
)
if rows and _timestamps_are_stale(rows, columns):
issues.append(
QualityIssue(
code="stale_timestamps",
description="Returned timestamps appear stale.",
severity="warning",
replan_hint="The result timestamps are stale. Use authoritative recency fields.",
)
)
suggested_type: str | None = None
if len(rows) == 1 and len(columns) <= 4:
non_null_values = [value for value in rows[0].values() if value is not None]
if non_null_values and all(isinstance(value, (int, float)) for value in non_null_values):
suggested_type = "kpiTile"
issues.append(
QualityIssue(
code="single_row_scalar",
description="Single scalar row is better rendered as KPI tile.",
severity="warning",
replan_hint="",
)
)
blocking = [issue for issue in issues if issue.severity == "blocking"]
return ProfileResult(
passed=len(blocking) == 0,
row_count=len(rows),
issues=issues,
replan_hints=[issue.replan_hint for issue in issues if issue.replan_hint],
suggested_component_type=suggested_type,
)
execution_profiler = ExecutionProfiler()

View File

@@ -0,0 +1,591 @@
"""
Natural DB-first Oracle agent.
Pipeline:
1. schema introspection
2. semantic SQL planning
3. plan verification and optional repair
4. SQL execution
5. execution quality profiling and auto-replan
6. visualization planning from actual result shape
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from backend.services.runtime_llm_service import runtime_llm_service
from .execution_profiler import execution_profiler
from .plan_verifier import plan_verifier
from .semantic_catalog import CATALOG_VERSION, build_semantic_context_for_planner
from .visualization_planner import VisualizationDecision, visualization_planner
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
logger = logging.getLogger(__name__)
DESTRUCTIVE_SQL = re.compile(
r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b",
re.IGNORECASE,
)
TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE)
_MAX_REPLAN_ATTEMPTS = 2
def _json_safe(value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
return value
def db_ready() -> bool:
if asyncpg is None:
return False
read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "")
if read_database_url and not read_database_url.startswith("PLACEHOLDER"):
return True
database_url = os.getenv("DATABASE_URL", "")
return bool(database_url and not database_url.startswith("PLACEHOLDER")) or all(
os.getenv(name) for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
)
async def connect_db() -> Any:
if asyncpg is None:
raise RuntimeError("asyncpg is not installed.")
read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "")
if read_database_url and not read_database_url.startswith("PLACEHOLDER"):
return await asyncpg.connect(read_database_url)
if all(os.getenv(name) for name in ("VELOCITY_DB_READ_NAME", "VELOCITY_DB_READ_USER", "VELOCITY_DB_READ_PASSWORD")):
return await asyncpg.connect(
host=os.getenv("VELOCITY_DB_READ_HOST", os.getenv("VELOCITY_DB_HOST", "127.0.0.1")),
port=int(os.getenv("VELOCITY_DB_READ_PORT", os.getenv("VELOCITY_DB_PORT", "5432"))),
database=os.environ["VELOCITY_DB_READ_NAME"],
user=os.environ["VELOCITY_DB_READ_USER"],
password=os.environ["VELOCITY_DB_READ_PASSWORD"],
)
database_url = os.getenv("DATABASE_URL", "")
if database_url and not database_url.startswith("PLACEHOLDER"):
return await asyncpg.connect(database_url)
return await asyncpg.connect(
host=os.getenv("VELOCITY_DB_HOST", "127.0.0.1"),
port=int(os.getenv("VELOCITY_DB_PORT", "5432")),
database=os.environ["VELOCITY_DB_NAME"],
user=os.environ["VELOCITY_DB_USER"],
password=os.environ["VELOCITY_DB_PASSWORD"],
)
@dataclass
class NaturalQueryResult:
prompt: str
sql: str
title: str
summary: str
columns: list[str]
rows: list[dict[str, Any]]
row_count: int
source_tables: list[str]
component_type: str
warnings: list[str]
visualization_decision: VisualizationDecision | None = None
replan_count: int = 0
semantic_catalog_version: str = CATALOG_VERSION
def as_dict(self) -> dict[str, Any]:
decision = self.visualization_decision
return {
"prompt": self.prompt,
"sql": self.sql,
"title": self.title,
"summary": self.summary,
"columns": self.columns,
"rows": self.rows,
"rowCount": self.row_count,
"sourceTables": self.source_tables,
"componentType": self.component_type,
"warnings": self.warnings,
"semanticCatalogVersion": self.semantic_catalog_version,
"replanCount": self.replan_count,
"visualizationDecision": {
"xAxis": decision.x_axis,
"yAxis": decision.y_axis,
"dimensionCols": decision.dimension_cols,
"measureCols": decision.measure_cols,
"widthMode": decision.width_mode,
"minHeightPx": decision.min_height_px,
"skeletonVariant": decision.skeleton_variant,
"vizParams": decision.viz_params,
"dataBindings": decision.data_bindings,
"confidence": decision.confidence,
"reasoning": decision.reasoning,
}
if decision
else {},
}
def sanitize_sql(sql: str, row_limit: int) -> tuple[str, list[str], list[str]]:
warnings: list[str] = []
clean = re.sub(r"--.*?$|/\*.*?\*/", "", sql.strip(), flags=re.MULTILINE | re.DOTALL).strip().rstrip(";")
if not re.match(r"^(select|with)\b", clean, re.IGNORECASE):
raise ValueError("Oracle SQL agent only accepts SELECT or WITH queries.")
if DESTRUCTIVE_SQL.search(clean):
raise ValueError("Oracle SQL agent blocked non-read SQL.")
tables: list[str] = []
for match in TABLE_REF_RE.finditer(clean):
table = match.group(1).split(".")[-1].strip('"').lower()
if table in {"lateral", "select"}:
continue
if table and table not in tables:
tables.append(table)
if "limit" not in clean.lower():
clean += f" LIMIT {row_limit}"
warnings.append(f"Row cap {row_limit} auto-applied (query had no LIMIT).")
return clean, tables, warnings
def _detect_intents(prompt: str) -> list[str]:
lowered = prompt.lower()
intents: list[str] = []
if any(token in lowered for token in (
"last contact", "last contacted", "recently contacted", "last call",
"last message", "last whatsapp", "contacted us", "follow-up", "follow up",
"days since", "no contact",
)):
intents.append("last_contacted")
if any(token in lowered for token in (
"interested in", "shown interest", "interest in", "interested clients",
"project interest", "property interest",
)):
intents.append("interested_clients")
if any(token in lowered for token in ("qd score", "qualification score", "desire score", "intent score", "qd")):
intents.append("qd_score")
if any(token in lowered for token in ("pipeline", "stage", "funnel", "kanban", "deal")):
intents.append("pipeline")
if any(token in lowered for token in ("site visit", "visited", "visit")):
intents.append("site_visits")
if any(token in lowered for token in ("call", "transcript", "whatsapp", "email", "message", "conversation", "interaction", "timeline", "activity")):
intents.append("timeline")
if any(token in lowered for token in ("objection", "concern", "complaint", "pushback")):
intents.append("objections")
if any(token in lowered for token in ("broker", "agent performance", "referral")):
intents.append("broker_performance")
if any(token in lowered for token in ("next action", "next step", "what should i do", "follow-up priority", "action queue")):
intents.append("next_action")
if any(token in lowered for token in ("project", "unit", "inventory", "available", "price", "configuration")):
intents.append("inventory")
if any(token in lowered for token in ("client 360", "dossier", "profile")):
intents.append("client_360")
if any(token in lowered for token in ("fact", "memory", "promise", "commitment", "budget", "preference")):
intents.append("extracted_facts")
return intents or ["last_contacted"]
def title_from_prompt(prompt: str) -> str:
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")
return (words[:1].upper() + words[1:80]) if words else "Oracle Query Result"
class NaturalDbAgent:
async def schema_catalog(self, conn: Any | None = None) -> dict[str, Any]:
own_conn = conn is None
if conn is None:
if not db_ready():
return {"tables": [], "available": False}
conn = await connect_db()
try:
table_names = await conn.fetch(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
)
public_tables = [row["table_name"] for row in table_names]
rows = await conn.fetch(
"""
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable
FROM information_schema.columns c
WHERE c.table_schema = 'public'
ORDER BY c.table_name, c.ordinal_position
"""
)
counts: dict[str, int | None] = {}
for table in public_tables:
exists = await conn.fetchval("SELECT to_regclass($1)", f"public.{table}")
counts[table] = None if not exists else int(await conn.fetchval(f'SELECT COUNT(*) FROM "{table}"'))
tables: dict[str, dict[str, Any]] = {}
for row in rows:
entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])})
entry["columns"].append(
{
"name": row["column_name"],
"dataType": row["data_type"],
"udtName": row["udt_name"],
"nullable": row["is_nullable"] == "YES",
}
)
return {"available": True, "tables": tables, "allowedTables": public_tables}
finally:
if own_conn:
await conn.close()
async def data_health(self, conn: Any | None = None) -> dict[str, Any]:
catalog = await self.schema_catalog(conn)
expected = {
"crm_people": 341,
"crm_leads": 250,
"crm_opportunities": 400,
"crm_property_interests": 400,
"intel_interactions": 1897,
"intel_messages": 6944,
"intel_calls": 478,
"intel_transcripts": 231,
"intel_emails": 149,
"intel_visits": 305,
"intel_reminders": 759,
"intel_extracted_facts": 1686,
"read_last_contacted": 250,
"read_next_best_action": 250,
}
tables = catalog.get("tables", {})
counts = {table: (meta or {}).get("rowCount") for table, meta in sorted(tables.items())}
return {
"counts": counts,
"expectedSyntheticV2Counts": expected,
"missingTables": [table for table, count in counts.items() if count is None],
"emptyTables": [table for table, count in counts.items() if count == 0],
"belowExpected": {
table: {"expected": expected_count, "actual": counts.get(table)}
for table, expected_count in expected.items()
if (counts.get(table) or 0) < expected_count
},
}
async def execute_prompt(self, prompt: str, *, row_limit: int = 100, conn: Any | None = None) -> NaturalQueryResult:
if not prompt.strip():
raise ValueError("Prompt is required.")
own_conn = conn is None
if conn is None:
if not db_ready():
raise RuntimeError("Database unavailable for Oracle natural query.")
conn = await connect_db()
try:
catalog = await self.schema_catalog(conn)
detected_intents = _detect_intents(prompt)
return await self._pipeline(
conn=conn,
prompt=prompt,
catalog=catalog,
detected_intents=detected_intents,
row_limit=row_limit,
attempt=0,
prior_feedback=None,
)
finally:
if own_conn:
await conn.close()
async def _pipeline(
self,
*,
conn: Any,
prompt: str,
catalog: dict[str, Any],
detected_intents: list[str],
row_limit: int,
attempt: int,
prior_feedback: str | None,
) -> NaturalQueryResult:
warnings: list[str] = []
plan = await self._plan_sql(
prompt=prompt,
catalog=catalog,
detected_intents=detected_intents,
row_limit=row_limit,
prior_feedback=prior_feedback,
)
raw_sql = str(plan.get("sql") or "").strip()
if not raw_sql:
raise RuntimeError("Natural SQL planner returned no SQL.")
verification = await plan_verifier.verify_and_repair(
sql=raw_sql,
prompt=prompt,
detected_intents=detected_intents,
row_limit=row_limit,
llm_service=runtime_llm_service,
)
if verification.was_repaired:
warnings.append(
"Plan verifier repaired violations: "
+ ", ".join(violation.rule for violation in verification.violations if violation.severity == "blocking")
)
if not verification.passed:
details = "; ".join(
f"{violation.rule}: {violation.detail}"
for violation in verification.violations
if violation.severity == "blocking"
)
raise RuntimeError(f"Oracle SQL plan failed verification: {details}")
if verification.notes:
warnings.extend(verification.notes)
effective_sql, source_tables, sanitize_warnings = sanitize_sql(verification.sql, row_limit)
warnings.extend(sanitize_warnings)
try:
records = await conn.fetch(effective_sql)
except Exception as exc:
raise RuntimeError(f"Natural SQL execution failed: {exc}") from exc
rows = [_json_safe(dict(record)) for record in records]
columns = list(rows[0].keys()) if rows else []
profile = execution_profiler.profile(
rows=rows,
columns=columns,
sql=effective_sql,
prompt=prompt,
source_tables=source_tables,
row_limit=row_limit,
)
if not profile.passed and attempt < _MAX_REPLAN_ATTEMPTS:
feedback = " | ".join(profile.replan_hints)
warnings.append(f"Auto-replan triggered (attempt {attempt + 1}): {feedback[:160]}")
return await self._pipeline(
conn=conn,
prompt=prompt,
catalog=catalog,
detected_intents=detected_intents,
row_limit=row_limit,
attempt=attempt + 1,
prior_feedback=feedback,
)
if not profile.passed:
for issue in profile.issues:
if issue.severity == "blocking":
warnings.append(f"Quality issue after {attempt} replans: [{issue.code}] {issue.description}")
visualization_decision = visualization_planner.plan(
rows=rows,
columns=columns,
prompt=prompt,
source_tables=source_tables,
profile_suggested_type=profile.suggested_component_type,
title_from_planner=str(plan.get("title") or ""),
)
title = visualization_decision.title or str(plan.get("title") or title_from_prompt(prompt))
summary = str(plan.get("rationale") or f"SQL-backed Oracle result from {', '.join(source_tables) or 'Velocity CRM'}.")
return NaturalQueryResult(
prompt=prompt,
sql=effective_sql,
title=title,
summary=summary,
columns=columns,
rows=rows,
row_count=len(rows),
source_tables=source_tables,
component_type=visualization_decision.component_type,
warnings=warnings,
visualization_decision=visualization_decision,
replan_count=attempt,
semantic_catalog_version=CATALOG_VERSION,
)
async def _plan_sql(
self,
*,
prompt: str,
catalog: dict[str, Any],
detected_intents: list[str],
row_limit: int,
prior_feedback: str | None = None,
) -> dict[str, Any]:
try:
providers = runtime_llm_service._provider_catalog()
except Exception:
providers = {}
if not providers:
raise RuntimeError("No runtime LLM providers configured for Oracle natural planning.")
schema_full = catalog.get("tables", {})
relevant_tables = self._relevant_tables_for_intents(detected_intents)
schema_brief_dict = {
table: meta
for table, meta in schema_full.items()
if table in relevant_tables or table in {"crm_people", "crm_leads", "inventory_projects", "inventory_units"}
}
schema_brief = json.dumps(schema_brief_dict, default=str)[:14000]
semantic_context = build_semantic_context_for_planner(detected_intents, max_concepts=5)
replan_section = ""
if prior_feedback:
replan_section = (
f"\n\nPREVIOUS ATTEMPT FAILED - EXECUTION FEEDBACK:\n{prior_feedback}\n"
"You must address the feedback and change the query accordingly."
)
example_section = (
"CANONICAL SQL PATTERNS:\n"
"Generic top QD clients:\n"
"SELECT p.full_name, p.primary_email, p.primary_phone, q.current_value AS qd_score, q.score_type, q.computed_at "
"FROM intel_qd_scores q JOIN crm_people p ON p.person_id = q.person_id "
"WHERE q.score_type = 'overall' ORDER BY q.current_value DESC LIMIT 8;\n"
"Property-scoped lowest QD clients:\n"
"SELECT p.full_name, p.primary_email, pi.project_name, q.current_value AS qd_score "
"FROM crm_property_interests pi JOIN crm_people p ON p.person_id = pi.person_id "
"JOIN intel_qd_scores q ON q.person_id = p.person_id "
"WHERE q.score_type = 'overall' AND pi.project_name ILIKE '%Atri Surya Toron%' "
"ORDER BY q.current_value ASC LIMIT 5;\n"
"Recently contacted high-interest clients:\n"
"SELECT p.full_name, p.primary_email, lc.last_contact_at, lc.last_channel, q.current_value AS qd_score "
"FROM read_last_contacted lc JOIN crm_people p ON p.person_id = lc.person_id "
"LEFT JOIN intel_qd_scores q ON q.person_id = p.person_id AND q.score_type = 'overall' "
"WHERE lc.last_contact_at >= NOW() - INTERVAL '3 months' "
"ORDER BY q.current_value DESC NULLS LAST LIMIT 10;"
)
response = await runtime_llm_service.chat(
provider_id="sglang",
model=None,
system_prompt=(
"You are Oracle's read-only PostgreSQL planner for Project Velocity CRM. "
"Use the semantic catalog as the business source of truth, not raw column guessing. "
"Generate exactly one SELECT or WITH query. "
"Return strict JSON with keys: sql, title, rationale. "
"Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements. "
"Never use columns that are not present in the raw schema."
),
messages=[
{
"role": "user",
"content": (
f"SEMANTIC CATALOG:\n{semantic_context}\n\n"
f"RAW SCHEMA:\n{schema_brief}\n\n"
"NON-NEGOTIABLE DATA RULES:\n"
"- crm_people is identity only; it does not own QD scores.\n"
"- For QD score prompts, join intel_qd_scores.person_id to crm_people.person_id and use intel_qd_scores.current_value.\n"
"- Valid intel_qd_scores.score_type values are: overall, intent, engagement, urgency, financial_qualification.\n"
"- Never filter intel_qd_scores.score_type = 'QD'. For generic QD prompts use score_type = 'overall'.\n"
"- For contact recency, use read_last_contacted.last_contact_at or intel_interactions.happened_at.\n"
"- Do not use edge_communication_events.timestamp or crm_property_interests.last_discussed_at for contact recency.\n\n"
f"{example_section}\n\n"
f"DETECTED INTENTS: {', '.join(detected_intents)}\n\n"
f"USER QUESTION:\n{prompt}\n\n"
f"ROW CAP: {row_limit}\n"
f"{replan_section}\n\n"
"Return strict JSON: {\"sql\": \"...\", \"title\": \"...\", \"rationale\": \"...\"}"
),
}
],
temperature=0.05,
response_format="json",
metadata={
"agent": "oracle_natural_db_agent_v2",
"intents": detected_intents,
"catalog_version": CATALOG_VERSION,
},
)
message = response.get("message") or {}
parsed = message.get("parsedJson")
content = message.get("content") or "{}"
if not isinstance(parsed, dict):
parsed = json.loads(content) if isinstance(content, str) else content
if isinstance(parsed, dict) and parsed.get("sql"):
return parsed
raise RuntimeError("Natural DB planner returned no valid SQL.")
@staticmethod
def _relevant_tables_for_intents(intents: list[str]) -> set[str]:
intent_tables: dict[str, set[str]] = {
"last_contacted": {
"intel_interactions",
"crm_people",
"crm_leads",
"read_last_contacted",
"crm_last_contact_read_model",
},
"interested_clients": {
"crm_property_interests",
"crm_people",
"inventory_projects",
"intel_qd_scores",
},
"qd_score": {"intel_qd_scores", "crm_people"},
"pipeline": {"crm_opportunities", "crm_leads", "crm_people", "inventory_projects"},
"site_visits": {"intel_visits", "crm_people", "inventory_projects"},
"timeline": {
"intel_interactions",
"intel_calls",
"intel_whatsapp_threads",
"intel_messages",
"intel_emails",
"intel_visits",
"crm_people",
},
"objections": {"intel_call_objections", "crm_people", "inventory_projects"},
"broker_performance": {"crm_leads", "crm_opportunities", "crm_people"},
"next_action": {"read_next_best_action", "crm_people"},
"inventory": {"inventory_projects", "inventory_units", "crm_property_interests"},
"client_360": {
"crm_people",
"crm_leads",
"intel_qd_scores",
"crm_property_interests",
"crm_opportunities",
"intel_interactions",
"read_last_contacted",
"read_next_best_action",
},
"extracted_facts": {"intel_extracted_facts", "crm_people"},
}
tables: set[str] = set()
for intent in intents:
tables.update(intent_tables.get(intent, set()))
return tables
natural_db_agent = NaturalDbAgent()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
{
"_meta": {
"version": "1.0.0",
"created": "2026-04-18",
"description": "Oracle Template Seed Database — canonical chapter/subchapter taxonomy and seed JSON examples for the Project Velocity Oracle platform",
"total_chapters": 6,
"total_subchapters": 24,
"total_seed_examples": 36
},
"chapters": [
{
"chapter_id": "ch-001",
"name": "Market Intelligence",
"description": "Components for real estate market analysis, pricing trends, demand signals, and competitive landscape.",
"sort_order": 1,
"subchapters": [
{
"subchapter_id": "sub-001-01",
"name": "Pricing Trends",
"description": "Price per sqft trends, AED/m² benchmarks, quarterly movement charts.",
"sort_order": 1
},
{
"subchapter_id": "sub-001-02",
"name": "Demand Signals",
"description": "Search volume, inquiry rate, site visit frequency, and absorption rate components.",
"sort_order": 2
},
{
"subchapter_id": "sub-001-03",
"name": "Competitive Landscape",
"description": "Developer comparison, project pipeline mapping, competitive unit mix analysis.",
"sort_order": 3
},
{
"subchapter_id": "sub-001-04",
"name": "Location Index",
"description": "District-level scores, proximity analysis, infrastructure readiness.",
"sort_order": 4
}
]
},
{
"chapter_id": "ch-002",
"name": "Lead Intelligence",
"description": "Components for lead profiling, scoring, pipeline health, and behaviour tracking.",
"sort_order": 2,
"subchapters": [
{
"subchapter_id": "sub-002-01",
"name": "Lead Profile",
"description": "Buyer persona cards, nationality, budget bracket, preferred property type.",
"sort_order": 1
},
{
"subchapter_id": "sub-002-02",
"name": "QD Score",
"description": "Qualification-Desire score breakdown, historical trend, per-dimension scores.",
"sort_order": 2
},
{
"subchapter_id": "sub-002-03",
"name": "Pipeline Health",
"description": "Pipeline stage distribution, velocity, stall alerts, probability weighting.",
"sort_order": 3
},
{
"subchapter_id": "sub-002-04",
"name": "Engagement History",
"description": "Touchpoint timeline, dwell time heat maps, content interaction logs.",
"sort_order": 4
}
]
},
{
"chapter_id": "ch-003",
"name": "Communication Intelligence",
"description": "Components surfacing insights from calls, messages, transcripts, and follow-up commitments.",
"sort_order": 3,
"subchapters": [
{
"subchapter_id": "sub-003-01",
"name": "Call Summary",
"description": "Transcript summary, speaker diarization, key-phrase extraction.",
"sort_order": 1
},
{
"subchapter_id": "sub-003-02",
"name": "Promise Tracker",
"description": "Promises made during calls, follow-up dates, commitment confidence.",
"sort_order": 2
},
{
"subchapter_id": "sub-003-03",
"name": "WhatsApp Thread",
"description": "Business WhatsApp message thread summaries, sentiment per message.",
"sort_order": 3
},
{
"subchapter_id": "sub-003-04",
"name": "Reminder Surface",
"description": "Due follow-ups, overdue reminders, NemoClaw-suggested next actions.",
"sort_order": 4
}
]
},
{
"chapter_id": "ch-004",
"name": "Inventory Analytics",
"description": "Components for property inventory insight, availability, and absorption.",
"sort_order": 4,
"subchapters": [
{
"subchapter_id": "sub-004-01",
"name": "Property Card",
"description": "Single-property summary card with unit details, pricing, media reference.",
"sort_order": 1
},
{
"subchapter_id": "sub-004-02",
"name": "Availability Matrix",
"description": "Bed-type × availability grid with unit count and price band.",
"sort_order": 2
},
{
"subchapter_id": "sub-004-03",
"name": "Absorption Rate",
"description": "Sales velocity per project and developer over rolling windows.",
"sort_order": 3
},
{
"subchapter_id": "sub-004-04",
"name": "Inventory Comparison",
"description": "Side-by-side comparison of two or more properties on key metrics.",
"sort_order": 4
}
]
},
{
"chapter_id": "ch-005",
"name": "Operational Metrics",
"description": "System-level, team-level, and showroom-level operational performance components.",
"sort_order": 5,
"subchapters": [
{
"subchapter_id": "sub-005-01",
"name": "Showroom Traffic",
"description": "Visitor count, zone dwell time, peak hour distribution.",
"sort_order": 1
},
{
"subchapter_id": "sub-005-02",
"name": "Team Performance",
"description": "Agent-level QD scores, conversion rates, call volume, follow-up compliance.",
"sort_order": 2
},
{
"subchapter_id": "sub-005-03",
"name": "Campaign Metrics",
"description": "Catalyst campaign reach, engagement rate, cost-per-lead, ROAS.",
"sort_order": 3
},
{
"subchapter_id": "sub-005-04",
"name": "System Health",
"description": "Backend queue depth, GPU utilization, transcription job latency.",
"sort_order": 4
}
]
},
{
"chapter_id": "ch-006",
"name": "Calendar and Follow-Up",
"description": "Components for scheduling, action planning, and NemoClaw-derived follow-up surfaces.",
"sort_order": 6,
"subchapters": [
{
"subchapter_id": "sub-006-01",
"name": "Calendar View",
"description": "Personal calendar view with communication-derived events and reminders.",
"sort_order": 1
},
{
"subchapter_id": "sub-006-02",
"name": "Action Queue",
"description": "Prioritized action list for an agent, ordered by urgency and lead value.",
"sort_order": 2
},
{
"subchapter_id": "sub-006-03",
"name": "Follow-Up Plan",
"description": "Structured follow-up plan derived from call outcomes and NemoClaw insights.",
"sort_order": 3
},
{
"subchapter_id": "sub-006-04",
"name": "Reminder Cards",
"description": "Surface-agnostic reminder card applicable to tablet and phone edge.",
"sort_order": 4
}
]
}
],
"seed_examples": [
{
"example_id": "ex-001",
"chapter_id": "ch-001",
"subchapter_id": "sub-001-01",
"title": "Dubai Marina — Price Per Sqft Trend (12-Month)",
"quality_notes": "Canonical example. Use for pricing trend chart templates.",
"is_canonical": true,
"template_name": "Pricing Trend Chart",
"component_type": "line_chart",
"accepted_shapes": ["time_series"],
"example_json": {
"componentType": "line_chart",
"title": "Dubai Marina — AED/sqft Trend",
"subtitle": "12-Month Rolling Average",
"dataSource": {
"type": "inventory_aggregate",
"district": "Dubai Marina",
"metric": "avg_price_per_sqft",
"window": "12M"
},
"visualization": {
"xAxis": "month",
"yAxis": "aed_per_sqft",
"format": "currency_aed",
"annotations": [
{ "date": "2025-10", "label": "Off-plan surge", "type": "event" }
],
"trend_line": true,
"confidence_band": false
},
"style": {
"accentColor": "#2563EB",
"gridLines": "subtle"
}
}
},
{
"example_id": "ex-002",
"chapter_id": "ch-001",
"subchapter_id": "sub-001-02",
"title": "Inquiry Velocity — Downtown Dubai (30-Day)",
"quality_notes": "Use for demand signal bar charts.",
"is_canonical": true,
"template_name": "Demand Signal Bar",
"component_type": "bar_chart",
"accepted_shapes": ["categorical_count"],
"example_json": {
"componentType": "bar_chart",
"title": "Inquiry Volume — Downtown Dubai",
"subtitle": "Last 30 Days by Week",
"dataSource": {
"type": "crm_aggregate",
"district": "Downtown Dubai",
"metric": "inquiry_count",
"window": "30D",
"groupBy": "week"
},
"visualization": {
"xAxis": "week",
"yAxis": "inquiry_count",
"format": "integer",
"comparison": { "enabled": true, "label": "Prior 30D", "style": "ghost_bar" }
},
"style": {
"accentColor": "#10B981",
"barRadius": 4
}
}
},
{
"example_id": "ex-003",
"chapter_id": "ch-002",
"subchapter_id": "sub-002-02",
"title": "Lead QD Score Card — Mohammed Al-Rashid",
"quality_notes": "Canonical single-lead QD score breakdown card.",
"is_canonical": true,
"template_name": "QD Score Card",
"component_type": "metric_card_group",
"accepted_shapes": ["qd_score_breakdown"],
"example_json": {
"componentType": "metric_card_group",
"title": "QD Score",
"subtitle": "Qualification × Desire",
"dataSource": {
"type": "sentinel_qd",
"leadId": "{{lead_id}}"
},
"visualization": {
"layout": "2x2_grid",
"cards": [
{ "dimension": "overall", "label": "Overall QD", "format": "percentage" },
{ "dimension": "qualification", "label": "Qualification", "format": "percentage" },
{ "dimension": "desire", "label": "Desire", "format": "percentage" },
{ "dimension": "velocity", "label": "Engagement Velocity", "format": "trend_arrow" }
],
"threshold_colors": {
"high": "#10B981",
"medium": "#F59E0B",
"low": "#EF4444"
}
}
}
},
{
"example_id": "ex-004",
"chapter_id": "ch-003",
"subchapter_id": "sub-003-01",
"title": "Call Summary Card — Diarized Transcript with Key Phrases",
"quality_notes": "Canonical call summary. Use for communication intelligence panels.",
"is_canonical": true,
"template_name": "Call Summary Card",
"component_type": "communication_summary",
"accepted_shapes": ["transcript_summary"],
"example_json": {
"componentType": "communication_summary",
"title": "Call Summary",
"dataSource": {
"type": "edge_communication_event",
"eventId": "{{event_id}}",
"channel": "pstn"
},
"visualization": {
"layout": "timeline_with_phrases",
"show_speaker_labels": true,
"show_duration": true,
"show_sentiment": true,
"key_phrase_highlight": true,
"sections": ["summary", "promises", "key_phrases", "next_action"]
}
}
},
{
"example_id": "ex-005",
"chapter_id": "ch-003",
"subchapter_id": "sub-003-02",
"title": "Promise Tracker — Lead Follow-Up Commitments",
"quality_notes": "Canonical promise tracker. Use for follow-up reminder surfaces.",
"is_canonical": true,
"template_name": "Promise Tracker Table",
"component_type": "data_table",
"accepted_shapes": ["communication_facts"],
"example_json": {
"componentType": "data_table",
"title": "Promises and Commitments",
"dataSource": {
"type": "edge_memory_facts",
"leadId": "{{lead_id}}",
"factTypes": ["promise", "follow_up_date", "decision_maker_note"]
},
"visualization": {
"columns": [
{ "key": "fact_text", "label": "Commitment", "width": "flex" },
{ "key": "effective_date", "label": "Due", "format": "date_relative" },
{ "key": "confidence", "label": "Confidence", "format": "percentage" },
{ "key": "extracted_from", "label": "Source", "format": "badge" }
],
"row_actions": ["mark_done", "create_calendar_event"],
"sort": { "column": "effective_date", "direction": "asc" }
}
}
},
{
"example_id": "ex-006",
"chapter_id": "ch-004",
"subchapter_id": "sub-004-01",
"title": "Property Card — Sobha One Tower A",
"quality_notes": "Canonical property card. Use for inventory summaries.",
"is_canonical": true,
"template_name": "Property Summary Card",
"component_type": "property_card",
"accepted_shapes": ["inventory_property"],
"example_json": {
"componentType": "property_card",
"title": "Property Summary",
"dataSource": {
"type": "inventory_property",
"propertyId": "{{property_id}}"
},
"visualization": {
"layout": "hero_with_stats",
"sections": [
"project_name",
"developer_name",
"location_map_pin",
"price_bands",
"unit_mix_summary",
"amenity_chips",
"media_carousel"
],
"cta": { "label": "Schedule Viewing", "action": "create_calendar_event" }
}
}
},
{
"example_id": "ex-007",
"chapter_id": "ch-005",
"subchapter_id": "sub-005-01",
"title": "Showroom Traffic Heatmap",
"quality_notes": "Canonical traffic component. Use for operational dashboards.",
"is_canonical": true,
"template_name": "Showroom Traffic Heatmap",
"component_type": "heatmap",
"accepted_shapes": ["zone_time_matrix"],
"example_json": {
"componentType": "heatmap",
"title": "Showroom Zone Traffic",
"subtitle": "Today — Live",
"dataSource": {
"type": "sentinel_live",
"metric": "visitor_dwell_time",
"groupBy": ["zone", "hour"]
},
"visualization": {
"xAxis": "hour_of_day",
"yAxis": "zone_name",
"value": "avg_dwell_minutes",
"colorScale": { "low": "#EFF6FF", "high": "#1D4ED8" },
"annotations": true
}
}
},
{
"example_id": "ex-008",
"chapter_id": "ch-006",
"subchapter_id": "sub-006-04",
"title": "Phone Edge Reminder Card — Follow-Up Due",
"quality_notes": "Designed for narrow phone edge surfaces. Minimal data footprint.",
"is_canonical": true,
"template_name": "Reminder Card",
"component_type": "compact_alert_card",
"accepted_shapes": ["insight_recommendation"],
"example_json": {
"componentType": "compact_alert_card",
"title": "Follow-Up Reminder",
"dataSource": {
"type": "insight_recommendations",
"leadId": "{{lead_id}}",
"status": "pending",
"limit": 1
},
"visualization": {
"layout": "single_card_narrow",
"fields": ["summary", "suggested_action", "target_system"],
"actions": ["accept", "dismiss", "snooze_1h"],
"urgency_indicator": true,
"surface_target": ["iphone_edge", "android_phone_edge"]
}
}
}
],
"kimi_synthetic_plan": {
"description": "Downstream Kimi synthetic data expansion plan consuming this seed DB",
"expansion_targets": [
{
"chapter_id": "ch-001",
"subchapter_id": "sub-001-01",
"seed_example_ids": ["ex-001"],
"requested_count": 50,
"model": "kimi",
"diversity_axes": ["district", "property_type", "time_window"]
},
{
"chapter_id": "ch-002",
"subchapter_id": "sub-002-02",
"seed_example_ids": ["ex-003"],
"requested_count": 100,
"model": "kimi",
"diversity_axes": ["lead_nationality", "budget_bracket", "pipeline_stage"]
},
{
"chapter_id": "ch-003",
"subchapter_id": "sub-003-01",
"seed_example_ids": ["ex-004"],
"requested_count": 200,
"model": "kimi",
"diversity_axes": ["call_outcome", "property_type", "language"]
},
{
"chapter_id": "ch-004",
"subchapter_id": "sub-004-01",
"seed_example_ids": ["ex-006"],
"requested_count": 150,
"model": "kimi",
"diversity_axes": ["developer_name", "district", "bedrooms"]
}
],
"quality_gate": {
"min_acceptance_confidence": 0.8,
"human_review_required_for_canonical": true,
"auto_accept_below_count": 20
}
}
}

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "nemoclaw_prompts"
_PLACEHOLDER_PATTERN = re.compile(r"\{(\w+)\}")
_TEMPLATE_HINTS = {
"pipeline": ["tpl_pipeline_board_v2", "tpl_followup_queue_v1"],
"kanban": ["tpl_pipeline_board_v2"],
"map": ["tpl_geo_investor_heat_v2"],
"geo": ["tpl_geo_investor_heat_v2"],
"trend": ["tpl_absorption_trend_v1", "tpl_campaign_lead_line_v1"],
"quota": ["tpl_quota_gauge_v1", "tpl_kpi_pipeline_health_v1"],
"broker": ["tpl_broker_performance_v1"],
"source": ["tpl_qd_source_compare_v1", "tpl_bar_source_quality_v3"],
"follow": ["tpl_followup_queue_v1", "tpl_followup_gap_v1"],
"campaign": ["tpl_campaign_lead_line_v1"],
}
class PersonaService:
def __init__(self) -> None:
self.prompt_files = {
"qd_calculator": _PROMPT_DIR / "qd_calculator.md",
"lead_tagger": _PROMPT_DIR / "lead_tagger.md",
"cctv_profiler": _PROMPT_DIR / "cctv_profiler.md",
}
async def health(self) -> dict[str, Any]:
loaded = {}
for key, path in self.prompt_files.items():
loaded[key] = path.exists() and path.read_text(encoding="utf-8").strip() != ""
return {
"status": "healthy" if all(loaded.values()) else "degraded",
"prompts": loaded,
}
async def render_prompt(
self,
*,
prompt_name: str,
variables: dict[str, Any],
) -> dict[str, Any]:
path = self.prompt_files.get(prompt_name)
if path is None or not path.exists():
raise FileNotFoundError(f"Unknown prompt '{prompt_name}'.")
template = path.read_text(encoding="utf-8")
rendered = template
for key, value in variables.items():
rendered = rendered.replace(f"{{{key}}}", json.dumps(value) if isinstance(value, (dict, list)) else str(value))
unresolved = sorted(set(_PLACEHOLDER_PATTERN.findall(rendered)))
return {
"promptName": prompt_name,
"templatePath": str(path),
"renderedPrompt": rendered,
"unresolvedVariables": unresolved,
}
async def plan_for_prompt(
self,
*,
prompt: str,
tenant_id: str,
actor_role: str,
) -> dict[str, Any]:
lower_prompt = prompt.lower()
recommended: list[str] = []
for token, template_ids in _TEMPLATE_HINTS.items():
if token in lower_prompt:
recommended.extend(template_ids)
if not recommended:
recommended = ["tpl_kpi_pipeline_health_v1", "tpl_qd_source_compare_v1"]
recommended = list(dict.fromkeys(recommended))
return {
"tenantId": tenant_id,
"actorRole": actor_role,
"recommendedTemplates": recommended,
"canvasBlocks": [
{
"type": "textCanvas",
"widthMode": "full",
"minHeightPx": 180,
"content": (
"Oracle planned a mixed response: query the CRM, reuse matching component templates, "
"and synthesize missing visualization blocks if a direct template is unavailable."
),
}
],
"workflowIntent": "comfy_oracle_canvas",
}
persona_service = PersonaService()

View File

@@ -0,0 +1,436 @@
"""
oracle/plan_verifier.py
Verify planned SQL before execution and optionally repair common semantic errors.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from .semantic_catalog import VALID_QD_SCORE_TYPES, build_semantic_context_for_planner
logger = logging.getLogger(__name__)
_DESTRUCTIVE = re.compile(
r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b",
re.IGNORECASE,
)
_BAD_TIMESTAMP_PATTERNS: list[tuple[str, str]] = [
("edge_communication_events", "timestamp"),
("crm_property_interests", "last_discussed_at"),
("crm_property_interests", "last_interaction"),
]
_BAD_SCORE_PATTERNS: list[tuple[str, str]] = [
("crm_people", "engagement_score"),
("crm_leads", "engagement_score"),
("intel_interactions", "engagement_score"),
("crm_people", "qd_score"),
("crm_leads", "qd_score"),
]
_HALLUCINATED_COLUMNS: list[tuple[str, str]] = [
("intel_interactions", "broker_id"),
("intel_interactions", "sentiment"),
("crm_leads", "last_contacted_at"),
("crm_people", "last_contact"),
("read_last_contacted", "last_contacted_at"),
("read_last_contacted", "days_since_last_contact"),
("read_last_contacted", "staleness_label"),
]
_CONTACT_INTENTS = {"last_contacted", "timeline"}
def _extract_limit_from_prompt(prompt: str, default: int) -> int:
lowered = prompt.lower()
numeric_match = re.search(r"\b(?:top|last|latest|recent|first|show|which|give me)\s+(\d{1,4})\b", lowered)
if numeric_match:
return max(1, min(int(numeric_match.group(1)), default))
words = {
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"eleven": 11,
"twelve": 12,
"fifteen": 15,
"twenty": 20,
}
word_match = re.search(
r"\b(?:top|last|latest|recent|first|show|which|give me)\s+"
r"(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|fifteen|twenty)\b",
lowered,
)
if word_match:
return max(1, min(words[word_match.group(1)], default))
return default
def _canonical_qd_sql(prompt: str, row_limit: int) -> str:
limit = _extract_limit_from_prompt(prompt, row_limit)
lowered = prompt.lower()
direction = "ASC" if any(token in lowered for token in ("lowest", "least", "bottom", "weakest")) else "DESC"
project_filter = ""
project_join = ""
project_match = re.search(r"\bin\s+([A-Za-z0-9][A-Za-z0-9 .&'-]{2,80})(?:\?|$)", prompt)
if project_match:
project_name = project_match.group(1).strip()
if not re.search(r"\b(last|month|months|week|weeks|day|days|year|years)\b", project_name, re.IGNORECASE):
project_join = "JOIN crm_property_interests pi ON pi.person_id = p.person_id "
escaped = project_name.replace("'", "''")
project_filter = f"AND pi.project_name ILIKE '%{escaped}%' "
return (
"SELECT p.full_name, p.primary_email, p.primary_phone, "
"q.current_value AS qd_score, q.score_type, q.computed_at "
"FROM intel_qd_scores q "
"JOIN crm_people p ON p.person_id = q.person_id "
f"{project_join}"
"WHERE q.score_type = 'overall' "
f"{project_filter}"
f"ORDER BY q.current_value {direction} "
f"LIMIT {limit}"
)
def _canonical_recent_contact_sql(prompt: str, row_limit: int) -> str:
limit = _extract_limit_from_prompt(prompt, row_limit)
interval = "3 months"
lowered = prompt.lower()
interval_match = re.search(r"\b(?:last|past|recent)\s+(\d{1,3})\s+(day|days|week|weeks|month|months|year|years)\b", lowered)
if interval_match:
count, unit = interval_match.groups()
interval = f"{int(count)} {unit}"
return (
"SELECT p.full_name, p.primary_email, p.primary_phone, "
"lc.last_contact_at, lc.last_channel, lc.days_since_contact, "
"q.current_value AS qd_score "
"FROM read_last_contacted lc "
"JOIN crm_people p ON p.person_id = lc.person_id "
"LEFT JOIN intel_qd_scores q ON q.person_id = p.person_id AND q.score_type = 'overall' "
f"WHERE lc.last_contact_at >= NOW() - INTERVAL '{interval}' "
"ORDER BY q.current_value DESC NULLS LAST, lc.last_contact_at DESC "
f"LIMIT {limit}"
)
def _semantic_rule_repair(
*,
prompt: str,
detected_intents: list[str],
row_limit: int,
violations: list[VerificationViolation],
) -> str | None:
violation_rules = {violation.rule for violation in violations}
if "qd_score" in detected_intents and violation_rules.intersection({"wrong_score_column", "impossible_score_type"}):
return _canonical_qd_sql(prompt, row_limit)
if set(detected_intents).intersection(_CONTACT_INTENTS) and violation_rules.intersection(
{"deprecated_timestamp", "hallucinated_column"}
):
return _canonical_recent_contact_sql(prompt, row_limit)
return None
def _extract_score_type_literals(sql: str) -> list[str]:
literals: list[str] = []
eq_pattern = re.compile(
r"(?:\b\w+\.)?score_type\s*=\s*'([^']+)'",
re.IGNORECASE,
)
in_pattern = re.compile(
r"(?:\b\w+\.)?score_type\s+in\s*\(([^)]*)\)",
re.IGNORECASE | re.DOTALL,
)
literals.extend(match.group(1) for match in eq_pattern.finditer(sql))
for match in in_pattern.finditer(sql):
literals.extend(re.findall(r"'([^']+)'", match.group(1)))
return literals
def _references_table(sql_lower: str, table: str) -> bool:
return bool(re.search(rf"\b(?:from|join)\s+(?:public\.)?{re.escape(table)}\b", sql_lower))
def _aliases_for_table(sql: str, table: str) -> set[str]:
aliases = {table}
pattern = re.compile(
rf"\b(?:from|join)\s+(?:public\.)?{re.escape(table)}(?:\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*))?",
re.IGNORECASE,
)
for match in pattern.finditer(sql):
alias = match.group(1)
if alias and alias.lower() not in {"on", "where", "join", "left", "right", "inner", "outer", "full", "cross"}:
aliases.add(alias)
return aliases
def _references_column(sql: str, sql_lower: str, table: str, column: str) -> bool:
if not _references_table(sql_lower, table):
return False
for alias in _aliases_for_table(sql, table):
qualified = re.compile(rf"\b{re.escape(alias)}\.{re.escape(column)}\b", re.IGNORECASE)
if qualified.search(sql):
return True
return False
@dataclass
class VerificationViolation:
rule: str
detail: str
severity: str
@dataclass
class VerificationResult:
passed: bool
sql: str
original_sql: str
violations: list[VerificationViolation] = field(default_factory=list)
was_repaired: bool = False
repair_attempted: bool = False
repair_failed: bool = False
notes: list[str] = field(default_factory=list)
class PlanVerifier:
def verify(self, sql: str, prompt: str, detected_intents: list[str], row_limit: int) -> VerificationResult:
del prompt
violations: list[VerificationViolation] = []
sql_lower = sql.lower()
intent_set = set(detected_intents)
if _DESTRUCTIVE.search(sql):
violations.append(
VerificationViolation(
rule="destructive_dml",
detail="SQL contains a write or DDL statement.",
severity="blocking",
)
)
for table, column in _BAD_TIMESTAMP_PATTERNS:
if intent_set.intersection(_CONTACT_INTENTS) and _references_column(sql, sql_lower, table, column):
violations.append(
VerificationViolation(
rule="deprecated_timestamp",
detail=(
f"SQL references {table}.{column}, which is sparse or deprecated. "
"Use intel_interactions.happened_at or read_last_contacted.last_contact_at."
),
severity="blocking",
)
)
valid_score_types = {value.lower() for value in VALID_QD_SCORE_TYPES}
for literal in _extract_score_type_literals(sql):
if literal.lower() not in valid_score_types:
violations.append(
VerificationViolation(
rule="impossible_score_type",
detail=(
f"SQL filters intel_qd_scores.score_type with impossible value '{literal}'. "
"Valid values are: " + ", ".join(VALID_QD_SCORE_TYPES) + ". "
"For generic QD prompts, use score_type = 'overall'."
),
severity="blocking",
)
)
for table, column in _BAD_SCORE_PATTERNS:
if _references_column(sql, sql_lower, table, column):
violations.append(
VerificationViolation(
rule="wrong_score_column",
detail=(
f"SQL references {table}.{column}, which is not the QD source of truth. "
"Use intel_qd_scores.current_value."
),
severity="blocking",
)
)
for table, column in _HALLUCINATED_COLUMNS:
if _references_column(sql, sql_lower, table, column):
violations.append(
VerificationViolation(
rule="hallucinated_column",
detail=f"SQL references {table}.{column}, which does not exist in the live schema.",
severity="blocking",
)
)
if "limit" not in sql_lower:
violations.append(
VerificationViolation(
rule="missing_limit",
detail=f"SQL has no LIMIT clause; executor will enforce row cap {row_limit}.",
severity="warning",
)
)
if re.search(r"\bselect\s+\*\b", sql_lower) and sql_lower.count("join") > 1:
violations.append(
VerificationViolation(
rule="select_star_join",
detail="SELECT * with multiple JOINs may create noisy wide rows.",
severity="warning",
)
)
blocking = [violation for violation in violations if violation.severity == "blocking"]
return VerificationResult(
passed=len(blocking) == 0,
sql=sql,
original_sql=sql,
violations=violations,
)
async def verify_and_repair(
self,
sql: str,
prompt: str,
detected_intents: list[str],
row_limit: int,
llm_service: Any | None = None,
) -> VerificationResult:
result = self.verify(sql, prompt, detected_intents, row_limit)
if result.passed:
return result
blocking = [violation for violation in result.violations if violation.severity == "blocking"]
if not blocking:
return result
result.repair_attempted = True
if llm_service is None:
result.repair_failed = True
result.notes.append("No LLM service available for SQL repair.")
return result
try:
repaired_sql = await self._repair_sql(
sql=sql,
prompt=prompt,
violations=blocking,
detected_intents=detected_intents,
row_limit=row_limit,
llm_service=llm_service,
)
except Exception as exc:
logger.warning("plan_verifier repair failed: %s", exc)
result.repair_failed = True
result.notes.append(f"Repair failed: {exc}")
return result
recheck = self.verify(repaired_sql, prompt, detected_intents, row_limit)
recheck.original_sql = sql
recheck.was_repaired = True
recheck.repair_attempted = True
recheck.notes.append(
"Repaired violations: " + ", ".join(violation.rule for violation in blocking)
)
if not recheck.passed:
semantic_repair = _semantic_rule_repair(
prompt=prompt,
detected_intents=detected_intents,
row_limit=row_limit,
violations=blocking,
)
if semantic_repair:
semantic_recheck = self.verify(semantic_repair, prompt, detected_intents, row_limit)
semantic_recheck.original_sql = sql
semantic_recheck.was_repaired = True
semantic_recheck.repair_attempted = True
semantic_recheck.notes.append(
"Semantic rule repair applied: " + ", ".join(violation.rule for violation in blocking)
)
return semantic_recheck
return recheck
async def _repair_sql(
self,
*,
sql: str,
prompt: str,
violations: list[VerificationViolation],
detected_intents: list[str],
row_limit: int,
llm_service: Any,
) -> str:
semantic_ctx = build_semantic_context_for_planner(detected_intents, max_concepts=4)
violation_text = "\n".join(f"- [{violation.rule}] {violation.detail}" for violation in violations)
hard_rules = (
"Hard repair rules:\n"
"- crm_people is identity only. It has no QD score source-of-truth column.\n"
"- For QD score prompts, use intel_qd_scores.current_value and join crm_people on person_id.\n"
"- Valid intel_qd_scores.score_type values are: "
+ ", ".join(VALID_QD_SCORE_TYPES)
+ ".\n"
"- Never use score_type = 'QD'. For generic QD prompts use score_type = 'overall'.\n"
"- For recent contact prompts, use read_last_contacted.last_contact_at or intel_interactions.happened_at.\n"
"- Never use edge_communication_events.timestamp or crm_property_interests.last_discussed_at for contact recency."
)
canonical_examples = (
"Canonical repair examples:\n"
"Generic QD ranking:\n"
"SELECT p.full_name, p.primary_email, p.primary_phone, q.current_value AS qd_score, q.score_type, q.computed_at "
"FROM intel_qd_scores q JOIN crm_people p ON p.person_id = q.person_id "
"WHERE q.score_type = 'overall' ORDER BY q.current_value DESC LIMIT 8;\n"
"Recent contact ranking:\n"
"SELECT p.full_name, p.primary_email, lc.last_contact_at, lc.last_channel, q.current_value AS qd_score "
"FROM read_last_contacted lc JOIN crm_people p ON p.person_id = lc.person_id "
"LEFT JOIN intel_qd_scores q ON q.person_id = p.person_id AND q.score_type = 'overall' "
"WHERE lc.last_contact_at >= NOW() - INTERVAL '3 months' "
"ORDER BY q.current_value DESC NULLS LAST LIMIT 10;"
)
response = await llm_service.chat(
provider_id="sglang",
model=None,
system_prompt=(
"You are Oracle's SQL repair agent. "
"Fix only the listed violations. Return strict JSON with key 'sql'."
),
messages=[
{
"role": "user",
"content": (
f"Original prompt: {prompt}\n\n"
f"Semantic catalog:\n{semantic_ctx}\n\n"
f"{hard_rules}\n\n"
f"{canonical_examples}\n\n"
f"Violations:\n{violation_text}\n\n"
f"Broken SQL:\n{sql}\n\n"
f"Row cap: {row_limit}\n\n"
"Return JSON: {\"sql\": \"<corrected SQL>\"}"
),
}
],
temperature=0.0,
response_format="json",
metadata={"agent": "oracle_plan_verifier_repair"},
)
message = response.get("message") or {}
parsed = message.get("parsedJson")
if not isinstance(parsed, dict):
content = message.get("content") or "{}"
parsed = json.loads(content) if isinstance(content, str) else {}
repaired = str(parsed.get("sql") or "").strip()
if not repaired:
raise ValueError("Repair LLM returned empty SQL.")
return repaired
plan_verifier = PlanVerifier()

View File

@@ -0,0 +1,225 @@
"""
oracle/policy_service.py
Enforces tenant isolation, role-based access, privacy-tier escalation,
field-level redaction, and row limit guardrails for all Oracle data access.
Section 11.3 of the Oracle Architecture Document.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# ── Constants ─────────────────────────────────────────────────────────────────
MAX_ROW_LIMITS: dict[str, int] = {
"junior_broker": 100,
"senior_broker": 500,
"sales_director": 2000,
"marketing_operator": 1000,
"data_steward": 5000,
"compliance_reviewer": 5000,
"platform_admin": 10000,
}
# Which roles can see which privacy tiers
PRIVACY_TIER_ACCESS: dict[str, set[str]] = {
"standard": {"junior_broker", "senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"},
"restricted": {"senior_broker", "sales_director", "data_steward", "compliance_reviewer", "platform_admin"},
"sensitive": {"data_steward", "compliance_reviewer", "platform_admin"},
}
# Datasets with cross-tenant join restrictions
CROSS_TENANT_RESTRICTED: set[str] = {
"global_lead_market",
"competitor_pricing",
"cross_tenant_referrals",
}
@dataclass
class PolicyContext:
tenant_id: str
actor_id: str
actor_role: str
policy_profile_id: str = "policy_standard_v4"
@dataclass
class ValidationResult:
passed: bool
errors: list[str]
warnings: list[str]
redaction_policy: str = "none"
effective_row_limit: int = 100
@classmethod
def ok(cls, row_limit: int, redaction: str = "none") -> "ValidationResult":
return cls(passed=True, errors=[], warnings=[], redaction_policy=redaction, effective_row_limit=row_limit)
@classmethod
def denied(cls, reason: str) -> "ValidationResult":
return cls(passed=False, errors=[reason], warnings=[])
class PolicyService:
"""
Validates all Oracle data access against policy rules.
Configuration is loaded from env / feature flags in production;
falls back to safe defaults for demo mode.
"""
def validate_retrieval_plan(
self,
plan: dict[str, Any],
ctx: PolicyContext,
) -> ValidationResult:
"""
Validates a structured retrieval plan (as produced by PromptOrchestrator).
Checks: tenant isolation, role access, privacy tier, row limits.
Returns ValidationResult with passed=True if all checks pass.
"""
errors: list[str] = []
warnings: list[str] = []
dataset = plan.get("dataset", "")
privacy_tier = plan.get("privacyTier", "standard")
requested_row_limit = plan.get("rowLimit", 100)
joins = plan.get("joins", [])
# 1. Tenant isolation — reject cross-tenant predicates
if dataset in CROSS_TENANT_RESTRICTED:
errors.append(
f"POLICY_CROSS_TENANT_JOIN_DENIED: Dataset '{dataset}' requires "
f"cross-tenant access which is not permitted for role '{ctx.actor_role}'."
)
# 2. Cross-tenant join detection
for join in joins:
if join.get("tenantId") and join["tenantId"] != ctx.tenant_id:
errors.append(
f"POLICY_CROSS_TENANT_JOIN_DENIED: Join to tenant '{join['tenantId']}' "
f"is not permitted."
)
# 3. Privacy tier access
allowed_roles = PRIVACY_TIER_ACCESS.get(privacy_tier, set())
if ctx.actor_role not in allowed_roles:
errors.append(
f"POLICY_PRIVACY_TIER_ESCALATION: Role '{ctx.actor_role}' cannot access "
f"'{privacy_tier}' tier data in dataset '{dataset}'."
)
# 4. Row limit guardrail
max_limit = MAX_ROW_LIMITS.get(ctx.actor_role, 100)
effective_limit = min(requested_row_limit, max_limit)
if requested_row_limit > max_limit:
warnings.append(
f"ROW_LIMIT_CAPPED: Requested {requested_row_limit} rows; "
f"capped to {effective_limit} for role '{ctx.actor_role}'."
)
# 5. Determine redaction policy
redaction = "none"
if privacy_tier == "restricted" and ctx.actor_role == "senior_broker":
redaction = "aggregate_only"
elif privacy_tier == "sensitive":
redaction = "full_redact"
if errors:
return ValidationResult(
passed=False,
errors=errors,
warnings=warnings,
redaction_policy=redaction,
effective_row_limit=effective_limit,
)
return ValidationResult(
passed=True,
errors=[],
warnings=warnings,
redaction_policy=redaction,
effective_row_limit=effective_limit,
)
def enforce_tenant_predicate(
self,
query_parameters: dict[str, Any],
ctx: PolicyContext,
) -> dict[str, Any]:
"""
Ensures :tenant_id parameter is always bound to the actor's tenant.
Overrides any attacker-supplied tenant_id parameter.
"""
params = dict(query_parameters)
params["tenant_id"] = ctx.tenant_id
return params
def validate_component_access(
self,
component_access_controls: dict[str, Any],
ctx: PolicyContext,
) -> bool:
"""
Returns True if the actor's role is in the component's allowedRoles.
"""
allowed_roles: list[str] = component_access_controls.get("allowedRoles", [])
if not allowed_roles:
# Open access (shouldn't happen in production)
logger.warning(
"POLICY_WARN: Component has no allowedRoles — defaulting to deny for tenant=%s actor=%s",
ctx.tenant_id,
ctx.actor_id,
)
return False
return ctx.actor_role in allowed_roles
def redact(
self,
rows: list[dict[str, Any]],
redaction_policy: str,
sensitive_fields: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
Applies field-level redaction to result rows.
"""
if redaction_policy == "none" or not rows:
return rows
if redaction_policy == "full_redact":
return [{"__redacted__": True, "count": len(rows)}]
if redaction_policy == "aggregate_only":
# Keep only aggregate fields; drop individual identifiers
safe_fields = {"count", "total", "average", "sum", "min", "max", "stage", "source", "district"}
return [{k: v for k, v in row.items() if k in safe_fields} for row in rows]
if redaction_policy == "team_scope":
# Keep rows where assigned_broker matches actor (simplified demo rule)
return rows # Full enforcement requires actor context per row
return rows
def audit_policy_check(
self,
ctx: PolicyContext,
dataset: str,
result: ValidationResult,
) -> None:
"""Emit an audit event for every policy check (passed or denied)."""
if not result.passed:
logger.warning(
"POLICY_DENIED tenant=%s actor=%s dataset=%s errors=%s",
ctx.tenant_id,
ctx.actor_id,
dataset,
result.errors,
)
else:
logger.debug(
"POLICY_PASS tenant=%s actor=%s dataset=%s redaction=%s limit=%d",
ctx.tenant_id,
ctx.actor_id,
dataset,
result.redaction_policy,
result.effective_row_limit,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,554 @@
"""
oracle/router_v1.py
FastAPI router for all Oracle v1 endpoints.
Mounted at /api/oracle/v1 in main.py.
Endpoints (from spec §13.2):
GET /me
GET /canvas-pages/{pageId}
POST /canvas-pages/{pageId}/prompts
POST /canvas-pages/{pageId}/forks
POST /canvas-pages/{pageId}/rollback
GET /canvas-pages/{pageId}/revisions
GET /component-templates
POST /component-templates/synthesize (stub)
GET /merge-requests
POST /merge-requests
POST /merge-requests/{mrId}/review
WS /ws/oracle/canvas/{pageId}
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Set
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, get_current_user
from .canvas_service import canvas_service
from .collaboration_service import collaboration_service
from .action_service import oracle_action_service
from .persona_service import persona_service
from .prompt_orchestrator import prompt_orchestrator
from .policy_service import PolicyService, PolicyContext
from .codebook_service import codebook_service
logger = logging.getLogger(__name__)
router = APIRouter()
policy_svc = PolicyService()
_DEFAULT_TENANT_ID = os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _ok(data: Any, meta: dict | None = None) -> dict:
return {"status": "ok", "data": data, "meta": meta or {}}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _normalize_oracle_role(role: str) -> str:
mapping = {
"JUNIOR_BROKER": "junior_broker",
"SENIOR_BROKER": "senior_broker",
"SALES_DIRECTOR": "sales_director",
"ADMIN": "platform_admin",
}
return mapping.get(role.strip().upper(), "sales_director")
def _build_user_profile(
*,
user_id: str,
email: str,
display_name: str,
role: str,
avatar_url: str | None,
default_page_id: str,
) -> dict[str, Any]:
return {
"userId": user_id,
"tenantId": _DEFAULT_TENANT_ID,
"email": email,
"displayName": display_name,
"role": _normalize_oracle_role(role),
"avatarUrl": avatar_url,
"timezone": os.getenv("ORACLE_DEFAULT_TIMEZONE", "Asia/Dubai"),
"locale": os.getenv("ORACLE_DEFAULT_LOCALE", "en-AE"),
"defaultPageId": default_page_id,
"canvasPreferences": {
"defaultDensity": "comfortable",
"defaultPlacementMode": "append_after_last_visible_component",
"showLineageBadges": True,
},
"policyProfileId": os.getenv("ORACLE_POLICY_PROFILE_ID", "policy_sales_director_standard_v4"),
"createdAt": os.getenv("ORACLE_PROFILE_CREATED_AT", _now()),
"updatedAt": _now(),
}
async def _get_current_user_profile(request: Request, user: UserPrincipal) -> dict[str, Any]:
seed_page = await canvas_service.ensure_default_page(
tenant_id=_DEFAULT_TENANT_ID,
owner_id=user.user_id,
title=os.getenv("ORACLE_DEFAULT_PAGE_TITLE", "Oracle Main Canvas"),
)
pool = getattr(request.app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
COALESCE(full_name, split_part(email, '@', 1), id::text) AS display_name,
COALESCE(email, id::text || '@velocity.local') AS email,
avatar_url
FROM users_and_roles
WHERE id = $1::uuid
""",
user.user_id,
)
return _build_user_profile(
user_id=user.user_id,
email=row["email"] if row else f"{user.user_id}@velocity.local",
display_name=row["display_name"] if row else user.user_id,
role=user.role,
avatar_url=row["avatar_url"] if row else None,
default_page_id=seed_page["pageId"],
)
async def _ctx_from_request(request: Request, user: UserPrincipal) -> PolicyContext:
me = await _get_current_user_profile(request, user)
return PolicyContext(
tenant_id=me["tenantId"],
actor_id=me["userId"],
actor_role=me["role"],
)
async def _resolve_page_id(request: Request, user: UserPrincipal, page_id: str) -> str:
normalized = (page_id or "").strip()
if normalized and normalized.lower() != "main":
return normalized
me = await _get_current_user_profile(request, user)
return str(me["defaultPageId"])
# ── Pydantic Models ───────────────────────────────────────────────────────────
class PromptSubmitRequest(BaseModel):
clientRequestId: str = Field(..., description="Client-generated idempotency key")
branchId: str
prompt: str = Field(..., min_length=1, max_length=4096)
conversationContext: list[dict[str, str]] = Field(default_factory=list)
placementMode: str = Field("append_after_last_visible_component")
targetLeadId: str | None = None
plannedWriteback: dict[str, Any] = Field(default_factory=dict)
class ForkCreateRequest(BaseModel):
recipientUserId: str
sourceRevision: int
visibility: str = Field("private", pattern="^(private|team)$")
message: str = ""
class RollbackRequest(BaseModel):
targetRevision: int = Field(..., ge=1)
clientRequestId: str
class MergeRequestCreateRequest(BaseModel):
sourcePageId: str
sourceBranchId: str
targetPageId: str
targetBranchId: str
title: str = Field(..., min_length=1, max_length=256)
description: str = ""
class MergeReviewRequest(BaseModel):
decision: str = Field(..., pattern="^(approve|reject|changes_requested)$")
comment: str = ""
resolutions: list[dict[str, Any]] = Field(default_factory=list)
class TemplateSynthesizeRequest(BaseModel):
prompt: str
dataShape: list[str]
styleSignatureRef: str | None = None
class PersonaRenderRequest(BaseModel):
promptName: str = Field(..., pattern="^(qd_calculator|lead_tagger|cctv_profiler)$")
variables: dict[str, Any] = Field(default_factory=dict)
class PageCreateRequest(BaseModel):
title: str = Field(default="Untitled Canvas", max_length=256)
class PageUpdateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/me", summary="Get current user profile")
async def get_me(request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
return _ok(await _get_current_user_profile(request, user))
@router.get("/canvas-pages", summary="List canvas pages for current user")
async def list_canvas_pages(
request: Request,
search: str | None = None,
limit: int = 50,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
ctx = await _ctx_from_request(request, user)
pages = await canvas_service.list_pages(
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
search=search,
limit=limit,
)
return _ok(pages, meta={"count": len(pages)})
@router.post("/canvas-pages", summary="Create a new canvas page")
async def create_canvas_page(
payload: PageCreateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
ctx = await _ctx_from_request(request, user)
page = await canvas_service.create_page(
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
title=payload.title.strip() or "Untitled Canvas",
)
return _ok(page)
@router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID")
async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page:
raise HTTPException(status_code=404, detail=f"Canvas page '{page_id}' not found.")
return _ok(page)
@router.patch("/canvas-pages/{page_id}", summary="Rename a canvas page")
async def rename_canvas_page(
page_id: str,
payload: PageUpdateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
try:
page = await canvas_service.update_page_title(
page_id=page_id,
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
title=payload.title,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _ok(page)
@router.delete("/canvas-pages/{page_id}", summary="Delete a canvas page")
async def delete_canvas_page(
page_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
try:
await canvas_service.delete_page(
page_id=page_id,
tenant_id=ctx.tenant_id,
owner_id=ctx.actor_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _ok({"pageId": page_id, "deleted": True})
@router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components")
async def submit_prompt(
page_id: str,
payload: PromptSubmitRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
execution = await prompt_orchestrator.execute(
tenant_id=ctx.tenant_id,
page_id=page_id,
branch_id=payload.branchId,
actor_id=ctx.actor_id,
actor_role=ctx.actor_role,
prompt=payload.prompt,
conversation_context=payload.conversationContext,
client_request_id=payload.clientRequestId,
placement_mode=payload.placementMode,
)
if execution["status"] == "failed":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"errors": execution.get("warnings", [])},
)
page = await canvas_service.get_page(page_id, ctx.tenant_id)
action = await oracle_action_service.create_from_execution(
execution=execution,
target_entity_type="lead" if payload.targetLeadId else "canvas_page",
target_entity_id=payload.targetLeadId or page_id,
action_type="oracle_prompt_writeback_plan" if payload.targetLeadId else "oracle_canvas_generation",
writeback_payload=payload.plannedWriteback,
)
return _ok({
"executionId": execution["executionId"],
"actionId": action["actionId"],
"status": execution["status"],
"pageId": page_id,
"branchId": payload.branchId,
"headRevision": execution.get("headRevision", page.get("headRevision", 0) if page else 0),
"componentsCreated": execution.get("componentsCreated", []),
"summary": execution.get("summary", ""),
"warnings": execution.get("warnings", []),
"components": page.get("components", []) if page else [],
})
@router.post("/canvas-pages/{page_id}/forks", summary="Create a fork (share) from a canvas page")
async def create_fork(
page_id: str,
payload: ForkCreateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page:
raise HTTPException(status_code=404, detail="Source page not found.")
try:
fork = await collaboration_service.create_fork(
source_page=page,
recipient_user_id=payload.recipientUserId,
created_by=ctx.actor_id,
visibility=payload.visibility,
message=payload.message,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _ok(fork)
@router.post("/canvas-pages/{page_id}/rollback", summary="Rollback canvas to a prior revision")
async def rollback_canvas(
page_id: str,
payload: RollbackRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
result = await canvas_service.rollback(
page_id=page_id,
tenant_id=ctx.tenant_id,
actor_id=ctx.actor_id,
target_revision=payload.targetRevision,
idempotency_key=payload.clientRequestId,
)
page = await canvas_service.get_page(page_id, ctx.tenant_id)
return _ok({
"pageId": page_id,
"headRevision": result.get("revisionNumber", payload.targetRevision),
"components": page.get("components", []) if page else [],
})
@router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page")
async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
page_id = await _resolve_page_id(request, user, page_id)
ctx = await _ctx_from_request(request, user)
revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id)
return _ok(revisions, meta={"count": len(revisions)})
@router.get("/component-templates", summary="List component templates")
async def list_templates(
category: str | None = None,
status: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
result = codebook_service.list_templates(
category=category,
status=status,
search=search,
limit=limit,
offset=offset,
)
return _ok(result["templates"], meta={"count": result["total"], "limit": limit, "offset": offset})
@router.post("/component-templates/synthesize", summary="Synthesize a new component template from a prompt")
async def synthesize_template(
payload: TemplateSynthesizeRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
me = await _get_current_user_profile(request, user)
template = codebook_service.synthesize_template(
prompt=payload.prompt,
data_shapes=payload.dataShape,
)
template["tenantId"] = me["tenantId"]
template.setdefault("createdAt", _now())
template.setdefault("updatedAt", _now())
return _ok(template)
@router.get("/persona/health", summary="Health check for Oracle persona prompt loading")
async def persona_health() -> dict:
return _ok(await persona_service.health())
@router.post("/persona/render", summary="Render a subordinate Oracle persona prompt")
async def persona_render(payload: PersonaRenderRequest) -> dict:
try:
rendered = await persona_service.render_prompt(
prompt_name=payload.promptName,
variables=payload.variables,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _ok(rendered)
@router.get("/merge-requests", summary="List merge requests for a target page")
async def list_merge_requests(targetPageId: str | None = None, status: str | None = None) -> dict:
if not targetPageId:
raise HTTPException(status_code=400, detail="targetPageId query param required")
mrs = await collaboration_service.list_merge_requests(targetPageId, status)
return _ok(mrs, meta={"count": len(mrs)})
@router.post("/merge-requests", summary="Open a merge request")
async def create_merge_request(
payload: MergeRequestCreateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
ctx = await _ctx_from_request(request, user)
source_page = await canvas_service.get_page(payload.sourcePageId, ctx.tenant_id)
target_page = await canvas_service.get_page(payload.targetPageId, ctx.tenant_id)
if not source_page or not target_page:
raise HTTPException(status_code=404, detail="Source or target page not found.")
mr = await collaboration_service.open_merge_request(
tenant_id=ctx.tenant_id,
source_page_id=payload.sourcePageId,
source_branch_id=payload.sourceBranchId,
source_head_revision=source_page.get("headRevision", 0),
target_page_id=payload.targetPageId,
target_branch_id=payload.targetBranchId,
target_base_revision=target_page.get("headRevision", 0),
title=payload.title,
description=payload.description,
created_by=ctx.actor_id,
source_components=source_page.get("components", []),
target_components=target_page.get("components", []),
base_components=[], # Simplified: empty base for demo
)
return _ok(mr)
@router.post("/merge-requests/{mr_id}/review", summary="Submit a merge request review")
async def review_merge_request(
mr_id: str,
payload: MergeReviewRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
ctx = await _ctx_from_request(request, user)
mr = await collaboration_service.review_merge_request(
mr_id=mr_id,
decision=payload.decision,
reviewer_id=ctx.actor_id,
comment=payload.comment,
resolutions=payload.resolutions,
)
return _ok(mr)
# ── WebSocket ─────────────────────────────────────────────────────────────────
class OracleConnectionManager:
def __init__(self) -> None:
self.active: dict[str, Set[WebSocket]] = {}
async def connect(self, ws: WebSocket, page_id: str) -> None:
await ws.accept()
self.active.setdefault(page_id, set()).add(ws)
def disconnect(self, ws: WebSocket, page_id: str) -> None:
page_connections = self.active.get(page_id, set())
page_connections.discard(ws)
async def broadcast_page(self, page_id: str, payload: dict) -> None:
dead: set[WebSocket] = set()
for ws in self.active.get(page_id, set()):
try:
await ws.send_text(json.dumps(payload))
except Exception:
dead.add(ws)
if dead:
self.active.get(page_id, set()).difference_update(dead)
oracle_manager = OracleConnectionManager()
@router.websocket("/ws/oracle/canvas/{page_id}")
async def oracle_canvas_ws(ws: WebSocket, page_id: str) -> None:
"""
WebSocket endpoint for real-time Oracle canvas collaboration.
Event types: oracle.page.revision.committed, oracle.prompt.received, oracle.presence.updated
"""
await oracle_manager.connect(ws, page_id)
try:
while True:
data = await ws.receive_text()
try:
msg = json.loads(data)
# Reflect heartbeat
if msg.get("type") == "heartbeat":
await ws.send_text(json.dumps({"type": "heartbeat.ack", "timestamp": _now()}))
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
oracle_manager.disconnect(ws, page_id)
# ── Pre-made templates seed ───────────────────────────────────────────────────

View File

@@ -0,0 +1,350 @@
-- ────────────────────────────────────────────────────────────────────────────
-- Oracle Schema Extension v2 — Multi-Surface Platform and Oracle Expansion
-- Date: 2026-04-18
-- Author: Velocity Platform Team
-- Depends on: schema_oracle.sql (must be applied first)
-- PostgreSQL 14+ required · UUID via pgcrypto already enabled
-- ────────────────────────────────────────────────────────────────────────────
-- ─── 1. Oracle Template Taxonomy ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS oracle_template_chapters (
chapter_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_template_subchapters (
subchapter_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chapter_id UUID NOT NULL REFERENCES oracle_template_chapters(chapter_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_template_seed_examples (
example_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES oracle_component_templates(template_id) ON DELETE CASCADE,
chapter_id UUID REFERENCES oracle_template_chapters(chapter_id),
subchapter_id UUID REFERENCES oracle_template_subchapters(subchapter_id),
title TEXT NOT NULL,
example_json JSONB NOT NULL,
quality_notes TEXT,
is_canonical BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Extend oracle_component_templates with chapter/subchapter linkage
-- (additive columns — does not alter existing rows)
ALTER TABLE oracle_component_templates
ADD COLUMN IF NOT EXISTS chapter_id UUID REFERENCES oracle_template_chapters(chapter_id),
ADD COLUMN IF NOT EXISTS subchapter_id UUID REFERENCES oracle_template_subchapters(subchapter_id),
ADD COLUMN IF NOT EXISTS json_template JSONB,
ADD COLUMN IF NOT EXISTS description TEXT;
-- ─── 2. Kimi Synthetic Data Jobs ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS oracle_synthetic_generation_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
template_id UUID NOT NULL REFERENCES oracle_component_templates(template_id),
chapter_id UUID REFERENCES oracle_template_chapters(chapter_id),
subchapter_id UUID REFERENCES oracle_template_subchapters(subchapter_id),
model TEXT NOT NULL DEFAULT 'kimi',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','running','completed','failed','cancelled')),
requested_count INTEGER NOT NULL DEFAULT 10,
accepted_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 3. Inventory Pipeline ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS inventory_import_batches (
batch_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('csv','json','api_push','manual')),
submitted_by TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','validating','processing','completed','failed','partial')),
total_rows INTEGER NOT NULL DEFAULT 0,
accepted_rows INTEGER NOT NULL DEFAULT 0,
rejected_rows INTEGER NOT NULL DEFAULT 0,
error_summary JSONB NOT NULL DEFAULT '[]'::JSONB,
source_file_ref TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS inventory_properties (
property_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
batch_id UUID REFERENCES inventory_import_batches(batch_id),
source_id TEXT, -- external source identifier
project_name TEXT NOT NULL,
developer_name TEXT NOT NULL,
location JSONB NOT NULL DEFAULT '{}'::JSONB, -- {city, district, lat, lng}
property_type TEXT NOT NULL, -- apartment, villa, penthouse, plot, etc.
price_bands JSONB NOT NULL DEFAULT '[]'::JSONB, -- [{minAED, maxAED, unitType}]
unit_mix JSONB NOT NULL DEFAULT '[]'::JSONB, -- [{bedrooms, count, sizeSqft}]
amenities TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','draft','under_review')),
validation_state JSONB NOT NULL DEFAULT '{}'::JSONB,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS inventory_media_assets (
media_asset_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
property_id UUID NOT NULL REFERENCES inventory_properties(property_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
media_type TEXT NOT NULL CHECK (media_type IN ('image','video','floorplan','brochure','360','vr')),
url TEXT NOT NULL,
thumbnail_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
uploaded_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 4. Edge Communication Events ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS edge_communication_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
lead_id TEXT NOT NULL,
channel TEXT NOT NULL
CHECK (channel IN ('pstn','whatsapp_message','whatsapp_voice',
'whatsapp_video','email','facebook_message',
'instagram_message','in_app_voip','manual_note')),
direction TEXT NOT NULL CHECK (direction IN ('inbound','outbound')),
provider TEXT, -- twilio, vonage, meta, etc.
capture_mode TEXT NOT NULL
CHECK (capture_mode IN ('direct_api','provider_routed','operator_import','operator_note')),
consent_state TEXT NOT NULL DEFAULT 'unknown'
CHECK (consent_state IN ('unknown','granted','denied','not_required')),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
duration_seconds INTEGER,
summary TEXT,
raw_reference TEXT, -- provider message/call ID
recording_ref TEXT, -- storage path or URL
provider_metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS edge_communication_memory_facts (
fact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
lead_id TEXT NOT NULL,
event_id UUID REFERENCES edge_communication_events(event_id),
fact_type TEXT NOT NULL
CHECK (fact_type IN ('promise','preference','follow_up_date',
'objection','interest_signal','budget','timeline',
'constraint','decision_maker_note','custom')),
fact_text TEXT NOT NULL,
effective_date DATE,
confidence NUMERIC(4,3) NOT NULL DEFAULT 1.0 CHECK (confidence BETWEEN 0 AND 1),
extracted_from TEXT NOT NULL
CHECK (extracted_from IN ('transcript','message_thread','operator_note','import')),
is_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
confirmed_by TEXT,
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 5. Transcription Jobs and Segments ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS edge_transcription_jobs (
transcription_job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
event_id UUID NOT NULL REFERENCES edge_communication_events(event_id) ON DELETE CASCADE,
media_type TEXT NOT NULL CHECK (media_type IN ('audio','video')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','queued','processing','completed','failed')),
transcript_ref TEXT, -- storage path to diarized JSON
provider TEXT NOT NULL DEFAULT 'nemoclaw',
consent_state TEXT NOT NULL DEFAULT 'unknown'
CHECK (consent_state IN ('unknown','granted','denied')),
speaker_count INTEGER,
word_count INTEGER,
language TEXT NOT NULL DEFAULT 'en',
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS edge_transcript_segments (
segment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transcription_job_id UUID NOT NULL REFERENCES edge_transcription_jobs(transcription_job_id) ON DELETE CASCADE,
event_id UUID NOT NULL REFERENCES edge_communication_events(event_id),
speaker_label TEXT NOT NULL, -- SPEAKER_00, SPEAKER_01, etc.
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
text TEXT NOT NULL,
confidence NUMERIC(4,3) NOT NULL DEFAULT 1.0,
is_agent_turn BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 6. User Calendar Events ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS user_calendar_events (
calendar_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
lead_id TEXT,
source_event_id UUID REFERENCES edge_communication_events(event_id),
title TEXT NOT NULL,
description TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('tentative','confirmed','done','cancelled')),
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
created_by TEXT NOT NULL
CHECK (created_by IN ('user','nemoclaw_suggested','operator_import')),
is_nemoclaw_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
location TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 7. Insight Recommendations ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS insight_recommendations (
recommendation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
lead_id TEXT NOT NULL,
source_event_id UUID REFERENCES edge_communication_events(event_id),
recommendation_type TEXT NOT NULL
CHECK (recommendation_type IN ('follow_up_call','send_message',
'schedule_meeting','update_crm',
'update_qd_score','send_property_info',
'escalate','custom')),
summary TEXT NOT NULL,
suggested_action TEXT NOT NULL,
target_system TEXT NOT NULL
CHECK (target_system IN ('crm','calendar','qd_score','whatsapp','email','operator')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','dismissed','acted_upon')),
confidence NUMERIC(4,3) NOT NULL DEFAULT 0.8,
acted_by TEXT,
acted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 8. Admin Action Events ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS admin_action_events (
action_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
action_id TEXT NOT NULL UNIQUE, -- idempotency key from client
action_type TEXT NOT NULL
CHECK (action_type IN (
'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'
)),
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
requested_by TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','completed','failed','rejected')),
result_message TEXT,
result_artifacts JSONB NOT NULL DEFAULT '[]'::JSONB,
executed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── 9. Surface Sessions (cross-surface telemetry) ───────────────────────────
CREATE TABLE IF NOT EXISTS surface_sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
surface_type TEXT NOT NULL
CHECK (surface_type IN ('webos','ipad','android_tablet',
'iphone_edge','android_phone_edge')),
app_version TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
screen_sequence TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::JSONB
);
-- ─── Indexes ──────────────────────────────────────────────────────────────────
-- Template taxonomy
CREATE INDEX IF NOT EXISTS idx_tmpl_chapters_tenant ON oracle_template_chapters(tenant_id, is_active);
CREATE INDEX IF NOT EXISTS idx_tmpl_subchapters_chapter ON oracle_template_subchapters(chapter_id, is_active);
CREATE INDEX IF NOT EXISTS idx_tmpl_seed_examples_template ON oracle_template_seed_examples(template_id);
CREATE INDEX IF NOT EXISTS idx_tmpl_seed_examples_chapter ON oracle_template_seed_examples(chapter_id);
-- Synthetic jobs
CREATE INDEX IF NOT EXISTS idx_synthetic_jobs_tenant ON oracle_synthetic_generation_jobs(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_synthetic_jobs_template ON oracle_synthetic_generation_jobs(template_id);
-- Inventory
CREATE INDEX IF NOT EXISTS idx_inv_batches_tenant ON inventory_import_batches(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_inv_props_tenant ON inventory_properties(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_inv_props_batch ON inventory_properties(batch_id);
CREATE INDEX IF NOT EXISTS idx_inv_media_property ON inventory_media_assets(property_id);
-- Edge communication
CREATE INDEX IF NOT EXISTS idx_edge_events_lead ON edge_communication_events(tenant_id, lead_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_edge_events_channel ON edge_communication_events(channel, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_edge_memory_lead ON edge_communication_memory_facts(tenant_id, lead_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_edge_memory_event ON edge_communication_memory_facts(event_id);
-- Transcription
CREATE INDEX IF NOT EXISTS idx_transcription_jobs_event ON edge_transcription_jobs(event_id);
CREATE INDEX IF NOT EXISTS idx_transcription_jobs_status ON edge_transcription_jobs(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_transcript_segments_job ON edge_transcript_segments(transcription_job_id, start_ms);
-- Calendar
CREATE INDEX IF NOT EXISTS idx_calendar_events_owner ON user_calendar_events(tenant_id, owner_user_id, start_at);
CREATE INDEX IF NOT EXISTS idx_calendar_events_lead ON user_calendar_events(lead_id, start_at);
-- Insights
CREATE INDEX IF NOT EXISTS idx_insights_lead ON insight_recommendations(tenant_id, lead_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_insights_status ON insight_recommendations(status, created_at DESC);
-- Admin
CREATE INDEX IF NOT EXISTS idx_admin_actions_tenant ON admin_action_events(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_actions_type ON admin_action_events(action_type, status);
-- Surface sessions
CREATE INDEX IF NOT EXISTS idx_surface_sessions_user ON surface_sessions(tenant_id, user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_surface_sessions_type ON surface_sessions(surface_type, started_at DESC);

View File

@@ -0,0 +1,236 @@
-- Oracle Canvas Schema — Section 16.4 of the Oracle Architecture Document v1.0
-- Run this against your PostgreSQL database to create the Oracle persistence layer.
-- Requires: UUID extension, JSONB support (PostgreSQL 14+)
-- ── Prerequisites ─────────────────────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Core tables ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS oracle_canvas_pages (
page_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
owner_id TEXT NOT NULL,
branch_id TEXT NOT NULL,
branch_name TEXT NOT NULL DEFAULT 'main',
page_type TEXT NOT NULL DEFAULT 'main' CHECK (page_type IN ('main', 'fork')),
title TEXT NOT NULL DEFAULT 'Untitled Canvas',
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
head_revision INTEGER NOT NULL DEFAULT 0,
base_revision INTEGER NOT NULL DEFAULT 0,
sharing_policy JSONB NOT NULL DEFAULT '{"shareMode":"direct_fork_only","allowReshare":false,"defaultForkVisibility":"private"}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_canvas_page_revisions (
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
revision_number INTEGER NOT NULL,
commit_kind TEXT NOT NULL CHECK (commit_kind IN ('prompt', 'merge', 'rollback', 'manual_edit')),
commit_summary TEXT,
actor_id TEXT NOT NULL,
execution_id UUID,
merge_request_id UUID,
components_snapshot JSONB NOT NULL DEFAULT '[]'::JSONB,
idempotency_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (page_id, revision_number)
);
CREATE TABLE IF NOT EXISTS oracle_canvas_components (
component_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
lifecycle_state TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle_state IN ('draft','active','superseded','archived','revoked')),
data_source_descriptor JSONB NOT NULL,
data_rows JSONB NOT NULL DEFAULT '[]'::JSONB,
visualization_parameters JSONB NOT NULL DEFAULT '{}'::JSONB,
data_bindings JSONB NOT NULL DEFAULT '{}'::JSONB,
provenance JSONB NOT NULL,
rendering_hints JSONB NOT NULL,
layout JSONB NOT NULL,
access_controls JSONB NOT NULL,
style_signature JSONB NOT NULL DEFAULT '{}'::JSONB,
validation_state JSONB NOT NULL DEFAULT '{}'::JSONB,
audit_log TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE oracle_canvas_components
ADD COLUMN IF NOT EXISTS data_rows JSONB NOT NULL DEFAULT '[]'::JSONB;
WITH latest_revisions AS (
SELECT DISTINCT ON (page_id, tenant_id)
page_id,
tenant_id,
components_snapshot
FROM oracle_canvas_page_revisions
ORDER BY page_id, tenant_id, revision_number DESC
),
snapshot_components AS (
SELECT
latest_revisions.page_id,
latest_revisions.tenant_id,
component->>'componentId' AS component_id,
COALESCE(component->'dataRows', '[]'::jsonb) AS data_rows
FROM latest_revisions,
jsonb_array_elements(latest_revisions.components_snapshot) AS component
)
UPDATE oracle_canvas_components occ
SET data_rows = snapshot_components.data_rows
FROM snapshot_components
WHERE occ.page_id = snapshot_components.page_id
AND occ.tenant_id = snapshot_components.tenant_id
AND occ.component_id::text = snapshot_components.component_id
AND occ.data_rows = '[]'::jsonb
AND snapshot_components.data_rows <> '[]'::jsonb;
CREATE TABLE IF NOT EXISTS oracle_prompt_executions (
execution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
branch_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
prompt TEXT NOT NULL,
intent_class TEXT NOT NULL DEFAULT 'analytical',
status TEXT NOT NULL DEFAULT 'received',
model_runtime TEXT NOT NULL DEFAULT 'nemoclaw_hosted',
semantic_model_version TEXT NOT NULL DEFAULT 'oracle_semantic_v1',
retrieval_plan JSONB,
visualization_plan JSONB,
warnings TEXT[] NOT NULL DEFAULT '{}',
summary TEXT,
components_created TEXT[] NOT NULL DEFAULT '{}',
client_request_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS oracle_component_templates (
template_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'catalog_active',
origin TEXT NOT NULL DEFAULT 'premade',
version TEXT NOT NULL DEFAULT '1.0.0',
accepted_shapes TEXT[] NOT NULL DEFAULT '{}',
style_signature JSONB DEFAULT NULL,
validation_state JSONB DEFAULT NULL,
provenance JSONB DEFAULT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_forks (
fork_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
source_branch_id TEXT NOT NULL,
source_revision INTEGER NOT NULL,
fork_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
fork_branch_id TEXT NOT NULL,
recipient_user_id TEXT NOT NULL,
created_by TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','merged','closed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_merge_requests (
merge_request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
source_branch_id TEXT NOT NULL,
source_head_revision INTEGER NOT NULL,
target_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
target_branch_id TEXT NOT NULL,
target_base_revision INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','changes_requested','approved','merged','closed')),
conflicts JSONB NOT NULL DEFAULT '[]'::JSONB,
diff_summary JSONB DEFAULT NULL,
resolutions JSONB DEFAULT NULL,
created_by TEXT NOT NULL,
reviewed_by TEXT,
reviewer_comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_lineage_records (
lineage_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
transformation_type TEXT NOT NULL,
transformation_spec_hash TEXT,
produced_kind TEXT NOT NULL,
produced_id TEXT NOT NULL,
policy_snapshot_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_audit_events (
audit_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor_type TEXT NOT NULL DEFAULT 'user',
correlation_id TEXT NOT NULL,
execution_id UUID,
details JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
-- Canvas pages: tenant lookup, branch lookup
CREATE INDEX IF NOT EXISTS idx_oracle_pages_tenant ON oracle_canvas_pages(tenant_id);
CREATE INDEX IF NOT EXISTS idx_oracle_pages_owner ON oracle_canvas_pages(owner_id);
CREATE INDEX IF NOT EXISTS idx_oracle_pages_branch ON oracle_canvas_pages(branch_id);
-- Revisions: page-scoped revision queries
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_page ON oracle_canvas_page_revisions(page_id, revision_number DESC);
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_tenant ON oracle_canvas_page_revisions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_execution ON oracle_canvas_page_revisions(execution_id);
-- Components: page-scoped, lifecycle
CREATE INDEX IF NOT EXISTS idx_oracle_components_page ON oracle_canvas_components(page_id, lifecycle_state);
CREATE INDEX IF NOT EXISTS idx_oracle_components_tenant ON oracle_canvas_components(tenant_id);
-- Prompt executions: page/actor lookup
CREATE INDEX IF NOT EXISTS idx_oracle_executions_page ON oracle_prompt_executions(page_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oracle_executions_actor ON oracle_prompt_executions(actor_id, created_at DESC);
-- Templates: tenant + category + status
CREATE INDEX IF NOT EXISTS idx_oracle_templates_tenant_cat ON oracle_component_templates(tenant_id, category, status);
-- Forks: source and recipient lookup
CREATE INDEX IF NOT EXISTS idx_oracle_forks_source ON oracle_forks(source_page_id);
CREATE INDEX IF NOT EXISTS idx_oracle_forks_recipient ON oracle_forks(recipient_user_id);
-- Merge requests: target/source page, status
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_target ON oracle_merge_requests(target_page_id, status);
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_source ON oracle_merge_requests(source_page_id, status);
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_tenant ON oracle_merge_requests(tenant_id, status);
-- Lineage: source/produced lookups
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_source ON oracle_lineage_records(source_kind, source_id);
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_produced ON oracle_lineage_records(produced_kind, produced_id);
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_tenant ON oracle_lineage_records(tenant_id);
-- Audit: entity lookup, correlation lookup
CREATE INDEX IF NOT EXISTS idx_oracle_audit_entity ON oracle_audit_events(entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oracle_audit_correlation ON oracle_audit_events(correlation_id);
CREATE INDEX IF NOT EXISTS idx_oracle_audit_tenant ON oracle_audit_events(tenant_id, created_at DESC);

View File

@@ -0,0 +1,512 @@
"""
oracle/semantic_catalog.py
Business-semantic layer for Oracle's natural DB planner.
This sits between raw schema introspection and SQL generation. It defines:
- authoritative tables and columns for business concepts
- deprecated or sparse fields the planner should avoid
- preferred join paths
- compact semantic context for the planner prompt
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
class Confidence:
RELIABLE = "reliable"
PARTIAL = "partial"
SPARSE = "sparse"
DEPRECATED = "deprecated"
@dataclass(frozen=True)
class FieldDescriptor:
table: str
column: str
confidence: str
description: str
notes: str = ""
valid_values: tuple[str, ...] = ()
examples: tuple[str, ...] = ()
@dataclass(frozen=True)
class JoinPath:
from_table: str
from_col: str
to_table: str
to_col: str
join_type: str = "INNER"
notes: str = ""
@dataclass
class ConceptDescriptor:
concept_id: str
label: str
description: str
authoritative_fields: list[FieldDescriptor]
deprecated_fields: list[FieldDescriptor] = field(default_factory=list)
preferred_join_paths: list[JoinPath] = field(default_factory=list)
usage_notes: str = ""
CATALOG_VERSION = "velocity_semantic_v2026_04_25_01"
@dataclass(frozen=True)
class ColumnMetadata:
table: str
column: str
topic: str
meaning: str
reliability: str
valid_values: tuple[str, ...] = ()
examples: tuple[str, ...] = ()
usage: str = ""
avoid: bool = False
VALID_QD_SCORE_TYPES: tuple[str, ...] = (
"overall",
"intent",
"engagement",
"urgency",
"financial_qualification",
)
COLUMN_METADATA: list[ColumnMetadata] = [
ColumnMetadata(
"intel_qd_scores",
"score_type",
"qd_score",
"Score family/category. There is no score_type value named QD.",
Confidence.RELIABLE,
valid_values=VALID_QD_SCORE_TYPES,
examples=("overall", "intent", "engagement"),
usage=(
"For generic QD score prompts, prefer score_type = 'overall'. "
"For specific intent/engagement/urgency/financial prompts, use the matching valid value. "
"Never filter score_type = 'QD'."
),
),
ColumnMetadata(
"intel_qd_scores",
"current_value",
"qd_score",
"Authoritative numeric score value for the selected score_type.",
Confidence.RELIABLE,
examples=("98.0", "72.4"),
usage="Rank, sort, average, or threshold QD-style scores with this column.",
),
ColumnMetadata(
"intel_qd_scores",
"computed_at",
"qd_score",
"Timestamp when the score was computed.",
Confidence.RELIABLE,
examples=("2026-04-18T00:00:00"),
usage="Use for score freshness, not client contact recency.",
),
ColumnMetadata(
"intel_interactions",
"happened_at",
"contact_recency",
"Primary timestamp for client contact and interaction recency.",
Confidence.RELIABLE,
usage="Use for contacted, last contacted, recent contact, activity, and timeline prompts.",
),
ColumnMetadata(
"read_last_contacted",
"last_contact_at",
"contact_recency",
"Precomputed per-client last contact timestamp.",
Confidence.RELIABLE,
usage="Prefer for client-level last-contact summaries when this read model is available.",
),
ColumnMetadata(
"edge_communication_events",
"timestamp",
"contact_recency",
"Legacy/sparse event timestamp that is not reliable for Oracle CRM recency.",
Confidence.SPARSE,
usage="Do not use for contact prompts.",
avoid=True,
),
ColumnMetadata(
"crm_property_interests",
"last_discussed_at",
"contact_recency",
"Sparse legacy field; property interest does not prove recent contact.",
Confidence.SPARSE,
usage="Do not use as the primary recency filter.",
avoid=True,
),
ColumnMetadata(
"crm_property_interests",
"project_name",
"property_interest",
"Human-readable project/property name attached to a client's interest.",
Confidence.RELIABLE,
examples=("Atri Surya Toron", "Godrej Elevate"),
usage="Use ILIKE filters for property/project scoped prompts.",
),
ColumnMetadata(
"crm_property_interests",
"interest_level",
"property_interest",
"Interest strength label or score imported from CRM enrichment.",
Confidence.RELIABLE,
usage="Use with project_name and person_id to rank interested clients or properties.",
),
]
CONCEPTS: list[ConceptDescriptor] = [
ConceptDescriptor(
concept_id="person_identity",
label="Client Identity",
description="Canonical identity record for a person in CRM.",
authoritative_fields=[
FieldDescriptor("crm_people", "person_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("crm_people", "full_name", Confidence.RELIABLE, "Display name"),
FieldDescriptor("crm_people", "primary_email", Confidence.RELIABLE, "Email"),
FieldDescriptor("crm_people", "primary_phone", Confidence.RELIABLE, "Phone"),
FieldDescriptor("crm_people", "persona_labels", Confidence.PARTIAL, "Buyer persona labels"),
],
usage_notes=(
"Anchor client-level queries on crm_people.person_id. "
"Treat crm_people as the identity source of truth."
),
),
ConceptDescriptor(
concept_id="lead_funnel",
label="Lead Funnel",
description="Lead ownership, stage, status, and urgency.",
authoritative_fields=[
FieldDescriptor("crm_leads", "lead_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("crm_leads", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("crm_leads", "stage", Confidence.RELIABLE, "Current funnel stage"),
FieldDescriptor("crm_leads", "status", Confidence.RELIABLE, "Lead status"),
FieldDescriptor("crm_leads", "assigned_user_id", Confidence.RELIABLE, "Owning user"),
FieldDescriptor("crm_leads", "budget_band", Confidence.PARTIAL, "Budget band"),
FieldDescriptor("crm_leads", "urgency", Confidence.PARTIAL, "Urgency tag"),
],
preferred_join_paths=[
JoinPath("crm_people", "person_id", "crm_leads", "person_id"),
],
),
ConceptDescriptor(
concept_id="qd_score",
label="QD Score",
description="Qualification / Desire score source of truth.",
authoritative_fields=[
FieldDescriptor("intel_qd_scores", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("intel_qd_scores", "current_value", Confidence.RELIABLE, "Authoritative QD score"),
FieldDescriptor(
"intel_qd_scores",
"score_type",
Confidence.RELIABLE,
"Score family",
notes="Valid values are overall, intent, engagement, urgency, financial_qualification. There is no value named QD.",
valid_values=VALID_QD_SCORE_TYPES,
),
FieldDescriptor("intel_qd_scores", "computed_at", Confidence.RELIABLE, "Score timestamp"),
],
deprecated_fields=[
FieldDescriptor("crm_people", "engagement_score", Confidence.DEPRECATED, "Not QD"),
FieldDescriptor("crm_leads", "engagement_score", Confidence.DEPRECATED, "Not QD"),
FieldDescriptor("intel_interactions", "engagement_score", Confidence.DEPRECATED, "Not QD"),
],
usage_notes=(
"When a prompt mentions QD, qualification, desire, or intent score, "
"use intel_qd_scores.current_value. Do not substitute engagement_score. "
"Do not filter score_type = 'QD'. For generic QD prompts, use score_type = 'overall'. "
"Use intent, engagement, urgency, or financial_qualification only when the prompt asks for that specific family."
),
),
ConceptDescriptor(
concept_id="communication_events",
label="Communication Events",
description="Authoritative recent-contact and interaction history source.",
authoritative_fields=[
FieldDescriptor("intel_interactions", "interaction_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("intel_interactions", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("intel_interactions", "channel", Confidence.RELIABLE, "Interaction channel"),
FieldDescriptor("intel_interactions", "interaction_type", Confidence.RELIABLE, "Interaction type"),
FieldDescriptor("intel_interactions", "happened_at", Confidence.RELIABLE, "Primary recency timestamp"),
FieldDescriptor("intel_interactions", "summary", Confidence.RELIABLE, "Interaction summary"),
],
deprecated_fields=[
FieldDescriptor("edge_communication_events", "timestamp", Confidence.SPARSE, "Do not use for recency"),
FieldDescriptor("crm_property_interests", "last_discussed_at", Confidence.SPARSE, "Do not use for recency"),
],
preferred_join_paths=[
JoinPath("crm_people", "person_id", "intel_interactions", "person_id", "LEFT"),
JoinPath("intel_interactions", "interaction_id", "intel_calls", "interaction_id", "LEFT"),
JoinPath("intel_interactions", "interaction_id", "intel_messages", "interaction_id", "LEFT"),
JoinPath("intel_interactions", "interaction_id", "intel_emails", "interaction_id", "LEFT"),
],
usage_notes=(
"For recent contact, last contact, or contacted us, prefer intel_interactions.happened_at. "
"Use read_last_contacted if available for precomputed summaries."
),
),
ConceptDescriptor(
concept_id="last_contact_read_model",
label="Last Contact Read Model",
description="Per-person last-contact summary materialization.",
authoritative_fields=[
FieldDescriptor("read_last_contacted", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("read_last_contacted", "last_contact_at", Confidence.RELIABLE, "Last contact time"),
FieldDescriptor("read_last_contacted", "last_channel", Confidence.RELIABLE, "Last contact channel"),
FieldDescriptor("read_last_contacted", "days_since_contact", Confidence.RELIABLE, "Recency in days"),
FieldDescriptor("read_last_contacted", "interactions_last_90d", Confidence.RELIABLE, "Recent interaction volume"),
],
deprecated_fields=[
FieldDescriptor("crm_property_interests", "last_discussed_at", Confidence.DEPRECATED, "Stale field"),
],
usage_notes=(
"If this table exists, prefer it for last-contact prompts over rebuilding recency from raw interactions."
),
),
ConceptDescriptor(
concept_id="next_best_action",
label="Next Best Action",
description="Precomputed follow-up action recommendations.",
authoritative_fields=[
FieldDescriptor("read_next_best_action", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("read_next_best_action", "action_label", Confidence.RELIABLE, "Human-readable action"),
FieldDescriptor("read_next_best_action", "urgency", Confidence.RELIABLE, "Urgency"),
FieldDescriptor("read_next_best_action", "recommended_channel", Confidence.RELIABLE, "Suggested channel"),
FieldDescriptor("read_next_best_action", "execute_within_hours", Confidence.RELIABLE, "Action SLA"),
],
),
ConceptDescriptor(
concept_id="property_interest",
label="Property Interest",
description="Client-level project or unit interest records.",
authoritative_fields=[
FieldDescriptor("crm_property_interests", "interest_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("crm_property_interests", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("crm_property_interests", "project_id", Confidence.PARTIAL, "FK to inventory_projects"),
FieldDescriptor("crm_property_interests", "project_name", Confidence.RELIABLE, "Primary text project scope"),
FieldDescriptor("crm_property_interests", "unit_id", Confidence.PARTIAL, "FK to inventory_units"),
FieldDescriptor("crm_property_interests", "interest_level", Confidence.RELIABLE, "Interest strength"),
FieldDescriptor("crm_property_interests", "configuration_preference", Confidence.PARTIAL, "Configuration"),
FieldDescriptor("crm_property_interests", "budget_min", Confidence.PARTIAL, "Minimum budget"),
FieldDescriptor("crm_property_interests", "budget_max", Confidence.PARTIAL, "Maximum budget"),
FieldDescriptor("crm_property_interests", "financing_plan", Confidence.PARTIAL, "Financing plan"),
FieldDescriptor("crm_property_interests", "notes", Confidence.PARTIAL, "Free-text notes"),
],
deprecated_fields=[
FieldDescriptor("crm_property_interests", "last_discussed_at", Confidence.DEPRECATED, "Do not use for recency"),
],
preferred_join_paths=[
JoinPath("crm_people", "person_id", "crm_property_interests", "person_id", "LEFT"),
JoinPath("crm_property_interests", "project_id", "inventory_projects", "project_id", "LEFT"),
],
usage_notes=(
"For prompts scoped to a specific property or project, filter on crm_property_interests.project_name "
"case-insensitively. For top properties, group by project_name and count distinct person_id."
),
),
ConceptDescriptor(
concept_id="opportunities",
label="Opportunities",
description="Deal pipeline records.",
authoritative_fields=[
FieldDescriptor("crm_opportunities", "opportunity_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("crm_opportunities", "lead_id", Confidence.RELIABLE, "FK to crm_leads"),
FieldDescriptor("crm_opportunities", "project_id", Confidence.RELIABLE, "FK to inventory_projects"),
FieldDescriptor("crm_opportunities", "stage", Confidence.RELIABLE, "Opportunity stage"),
FieldDescriptor("crm_opportunities", "value", Confidence.RELIABLE, "Deal value"),
FieldDescriptor("crm_opportunities", "probability", Confidence.PARTIAL, "Probability"),
FieldDescriptor("crm_opportunities", "next_action", Confidence.RELIABLE, "Next action"),
],
preferred_join_paths=[
JoinPath("crm_people", "person_id", "crm_leads", "person_id"),
JoinPath("crm_leads", "lead_id", "crm_opportunities", "lead_id", "LEFT"),
JoinPath("crm_opportunities", "project_id", "inventory_projects", "project_id", "LEFT"),
],
),
ConceptDescriptor(
concept_id="site_visits",
label="Site Visits",
description="Physical visit records and outcomes.",
authoritative_fields=[
FieldDescriptor("intel_visits", "visit_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("intel_visits", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("intel_visits", "project_id", Confidence.PARTIAL, "FK to inventory_projects"),
FieldDescriptor("intel_visits", "project_name", Confidence.PARTIAL, "Project name"),
FieldDescriptor("intel_visits", "visited_at", Confidence.RELIABLE, "Visit timestamp"),
FieldDescriptor("intel_visits", "visit_notes", Confidence.RELIABLE, "Visit notes"),
],
),
ConceptDescriptor(
concept_id="inventory",
label="Inventory",
description="Project and unit master data.",
authoritative_fields=[
FieldDescriptor("inventory_projects", "project_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("inventory_projects", "project_name", Confidence.RELIABLE, "Project name"),
FieldDescriptor("inventory_projects", "developer_name", Confidence.RELIABLE, "Developer"),
FieldDescriptor("inventory_projects", "micro_market", Confidence.RELIABLE, "Micro market"),
FieldDescriptor("inventory_units", "unit_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("inventory_units", "project_id", Confidence.RELIABLE, "FK to inventory_projects"),
FieldDescriptor("inventory_units", "configuration", Confidence.RELIABLE, "Configuration"),
FieldDescriptor("inventory_units", "price_current", Confidence.RELIABLE, "Current price"),
FieldDescriptor("inventory_units", "status", Confidence.RELIABLE, "Unit status"),
],
),
ConceptDescriptor(
concept_id="extracted_facts",
label="Extracted Facts",
description="AI-extracted CRM memory facts.",
authoritative_fields=[
FieldDescriptor("intel_extracted_facts", "fact_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("intel_extracted_facts", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("intel_extracted_facts", "fact_type", Confidence.RELIABLE, "Fact type"),
FieldDescriptor("intel_extracted_facts", "fact_text", Confidence.RELIABLE, "Fact text"),
FieldDescriptor("intel_extracted_facts", "confidence", Confidence.RELIABLE, "Extraction confidence"),
FieldDescriptor("intel_extracted_facts", "effective_date", Confidence.PARTIAL, "Fact date"),
],
),
ConceptDescriptor(
concept_id="call_objections",
label="Call Objections",
description="Structured objections extracted from calls.",
authoritative_fields=[
FieldDescriptor("intel_call_objections", "objection_id", Confidence.RELIABLE, "Primary key"),
FieldDescriptor("intel_call_objections", "person_id", Confidence.RELIABLE, "FK to crm_people"),
FieldDescriptor("intel_call_objections", "objection_type", Confidence.RELIABLE, "Objection type"),
FieldDescriptor("intel_call_objections", "objection_text", Confidence.RELIABLE, "Objection text"),
FieldDescriptor("intel_call_objections", "intensity", Confidence.RELIABLE, "Intensity"),
FieldDescriptor("intel_call_objections", "was_resolved", Confidence.RELIABLE, "Resolution flag"),
FieldDescriptor("intel_call_objections", "raised_at", Confidence.RELIABLE, "Raised timestamp"),
],
),
]
_CONCEPT_INDEX: dict[str, ConceptDescriptor] = {concept.concept_id: concept for concept in CONCEPTS}
def get_concept(concept_id: str) -> ConceptDescriptor | None:
return _CONCEPT_INDEX.get(concept_id)
def all_concepts() -> list[ConceptDescriptor]:
return CONCEPTS
INTENT_CONCEPT_MAP: dict[str, list[str]] = {
"last_contacted": ["last_contact_read_model", "communication_events", "person_identity"],
"interested_clients": ["property_interest", "person_identity", "lead_funnel"],
"qd_score": ["qd_score", "person_identity"],
"pipeline": ["opportunities", "lead_funnel", "person_identity"],
"site_visits": ["site_visits", "person_identity", "property_interest"],
"timeline": ["communication_events", "person_identity"],
"objections": ["call_objections", "communication_events", "person_identity"],
"broker_performance": ["lead_funnel", "opportunities"],
"next_action": ["next_best_action", "person_identity", "lead_funnel"],
"inventory": ["inventory", "property_interest"],
"extracted_facts": ["extracted_facts", "person_identity"],
"client_360": [
"person_identity",
"lead_funnel",
"qd_score",
"communication_events",
"property_interest",
"opportunities",
"next_best_action",
],
}
def concepts_for_intent(intent: str) -> list[ConceptDescriptor]:
ids = INTENT_CONCEPT_MAP.get(intent, ["person_identity", "lead_funnel"])
return [_CONCEPT_INDEX[concept_id] for concept_id in ids if concept_id in _CONCEPT_INDEX]
def _field_to_dict(field: FieldDescriptor) -> dict[str, Any]:
return {
"table": field.table,
"column": field.column,
"confidence": field.confidence,
"description": field.description,
**({"notes": field.notes} if field.notes else {}),
**({"valid_values": list(field.valid_values)} if field.valid_values else {}),
**({"examples": list(field.examples)} if field.examples else {}),
}
def concept_to_dict(concept: ConceptDescriptor) -> dict[str, Any]:
return {
"concept_id": concept.concept_id,
"label": concept.label,
"description": concept.description,
"authoritative_fields": [_field_to_dict(field) for field in concept.authoritative_fields],
"deprecated_fields": [_field_to_dict(field) for field in concept.deprecated_fields],
"preferred_join_paths": [
{
"from": f"{join.from_table}.{join.from_col}",
"to": f"{join.to_table}.{join.to_col}",
"join_type": join.join_type,
**({"notes": join.notes} if join.notes else {}),
}
for join in concept.preferred_join_paths
],
**({"usage_notes": concept.usage_notes} if concept.usage_notes else {}),
}
def build_semantic_context_for_planner(detected_intents: list[str], *, max_concepts: int = 5) -> str:
import json
seen: set[str] = set()
ordered: list[ConceptDescriptor] = []
for intent in detected_intents:
for concept in concepts_for_intent(intent):
if concept.concept_id not in seen:
seen.add(concept.concept_id)
ordered.append(concept)
relevant_topics = set(detected_intents)
if "last_contacted" in relevant_topics or "timeline" in relevant_topics:
relevant_topics.add("contact_recency")
if "interested_clients" in relevant_topics or "inventory" in relevant_topics:
relevant_topics.add("property_interest")
if "qd_score" in relevant_topics:
relevant_topics.add("qd_score")
column_metadata = [
{
"table": item.table,
"column": item.column,
"topic": item.topic,
"meaning": item.meaning,
"reliability": item.reliability,
**({"valid_values": list(item.valid_values)} if item.valid_values else {}),
**({"examples": list(item.examples)} if item.examples else {}),
**({"usage": item.usage} if item.usage else {}),
**({"avoid": item.avoid} if item.avoid else {}),
}
for item in COLUMN_METADATA
if item.topic in relevant_topics or item.avoid
]
return json.dumps(
{
"catalog_version": CATALOG_VERSION,
"concepts": [concept_to_dict(concept) for concept in ordered[:max_concepts]],
"column_metadata": column_metadata,
"global_rules": [
"Do not invent enum values. Use only valid_values from column_metadata when filtering enum-like columns.",
"Queries that return zero rows because of impossible enum filters are invalid plans.",
"For contact recency, use read_last_contacted.last_contact_at or intel_interactions.happened_at.",
"Do not use fields marked avoid=true for the main business filter.",
],
},
separators=(",", ":"),
)

View File

@@ -0,0 +1,382 @@
"""
oracle/visualization_planner.py
Pick Oracle canvas renderer types from actual result shape.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class ColumnProfile:
name: str
is_numeric: bool
is_string: bool
is_datetime: bool
is_boolean: bool
null_rate: float
sample_values: list[Any]
@dataclass
class VisualizationDecision:
component_type: str
x_axis: str | None
y_axis: str | None
series_cols: list[str]
dimension_cols: list[str]
measure_cols: list[str]
title: str
width_mode: str
min_height_px: int
skeleton_variant: str
viz_params: dict[str, Any]
data_bindings: dict[str, Any]
confidence: float
reasoning: str
def _looks_like_timestamp(value: str) -> bool:
return bool(re.match(r"\d{4}-\d{2}-\d{2}", value))
def _profile_columns(rows: list[dict[str, Any]], columns: list[str]) -> list[ColumnProfile]:
if not rows:
return [ColumnProfile(column, False, False, False, False, 1.0, []) for column in columns]
sample_size = min(len(rows), 20)
profiles: list[ColumnProfile] = []
for column in columns:
values = [rows[index].get(column) for index in range(sample_size)]
non_null = [value for value in values if value is not None]
null_rate = 1.0 - len(non_null) / sample_size if sample_size else 1.0
profiles.append(
ColumnProfile(
name=column,
is_numeric=any(isinstance(value, (int, float)) for value in non_null),
is_string=any(isinstance(value, str) and not _looks_like_timestamp(value) for value in non_null[:5]),
is_datetime=any(isinstance(value, str) and _looks_like_timestamp(value) for value in non_null[:5]),
is_boolean=any(isinstance(value, bool) for value in non_null),
null_rate=null_rate,
sample_values=non_null[:3],
)
)
return profiles
_DIMENSION_HINTS = {
"name", "full_name", "project_name", "developer_name", "agent_name",
"broker_company", "category", "label", "stage", "channel", "type",
"micro_market", "district", "status", "persona", "nationality",
}
_MEASURE_HINTS = {
"count", "total", "sum", "avg", "average", "value", "score", "rate",
"current_value", "qd_score", "probability", "interest_count", "visit_count",
"interaction_count", "days", "amount", "revenue",
}
_TIMESTAMP_HINTS = {"at", "date", "time", "when", "timestamp"}
_PREFERRED_X = [
"project_name", "developer_name", "category", "stage", "channel",
"micro_market", "broker_company", "agent_name", "name", "full_name",
"label", "status", "type",
]
_PREFERRED_Y = [
"count", "total", "interested_clients", "interest_count", "client_count",
"current_value", "qd_score", "value", "probability", "interaction_count",
"visit_count", "days_since_last_contact",
]
_TABLE_COLUMN_PRESETS: dict[str, list[str]] = {
"crm_people": ["full_name", "primary_phone", "primary_email", "persona_labels"],
"intel_qd_scores": ["full_name", "current_value", "score_type", "computed_at"],
"crm_leads": ["full_name", "stage", "status", "budget_band", "urgency"],
"intel_interactions": ["full_name", "channel", "interaction_type", "happened_at", "summary"],
"read_last_contacted": ["full_name", "last_contacted_at", "last_channel", "days_since_last_contact", "staleness_label"],
"crm_property_interests": ["full_name", "project_name", "interest_level", "configuration_preference"],
"intel_call_objections": ["full_name", "objection_type", "intensity", "was_resolved", "raised_at"],
"intel_extracted_facts": ["full_name", "fact_type", "fact_text", "confidence", "effective_date"],
"read_next_best_action": ["full_name", "action_label", "urgency", "recommended_channel", "execute_within_hours"],
}
def _pick_axis(candidates: list[str], preferred: list[str]) -> str | None:
for candidate in preferred:
if candidate in candidates:
return candidate
return candidates[0] if candidates else None
def _title_from_prompt(prompt: str) -> str:
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")[:72]
return (words[:1].upper() + words[1:]) if words else "Oracle Query Result"
class VisualizationPlanner:
def plan(
self,
*,
rows: list[dict[str, Any]],
columns: list[str],
prompt: str,
source_tables: list[str],
profile_suggested_type: str | None = None,
title_from_planner: str | None = None,
) -> VisualizationDecision:
profiles = _profile_columns(rows, columns)
classifications = {profile.name: self._classify_column(profile) for profile in profiles}
dimensions = [column for column, kind in classifications.items() if kind == "dimension"]
measures = [column for column, kind in classifications.items() if kind == "measure"]
timestamps = [column for column, kind in classifications.items() if kind == "timestamp"]
row_count = len(rows)
prompt_lower = prompt.lower()
if profile_suggested_type:
return self._build_decision(
component_type=profile_suggested_type,
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning=f"Execution profiler suggested {profile_suggested_type}",
confidence=0.9,
)
timeline_terms = ("timeline", "history", "activity", "message", "call log", "whatsapp", "email", "conversation", "transcript", "interaction")
if any(term in prompt_lower for term in timeline_terms) and timestamps:
return self._build_decision(
component_type="activityStream",
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning="Activity-like prompt plus timestamped result.",
confidence=0.88,
)
if row_count == 1 and measures and not dimensions:
return self._build_decision(
component_type="kpiTile",
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning="Single numeric row.",
confidence=0.92,
)
if timestamps and measures and any(term in prompt_lower for term in ("trend", "over time", "monthly", "weekly", "growth", "timeseries")):
return self._build_decision(
component_type="lineChart",
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning="Temporal series plus measure.",
confidence=0.87,
)
if ("stage" in columns or "pipeline" in prompt_lower) and any(term in prompt_lower for term in ("pipeline", "funnel", "stage", "kanban", "deal")):
return self._build_decision(
component_type="pipelineBoard",
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning="Pipeline-like prompt and stage-like data.",
confidence=0.85,
)
if dimensions and measures and row_count <= 30 and not timestamps:
return self._build_decision(
component_type="barChart",
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning="Categorical dimension plus measure.",
confidence=0.8,
)
return self._build_decision(
component_type="table",
dimensions=dimensions,
measures=measures,
timestamps=timestamps,
columns=columns,
rows=rows,
row_count=row_count,
prompt=prompt,
source_tables=source_tables,
title=title_from_planner,
reasoning="Default structured table.",
confidence=0.7,
)
@staticmethod
def _classify_column(profile: ColumnProfile) -> str:
lower = profile.name.lower()
if lower.endswith("_id"):
return "identity"
if profile.is_datetime or any(token in lower for token in _TIMESTAMP_HINTS):
return "timestamp"
if lower in _DIMENSION_HINTS or (profile.is_string and not profile.is_numeric):
return "dimension"
if profile.is_numeric or any(token in lower for token in _MEASURE_HINTS):
return "measure"
return "other"
def _build_decision(
self,
*,
component_type: str,
dimensions: list[str],
measures: list[str],
timestamps: list[str],
columns: list[str],
rows: list[dict[str, Any]],
row_count: int,
prompt: str,
source_tables: list[str],
title: str | None,
reasoning: str,
confidence: float,
) -> VisualizationDecision:
x_axis = _pick_axis(dimensions + timestamps, _PREFERRED_X + list(timestamps))
y_axis = _pick_axis(measures, _PREFERRED_Y)
if component_type == "table":
display_columns = self._table_columns(columns, source_tables)
else:
display_columns = columns
viz_params = self._build_viz_params(
component_type=component_type,
x_axis=x_axis,
y_axis=y_axis,
display_columns=display_columns,
row_count=row_count,
)
data_bindings = {
"dimensions": dimensions[:2] if dimensions else (timestamps[:1] if timestamps else []),
"measures": measures[:3],
"series": [],
"filters": [],
}
width_mode = "full" if component_type in {"table", "activityStream", "pipelineBoard"} else "half"
height_map = {
"kpiTile": 140,
"barChart": 320,
"lineChart": 320,
"activityStream": 380,
"table": 300,
"pipelineBoard": 400,
}
skeleton_map = {
"kpiTile": "kpi",
"barChart": "chart",
"lineChart": "chart",
"activityStream": "table",
"table": "table",
"pipelineBoard": "pipeline",
}
return VisualizationDecision(
component_type=component_type,
x_axis=x_axis,
y_axis=y_axis,
series_cols=[],
dimension_cols=dimensions,
measure_cols=measures,
title=title or _title_from_prompt(prompt),
width_mode=width_mode,
min_height_px=height_map.get(component_type, 300),
skeleton_variant=skeleton_map.get(component_type, "generic"),
viz_params=viz_params,
data_bindings=data_bindings,
confidence=confidence,
reasoning=reasoning,
)
@staticmethod
def _table_columns(all_columns: list[str], source_tables: list[str]) -> list[str]:
for table in source_tables:
preset = _TABLE_COLUMN_PRESETS.get(table)
if preset:
matched = [column for column in preset if column in all_columns]
if matched:
return matched
return [column for column in all_columns if not column.endswith("_id") or column == "person_id"][:8]
@staticmethod
def _build_viz_params(
*,
component_type: str,
x_axis: str | None,
y_axis: str | None,
display_columns: list[str],
row_count: int,
) -> dict[str, Any]:
del row_count
if component_type == "barChart":
return {
"xAxis": x_axis or "category",
"yAxis": y_axis or "value",
"sort": "desc",
"showLabels": True,
"legend": False,
}
if component_type == "lineChart":
return {"showPoints": True, "smooth": True}
if component_type == "kpiTile":
return {"label": "Result", "trend": "", "comparisonLabel": ""}
if component_type == "table":
return {
"columns": display_columns,
"emptyStateTitle": "No matching records found",
"emptyStateDescription": "The query ran successfully but returned no rows for this prompt.",
"rankBy": y_axis,
"showTopBadge": False,
}
if component_type == "activityStream":
return {"showUrgencyIndicator": True}
if component_type == "pipelineBoard":
return {"showValue": True, "colorByStage": True}
return {}
visualization_planner = VisualizationPlanner()

20
core/requirements.txt Normal file
View File

@@ -0,0 +1,20 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
python-dotenv>=1.0.0
requests>=2.31.0
pydantic>=2.8.0
# Meta Marketing API SDK
facebook-sdk>=3.1.0
facebook-business>=21.0.0
supabase>=2.10.0
# ── Sentinel QD Engine dependencies ──────────────────────────────────────────
asyncpg>=0.31.0 # Raw PostgreSQL async driver (no ORM)
python-jose[cryptography]>=3.3.0 # JWT encode/decode
passlib[bcrypt]>=1.7.4 # bcrypt password hashing
httpx>=0.28.0 # Async HTTP client for NemoClaw API calls
pynvml>=11.5.0 # GPU VRAM health checks before NemoClaw inference
python-multipart>=0.0.9 # Multipart form parsing for CSV uploads
pytest>=8.3.0
pytest-asyncio>=0.25.0
# Alembic for schema migrations (run separately, not imported by the app)
alembic>=1.14.0

View File

@@ -0,0 +1 @@
"""backend.routers package"""

View File

@@ -0,0 +1,142 @@
"""
backend/routers/cctv.py - CCTV ingestion and auto-mode session linkage.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, require_role
from backend.db.pool import get_pool
from backend.services.auto_mode_matcher import auto_mode_match_session
from backend.services.nemoclaw_client import profile_cctv_visitor
router = APIRouter()
class CCTVEventRequest(BaseModel):
zone: str
session_id: str | None = None
license_plate: str | None = None
face_description: str | None = None
vehicle_description: str | None = None
raw_payload: dict[str, Any] = Field(default_factory=dict)
captured_at: datetime | None = None
class FinalizeAutoModeRequest(BaseModel):
session_id: str
async def _ensure_session(
conn: asyncpg.Connection,
*,
session_id: str | None,
) -> None:
if not session_id:
return
await conn.execute(
"""
INSERT INTO perception_sessions (id, session_mode, video_asset_id, auto_mode_evidence)
VALUES ($1::uuid, 'auto', 'unknown', '{}'::jsonb)
ON CONFLICT (id) DO NOTHING
""",
session_id,
)
@router.post("/event", summary="Receive a CCTV frame event from the ONVIF/RTSP bridge")
async def ingest_cctv_event(
body: CCTVEventRequest,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> dict[str, Any]:
del user
profile = await profile_cctv_visitor(
license_plate=body.license_plate,
zone=body.zone,
face_description=body.face_description,
vehicle_description=body.vehicle_description,
)
captured_at = body.captured_at or datetime.now(timezone.utc)
async with pool.acquire() as conn:
async with conn.transaction():
await _ensure_session(conn, session_id=body.session_id)
row = await conn.fetchrow(
"""
INSERT INTO cctv_events
(zone, license_plate, vehicle_class, wealth_indicator, nemoclaw_tags,
nemoclaw_notes, linked_session_id, captured_at, raw_payload)
VALUES
($1, $2, $3, $4, $5::text[], $6, $7::uuid, $8, $9::jsonb)
RETURNING id::text
""",
body.zone,
body.license_plate,
profile.vehicle_class,
profile.wealth_indicator,
profile.tags_to_add,
profile.notes,
body.session_id,
captured_at,
body.raw_payload,
)
if body.session_id:
await conn.execute(
"""
UPDATE perception_sessions
SET auto_mode_evidence = auto_mode_evidence || $1::jsonb
WHERE id = $2::uuid
""",
{
"license_plate": body.license_plate,
"vehicle_description": body.vehicle_description,
"face_description": body.face_description,
"vehicle_class": profile.vehicle_class,
"wealth_indicator": profile.wealth_indicator,
"nemoclaw_tags": profile.tags_to_add,
"latest_cctv_event_id": row["id"],
},
body.session_id,
)
return {
"status": "ingested",
"event_id": row["id"],
"session_id": body.session_id,
"wealth_indicator": profile.wealth_indicator,
"vehicle_class": profile.vehicle_class,
"tags_to_add": profile.tags_to_add,
}
@router.post("/finalize-auto-mode", summary="Match or create a lead after an auto mode session")
async def finalize_auto_mode(
body: FinalizeAutoModeRequest,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> dict[str, Any]:
del user
async with pool.acquire() as conn:
async with conn.transaction():
try:
result = await auto_mode_match_session(conn, session_id=body.session_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {
"status": "matched",
"session_id": body.session_id,
"lead_id": result.lead_id,
"action": result.action,
"confidence": result.confidence,
"rationale": result.rationale,
"tags_applied": result.tags_applied,
}

View File

@@ -0,0 +1,102 @@
"""
backend/routers/scenes.py - Video scene map ingestion.
"""
from __future__ import annotations
import csv
import io
import asyncpg
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from backend.auth.dependencies import UserPrincipal, require_role
from backend.db.pool import get_pool
router = APIRouter()
@router.post("/upload", summary="Upload a scene CSV for a marketing video")
async def upload_scene_map(
video_asset_id: str,
file: UploadFile = File(...),
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> dict[str, int | str]:
del user
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="Scene upload must be a CSV file.")
raw_bytes = await file.read()
try:
text = raw_bytes.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="CSV must be UTF-8 encoded.") from exc
reader = csv.DictReader(io.StringIO(text))
required = {"scene_no", "start_ms", "end_ms", "room_type"}
if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
raise HTTPException(
status_code=400,
detail="CSV must contain scene_no,start_ms,end_ms,room_type columns.",
)
rows: list[tuple[str, int, int, int, str, str | None]] = []
for row in reader:
try:
rows.append(
(
video_asset_id,
int(row["scene_no"]),
int(row["start_ms"]),
int(row["end_ms"]),
row["room_type"].strip(),
(row.get("description") or "").strip() or None,
)
)
except (TypeError, ValueError, KeyError) as exc:
raise HTTPException(status_code=400, detail=f"Invalid scene row: {row}") from exc
if not rows:
raise HTTPException(status_code=400, detail="CSV contains no scene rows.")
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
"DELETE FROM video_scene_maps WHERE video_asset_id = $1",
video_asset_id,
)
await conn.executemany(
"""
INSERT INTO video_scene_maps
(video_asset_id, scene_no, start_ms, end_ms, room_type, description)
VALUES ($1, $2, $3, $4, $5, $6)
""",
rows,
)
return {"status": "uploaded", "video_asset_id": video_asset_id, "row_count": len(rows)}
@router.get("/{video_asset_id}", summary="List the uploaded scene map for a marketing video")
async def get_scene_map(
video_asset_id: str,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> dict[str, object]:
del user
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT scene_no, start_ms, end_ms, room_type, description
FROM video_scene_maps
WHERE video_asset_id = $1
ORDER BY scene_no ASC
""",
video_asset_id,
)
return {
"video_asset_id": video_asset_id,
"row_count": len(rows),
"scenes": [dict(row) for row in rows],
}

View File

@@ -0,0 +1,805 @@
"""
backend/routers/sentinel.py - Sentinel WebSocket and biometric endpoints.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Set
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from backend.auth.dependencies import UserPrincipal, require_role
from backend.db.pool import get_pool
from backend.services.auto_mode_matcher import auto_mode_match_session
from backend.services.nemoclaw_client import score_qd, tag_lead
logger = logging.getLogger("velocity.sentinel")
router = APIRouter()
_UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
)
class SentinelConnectionManager:
def __init__(self) -> None:
self._channels: dict[str, Set[WebSocket]] = {
"notifications": set(),
"perception": set(),
}
async def connect(self, ws: WebSocket, channel: str) -> None:
await ws.accept()
self._channels.setdefault(channel, set()).add(ws)
logger.info("WS connected: channel=%s total=%d", channel, len(self._channels[channel]))
def disconnect(self, ws: WebSocket, channel: str) -> None:
self._channels.get(channel, set()).discard(ws)
async def broadcast(self, payload: dict[str, Any], channel: str = "notifications") -> None:
dead: Set[WebSocket] = set()
for ws in list(self._channels.get(channel, set())):
try:
await ws.send_text(json.dumps(payload))
except Exception:
dead.add(ws)
self._channels[channel] -= dead
async def broadcast_all(self, payload: dict[str, Any]) -> None:
for channel in self._channels:
await self.broadcast(payload, channel)
manager = SentinelConnectionManager()
def _is_uuid(value: str | None) -> bool:
return bool(value and _UUID_RE.match(value))
async def _resolve_scene_label(
conn: asyncpg.Connection,
video_asset_id: str | None,
video_ts_ms: int,
) -> str | None:
if not video_asset_id:
return None
row = await conn.fetchrow(
"""
SELECT room_type, description
FROM video_scene_maps
WHERE video_asset_id = $1
AND start_ms <= $2
AND end_ms >= $2
ORDER BY start_ms DESC
LIMIT 1
""",
video_asset_id,
video_ts_ms,
)
if not row:
return None
description = row["description"]
return f"{row['room_type']} - {description}" if description else str(row["room_type"])
async def _ensure_session_row(
conn: asyncpg.Connection,
*,
session_id: str,
session_mode: str,
lead_id: str | None,
video_asset_id: str | None,
) -> None:
await conn.execute(
"""
INSERT INTO perception_sessions (id, session_mode, lead_id, video_asset_id, auto_mode_evidence)
VALUES ($1::uuid, $2::session_mode_enum, $3::uuid, $4, '{}'::jsonb)
ON CONFLICT (id) DO UPDATE
SET video_asset_id = EXCLUDED.video_asset_id,
lead_id = COALESCE(perception_sessions.lead_id, EXCLUDED.lead_id)
""",
session_id,
session_mode,
lead_id if _is_uuid(lead_id) else None,
video_asset_id or "unknown",
)
async def _resolve_canonical_context(
conn: asyncpg.Connection,
*,
person_id: str | None,
canonical_lead_id: str | None,
legacy_lead_id: str | None,
) -> dict[str, Any] | None:
if _is_uuid(person_id):
row = await conn.fetchrow(
"""
SELECT
p.person_id::text AS person_id,
p.full_name,
p.primary_phone,
p.buyer_type,
p.legacy_li_id::text AS legacy_li_id,
cl.lead_id::text AS lead_id,
cl.status AS lead_status,
cl.budget_band,
cl.urgency,
COALESCE((
SELECT current_value
FROM intel_qd_scores
WHERE person_id = p.person_id AND score_type = 'engagement_score'
ORDER BY computed_at DESC
LIMIT 1
), 0.50) AS engagement_score
FROM crm_people p
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id
WHERE p.person_id = $1::uuid
ORDER BY cl.created_at DESC NULLS LAST
LIMIT 1
""",
person_id,
)
if row:
return dict(row)
if _is_uuid(canonical_lead_id):
row = await conn.fetchrow(
"""
SELECT
p.person_id::text AS person_id,
p.full_name,
p.primary_phone,
p.buyer_type,
p.legacy_li_id::text AS legacy_li_id,
cl.lead_id::text AS lead_id,
cl.status AS lead_status,
cl.budget_band,
cl.urgency,
COALESCE((
SELECT current_value
FROM intel_qd_scores
WHERE person_id = p.person_id AND score_type = 'engagement_score'
ORDER BY computed_at DESC
LIMIT 1
), 0.50) AS engagement_score
FROM crm_leads cl
INNER JOIN crm_people p ON p.person_id = cl.person_id
WHERE cl.lead_id = $1::uuid
LIMIT 1
""",
canonical_lead_id,
)
if row:
return dict(row)
if _is_uuid(legacy_lead_id):
row = await conn.fetchrow(
"""
SELECT
p.person_id::text AS person_id,
p.full_name,
p.primary_phone,
p.buyer_type,
p.legacy_li_id::text AS legacy_li_id,
cl.lead_id::text AS lead_id,
cl.status AS lead_status,
cl.budget_band,
cl.urgency,
COALESCE((
SELECT current_value
FROM intel_qd_scores
WHERE person_id = p.person_id AND score_type = 'engagement_score'
ORDER BY computed_at DESC
LIMIT 1
), 0.50) AS engagement_score
FROM crm_people p
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id
WHERE p.legacy_li_id = $1::uuid
ORDER BY cl.created_at DESC NULLS LAST
LIMIT 1
""",
legacy_lead_id,
)
if row:
return dict(row)
return None
async def _ensure_canonical_interaction(
conn: asyncpg.Connection,
*,
person_id: str,
canonical_lead_id: str | None,
session_id: str,
session_mode: str,
video_asset_id: str | None,
) -> str:
existing = await conn.fetchrow(
"""
SELECT interaction_id::text
FROM intel_interactions
WHERE source_ref = $1
AND channel = 'perception_session'::intel_channel
ORDER BY created_at DESC
LIMIT 1
""",
session_id,
)
if existing:
return existing["interaction_id"]
row = await conn.fetchrow(
"""
INSERT INTO intel_interactions (
person_id, lead_id, channel, interaction_type, source_ref, summary, metadata_json
)
VALUES (
$1::uuid,
$2::uuid,
'perception_session'::intel_channel,
'sentinel_live_session',
$3,
$4,
$5::jsonb
)
ON CONFLICT DO NOTHING
RETURNING interaction_id::text
""",
person_id,
canonical_lead_id if _is_uuid(canonical_lead_id) else None,
session_id,
f"Sentinel live session ({session_mode})",
json.dumps(
{
"session_id": session_id,
"session_mode": session_mode,
"video_asset_id": video_asset_id,
}
),
)
return row["interaction_id"]
async def _persist_canonical_qd(
conn: asyncpg.Connection,
*,
person_id: str,
interaction_id: str,
session_id: str,
scene_label: str | None,
video_ts_ms: int,
result_qd_score: int,
baseline_score: int,
blend_shapes: dict[str, float],
session_mode: str,
) -> None:
normalized_score = max(0.0, min(1.0, result_qd_score / 100.0))
normalized_delta = max(-1.0, min(1.0, (result_qd_score - baseline_score) / 100.0))
event_type = "engagement_sample"
if result_qd_score - baseline_score >= 8:
event_type = "engagement_spike"
elif result_qd_score - baseline_score <= -8:
event_type = "negative_shift"
metadata = {
"interaction_id": interaction_id,
"scene_label": scene_label,
"video_ts_ms": video_ts_ms,
"qd_before": baseline_score,
"qd_after": result_qd_score,
"delta": result_qd_score - baseline_score,
"session_mode": session_mode,
"blend_shapes": blend_shapes,
}
await conn.execute(
"""
INSERT INTO intel_perception_events (
person_id, session_ref, event_type, engagement_score, media_ref, happened_at, metadata_json
)
VALUES ($1::uuid, $2, $3, $4, $5, NOW(), $6::jsonb)
""",
person_id,
session_id,
event_type,
normalized_score,
scene_label or f"video_ts:{video_ts_ms}",
json.dumps(metadata),
)
await conn.execute(
"""
INSERT INTO intel_qd_scores (
person_id, score_type, current_value, computed_at, evidence_refs_json, reasoning, metadata_json
)
VALUES (
$1::uuid,
'engagement_score',
$2,
NOW(),
$3::jsonb,
$4,
$5::jsonb
)
ON CONFLICT (person_id, score_type)
DO UPDATE SET
current_value = EXCLUDED.current_value,
computed_at = EXCLUDED.computed_at,
evidence_refs_json = EXCLUDED.evidence_refs_json,
reasoning = EXCLUDED.reasoning,
metadata_json = EXCLUDED.metadata_json
""",
person_id,
normalized_score,
json.dumps([session_id, interaction_id]),
f"Sentinel session updated engagement to {result_qd_score}/100",
json.dumps(metadata),
)
await conn.execute(
"""
INSERT INTO intel_qd_timeseries (
person_id, score_type, signal_source, timestamp, value, delta, evidence_ref, metadata_json
)
VALUES (
$1::uuid,
'engagement_score',
'sentinel_live_session',
NOW(),
$2,
$3,
$4,
$5::jsonb
)
""",
person_id,
normalized_score,
normalized_delta,
session_id,
json.dumps(metadata),
)
@router.websocket("/ws/notifications")
async def notifications_ws(ws: WebSocket) -> None:
await manager.connect(ws, "notifications")
try:
while True:
data = await ws.receive_text()
await ws.send_text(json.dumps({"type": "ack", "data": data}))
except WebSocketDisconnect:
manager.disconnect(ws, "notifications")
@router.websocket("/ws/perception")
async def perception_ws(ws: WebSocket) -> None:
await manager.connect(ws, "perception")
pool: asyncpg.Pool | None = getattr(ws.app.state, "db_pool", None)
if pool is None:
await ws.send_text(json.dumps({"type": "system", "data": {"error": "Database unavailable"}}))
await ws.close(code=1011)
return
try:
while True:
raw = await ws.receive_text()
try:
packet = json.loads(raw)
except json.JSONDecodeError:
continue
if packet.get("event") != "BIOMETRIC_PACKET":
continue
person_id = packet.get("person_id")
canonical_lead_id = packet.get("canonical_lead_id")
lead_id = packet.get("lead_id")
session_id = packet.get("session_id")
session_mode = packet.get("session_mode", "assigned")
video_ts_ms = int(packet.get("video_ts_ms", 0))
video_asset_id = packet.get("video_asset_id")
blend_shapes = packet.get("blend_shapes", {})
if (
not session_id
or not _is_uuid(session_id)
or session_mode not in {"assigned", "auto"}
or not isinstance(blend_shapes, dict)
or not blend_shapes
):
continue
async def _score(
sid: str = session_id,
lid: str | None = lead_id,
mode: str = session_mode,
bts: int = video_ts_ms,
bs: dict[str, float] = blend_shapes,
asset_id: str | None = video_asset_id,
) -> None:
try:
async with pool.acquire() as conn:
async with conn.transaction():
canonical = await _resolve_canonical_context(
conn,
person_id=person_id,
canonical_lead_id=canonical_lead_id,
legacy_lead_id=lid,
)
await _ensure_session_row(
conn,
session_id=sid,
session_mode=mode,
lead_id=(canonical["legacy_li_id"] if canonical and canonical.get("legacy_li_id") else lid),
video_asset_id=asset_id,
)
lead_row = None
legacy_lead_id = canonical["legacy_li_id"] if canonical and canonical.get("legacy_li_id") else lid
if _is_uuid(legacy_lead_id):
lead_row = await conn.fetchrow(
"""
SELECT quantum_dynamics_score, budget, interest, tags
FROM leads_intelligence
WHERE id = $1::uuid
""",
legacy_lead_id,
)
session_row = await conn.fetchrow(
"""
SELECT final_qd_score, auto_mode_evidence
FROM perception_sessions
WHERE id = $1::uuid
""",
sid,
)
scene_label = await _resolve_scene_label(conn, asset_id, bts)
crm = {
"budget": (canonical["budget_band"] if canonical and canonical.get("budget_band") else None)
or (lead_row["budget"] if lead_row else None)
or "unknown",
"interest": (lead_row["interest"] if lead_row else None) or "unknown",
"prior_interaction_count": await conn.fetchval(
"""
SELECT COUNT(*)
FROM omnichannel_logs
WHERE lead_id = $1::uuid
""",
legacy_lead_id,
)
if _is_uuid(legacy_lead_id)
else 0,
"tags": list((lead_row["tags"] if lead_row else None) or []),
"session_mode": mode,
}
baseline_score = (
lead_row["quantum_dynamics_score"]
if lead_row and lead_row["quantum_dynamics_score"] is not None
else (
int(round(float(canonical["engagement_score"]) * 100))
if canonical and canonical.get("engagement_score") is not None
else ((session_row["final_qd_score"] if session_row else None) or 50)
)
)
result = await score_qd(
lead_id=(canonical["person_id"] if canonical else None) or lid or sid,
batch_id=sid,
blend_shapes=bs,
video_ts_ms=bts,
scene_label=scene_label,
crm_context=crm,
current_qd_score=baseline_score,
)
evidence = dict((session_row["auto_mode_evidence"] if session_row else {}) or {})
evidence.update(
{
"last_scene_label": scene_label,
"last_video_ts_ms": bts,
"person_id": canonical["person_id"] if canonical else None,
}
)
await conn.execute(
"""
UPDATE perception_sessions
SET final_qd_score = $1,
auto_mode_evidence = $2::jsonb
WHERE id = $3::uuid
""",
result.qd_score,
evidence,
sid,
)
if canonical and _is_uuid(canonical["person_id"]):
interaction_id = await _ensure_canonical_interaction(
conn,
person_id=canonical["person_id"],
canonical_lead_id=canonical["lead_id"],
session_id=sid,
session_mode=mode,
video_asset_id=asset_id,
)
await _persist_canonical_qd(
conn,
person_id=canonical["person_id"],
interaction_id=interaction_id,
session_id=sid,
scene_label=scene_label,
video_ts_ms=bts,
result_qd_score=result.qd_score,
baseline_score=baseline_score,
blend_shapes=bs,
session_mode=mode,
)
if lead_row and _is_uuid(legacy_lead_id):
await conn.execute(
"""
INSERT INTO omnichannel_logs (event_type, lead_id, payload, video_timestamp_ms)
VALUES ('SENTIMENT_SPIKE', $1::uuid, $2::jsonb, $3)
""",
legacy_lead_id,
json.dumps(
{
"blend_shapes": bs,
"scene_label": scene_label,
"qd_before": baseline_score,
"qd_after": result.qd_score,
"confidence": result.confidence,
"session_id": sid,
"person_id": canonical["person_id"] if canonical else None,
}
),
bts,
)
await conn.execute(
"""
UPDATE leads_intelligence
SET quantum_dynamics_score = $1, updated_at = NOW()
WHERE id = $2::uuid
""",
result.qd_score,
legacy_lead_id,
)
event = {
"type": "QD_UPDATED",
"data": {
"person_id": canonical["person_id"] if canonical else None,
"lead_id": legacy_lead_id,
"session_id": sid,
"qd_score": result.qd_score,
"delta": result.qd_score - baseline_score,
"reasoning": result.reasoning,
"scene_label": scene_label,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
}
await manager.broadcast_all(event)
except Exception as exc:
logger.exception("QD scoring failed for session %s: %s", sid, exc)
asyncio.create_task(_score())
except WebSocketDisconnect:
manager.disconnect(ws, "perception")
class ConsentRequest(BaseModel):
lead_id: str
ip_address: str | None = None
user_agent: str | None = None
class TagLeadRequest(BaseModel):
lead_id: str
phone: str
budget: str | None = None
message_text: str
class SessionCompleteRequest(BaseModel):
session_id: str
session_mode: str
person_id: str | None = None
canonical_lead_id: str | None = None
lead_id: str | None = None
lead_name: str | None = None
final_qd_score: int | None = None
@router.post("/consent", status_code=201, summary="Record biometric consent")
async def record_consent(
body: ConsentRequest,
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, str]:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO consent_log (lead_id, ip_address, user_agent, action)
VALUES ($1::uuid, $2, $3, 'granted')
""",
body.lead_id,
body.ip_address,
body.user_agent,
)
return {"status": "consent_recorded"}
@router.post("/session/complete", summary="Close a perception session and finalize auto mode if needed")
async def complete_session(
body: SessionCompleteRequest,
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
if not _is_uuid(body.session_id):
raise HTTPException(status_code=400, detail="session_id must be a UUID.")
if body.session_mode not in {"assigned", "auto"}:
raise HTTPException(status_code=400, detail="session_mode must be assigned or auto.")
async with pool.acquire() as conn:
async with conn.transaction():
await _ensure_session_row(
conn,
session_id=body.session_id,
session_mode=body.session_mode,
lead_id=body.lead_id,
video_asset_id=None,
)
canonical = await _resolve_canonical_context(
conn,
person_id=body.person_id,
canonical_lead_id=body.canonical_lead_id,
legacy_lead_id=body.lead_id,
)
await conn.execute(
"""
UPDATE perception_sessions
SET ended_at = NOW(),
final_qd_score = COALESCE($1, final_qd_score)
WHERE id = $2::uuid
""",
body.final_qd_score,
body.session_id,
)
if canonical and body.final_qd_score is not None:
interaction_id = await _ensure_canonical_interaction(
conn,
person_id=canonical["person_id"],
canonical_lead_id=canonical["lead_id"],
session_id=body.session_id,
session_mode=body.session_mode,
video_asset_id=None,
)
await _persist_canonical_qd(
conn,
person_id=canonical["person_id"],
interaction_id=interaction_id,
session_id=body.session_id,
scene_label="session_complete",
video_ts_ms=0,
result_qd_score=body.final_qd_score,
baseline_score=body.final_qd_score,
blend_shapes={},
session_mode=body.session_mode,
)
if body.session_mode == "auto":
result = await auto_mode_match_session(conn, session_id=body.session_id)
event = {
"type": "LEAD_TAGGED",
"data": {
"lead_id": result.lead_id,
"tags": result.tags_applied,
"lead_name": "Auto-matched lead",
"session_id": body.session_id,
},
}
await manager.broadcast(event, "notifications")
return {
"status": "completed",
"session_id": body.session_id,
"lead_id": result.lead_id,
"match_action": result.action,
"match_confidence": result.confidence,
"tags_applied": result.tags_applied,
}
return {"status": "completed", "session_id": body.session_id}
@router.post("/tag-lead", summary="Apply NemoClaw lead tagging to a CRM lead")
async def tag_lead_route(
body: TagLeadRequest,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> dict[str, Any]:
result = await tag_lead(
lead_id=body.lead_id,
phone=body.phone,
budget=body.budget,
message_text=body.message_text,
)
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE leads_intelligence
SET tags = ARRAY(
SELECT DISTINCT unnest(
COALESCE(tags, ARRAY[]::text[]) || $1::text[]
)
)
WHERE id = $2::uuid
""",
result.tags_to_add,
body.lead_id,
)
await conn.execute(
"""
INSERT INTO omnichannel_logs (event_type, lead_id, payload)
VALUES ('LEAD_TAGGED', $1::uuid, $2::jsonb)
""",
body.lead_id,
json.dumps(
{
"tags_added": result.tags_to_add,
"tags_removed": result.tags_to_remove,
"actor_user_id": user.user_id,
}
),
)
event = {
"type": "LEAD_TAGGED",
"data": {
"lead_id": body.lead_id,
"tags": result.tags_to_add,
},
}
await manager.broadcast(event, "notifications")
return {
"lead_id": body.lead_id,
"tags_to_add": result.tags_to_add,
"tags_to_remove": result.tags_to_remove,
}
@router.get("/qd-score/{lead_id}", summary="Current Quantum Dynamics score for a lead")
async def get_qd_score(
lead_id: str,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> dict[str, Any]:
del user
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT quantum_dynamics_score, tags FROM leads_intelligence WHERE id = $1::uuid",
lead_id,
)
if not row:
raise HTTPException(status_code=404, detail="Lead not found.")
return {
"lead_id": lead_id,
"qd_score": row["quantum_dynamics_score"],
"tags": list(row["tags"] or []),
}
async def broadcast_sentinel_event(payload: dict[str, Any]) -> None:
await manager.broadcast(payload, "notifications")

View File

@@ -0,0 +1,190 @@
"""
backend/routers/vault.py — Velocity Vault (Trackable Link) Router
Endpoints:
POST /api/vault/generate-link → Generate a trackable URL for a shared asset
GET /vault/{tracking_hash} → Public link accessed by the prospect;
logs the open, fires WS_ASSET_OPENED
SRS Reference: Section 3C — Velocity Link Generation
"""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timezone
from typing import Optional
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, JSONResponse
from pydantic import BaseModel, UUID4
from backend.auth.dependencies import UserPrincipal, require_role
from backend.db.pool import get_pool
router = APIRouter()
# ── Pydantic models ───────────────────────────────────────────────────────────
class GenerateLinkRequest(BaseModel):
lead_id: str
asset_name: str
asset_type: str # 'pdf' | 'image' | 'video'
storage_path: str # relative to /opt/dlami/nvme/assets/
class GenerateLinkResponse(BaseModel):
tracking_hash: str
vault_url: str
asset_id: str
# ── Helper: WebSocket broadcast ───────────────────────────────────────────────
async def _broadcast_vault_opened(
request: Request,
lead_id: str,
lead_name: str,
asset_name: str,
tracking_hash: str,
ip: Optional[str],
) -> None:
"""Fires WS_ASSET_OPENED to all broker WebSocket clients watching this lead."""
broadcast = getattr(request.app.state, "broadcast_sentinel_event", None)
if broadcast:
await broadcast({
"type": "WS_ASSET_OPENED",
"data": {
"lead_id": lead_id,
"lead_name": lead_name,
"asset_name": asset_name,
"tracking_hash": tracking_hash,
"opened_at": datetime.now(timezone.utc).isoformat(),
"ip": ip,
},
})
# ── POST /api/vault/generate-link ─────────────────────────────────────────────
@router.post(
"/generate-link",
response_model=GenerateLinkResponse,
status_code=status.HTTP_201_CREATED,
summary="Generate a trackable Velocity Link for a document share",
)
async def generate_link(
body: GenerateLinkRequest,
request: Request,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> GenerateLinkResponse:
"""
Creates a cryptographically unique URL for every document share instance.
When the prospect opens the URL, FastAPI logs the event and fires a
real-time WebSocket notification to the broker's Active Notification Center.
"""
tracking_hash = secrets.token_hex(32) # 64 character hex string
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO velocity_vault_assets
(asset_name, asset_type, storage_path, tracking_hash, lead_id, created_by)
VALUES ($1, $2, $3, $4, $5::uuid, $6::uuid)
RETURNING id::text
""",
body.asset_name,
body.asset_type,
body.storage_path,
tracking_hash,
body.lead_id,
user.user_id,
)
base_url = os.getenv("VELOCITY_API_BASE_URL", "http://localhost:8000")
vault_url = f"{base_url}/vault/{tracking_hash}"
return GenerateLinkResponse(
tracking_hash=tracking_hash,
vault_url=vault_url,
asset_id=row["id"],
)
# ── GET /vault/{tracking_hash} ────────────────────────────────────────────────
@router.get(
"/{tracking_hash}",
summary="Public Velocity Link endpoint — accessed by the prospect",
include_in_schema=False,
)
async def open_vault_link(
tracking_hash: str,
request: Request,
pool: asyncpg.Pool = Depends(get_pool),
) -> RedirectResponse:
"""
No auth required — this URL is shared with the prospect externally.
On access:
1. Appends NOW() to velocity_vault_assets.opened_at
2. Writes a WS_ASSET_OPENED entry to omnichannel_logs
3. Broadcasts the event to all connected broker WebSocket clients
4. Redirects the prospect to the actual asset file
"""
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE velocity_vault_assets
SET opened_at = array_append(opened_at, NOW())
WHERE tracking_hash = $1
RETURNING id::text, lead_id::text, asset_name, storage_path
""",
tracking_hash,
)
if row is None:
raise HTTPException(status_code=404, detail="Link not found or expired.")
lead_id = row["lead_id"]
asset_name = row["asset_name"]
# Fetch lead name for the notification body
lead_row = await conn.fetchrow(
"SELECT name FROM leads_intelligence WHERE id = $1::uuid",
lead_id,
)
lead_name = lead_row["name"] if lead_row else "Unknown Lead"
# Write to omnichannel_logs
ip = request.client.host if request.client else None
await conn.execute(
"""
INSERT INTO omnichannel_logs (event_type, lead_id, payload)
VALUES ('WS_ASSET_OPENED', $1::uuid, $2::jsonb)
""",
lead_id,
{
"tracking_hash": tracking_hash,
"asset_name": asset_name,
"ip": ip,
"user_agent": request.headers.get("user-agent", ""),
},
)
# Fire real-time WebSocket broadcast to all brokers
await _broadcast_vault_opened(
request=request,
lead_id=lead_id,
lead_name=lead_name,
asset_name=asset_name,
tracking_hash=tracking_hash,
ip=ip,
)
# Redirect to the static asset file served by FastAPI
asset_url = f"/assets/{row['storage_path']}"
return RedirectResponse(url=asset_url, status_code=302)

View File

@@ -0,0 +1,109 @@
"""
backend/routers/videos.py - Marketing video catalog for Sentinel live sessions.
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
from fastapi import APIRouter
router = APIRouter()
VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
DEFAULT_COLORS = ["#3b82f6", "#06b6d4", "#8b5cf6", "#10b981", "#f59e0b", "#ef4444"]
def _slugify(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
def _humanize(value: str) -> str:
base = re.sub(r"[-_]+", " ", value).strip()
return re.sub(r"\s+", " ", base).title()
def _derive_unit(name: str) -> str:
parts = re.findall(r"[A-Za-z0-9]+", name)
if not parts:
return "N/A"
if len(parts) >= 2:
return f"{parts[0]}-{parts[1]}"
return parts[0]
def _build_record(
*,
file_path: Path,
public_root: str,
metadata: dict[str, Any] | None,
color_index: int,
) -> dict[str, Any]:
rel_path = file_path.relative_to(public_root).as_posix()
name = metadata.get("title") if metadata else None
title = name or _humanize(file_path.stem.replace("video", "").replace("Video", ""))
property_name = metadata.get("property_name") if metadata else None
property_name = property_name or _humanize(file_path.parent.name if file_path.parent.name != "videos" else file_path.stem)
slug = metadata.get("id") if metadata else None
slug = slug or _slugify(file_path.stem)
return {
"id": slug,
"title": title,
"property_name": property_name,
"unit_number": (metadata or {}).get("unit_number") or _derive_unit(file_path.stem),
"type": (metadata or {}).get("type") or "Property Walkthrough",
"duration_seconds": int((metadata or {}).get("duration_seconds") or 0),
"video_url": f"/assets/{rel_path}",
"thumbnail_color": (metadata or {}).get("thumbnail_color") or DEFAULT_COLORS[color_index % len(DEFAULT_COLORS)],
}
@router.get("/marketing", summary="List marketing videos available for Sentinel live sessions")
async def list_marketing_videos() -> dict[str, Any]:
asset_root = Path(os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets"))
video_root = Path(os.getenv("VELOCITY_VIDEO_DIR", str(asset_root / "videos")))
catalog_path = video_root / "catalog.json"
catalog_entries: list[dict[str, Any]] = []
if catalog_path.exists():
catalog_entries = json.loads(catalog_path.read_text(encoding="utf-8"))
records: list[dict[str, Any]] = []
indexed: set[Path] = set()
for idx, entry in enumerate(catalog_entries):
file_path = video_root / entry["storage_path"]
if not file_path.exists():
continue
indexed.add(file_path.resolve())
records.append(
_build_record(
file_path=file_path,
public_root=str(asset_root),
metadata=entry,
color_index=idx,
)
)
unindexed_files = sorted(
[
path
for path in video_root.rglob("*")
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS and path.resolve() not in indexed
]
)
for idx, file_path in enumerate(unindexed_files, start=len(records)):
records.append(
_build_record(
file_path=file_path,
public_root=str(asset_root),
metadata=None,
color_index=idx,
)
)
return {"count": len(records), "videos": records}

View File

@@ -0,0 +1 @@
"""backend.services package"""

View File

@@ -0,0 +1,520 @@
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import uuid
from datetime import datetime, timedelta
from enum import Enum
from typing import Literal
import httpx
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class Platform(str, Enum):
META = "meta"
GOOGLE = "google"
class CampaignStatus(str, Enum):
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
ARCHIVED = "archived"
class AdInsight(BaseModel):
campaign_id: str
campaign_name: str
platform: Platform
date: str
impressions: int = 0
clicks: int = 0
conversions: int = 0
spend: float = 0.0
ctr: float = 0.0
cpc: float = 0.0
cpm: float = 0.0
roas: float = 0.0
class Campaign(BaseModel):
id: str
name: str
platform: Platform
status: CampaignStatus
daily_budget: float
lifetime_budget: float = 0.0
spent: float = 0.0
start_date: str
end_date: str | None = None
objective: str = "CONVERSIONS"
bid_strategy: str = "LOWEST_COST"
class BudgetUpdate(BaseModel):
campaign_id: str
platform: Platform
daily_budget: float | None = Field(default=None, ge=0)
lifetime_budget: float | None = Field(default=None, ge=0)
status: CampaignStatus | None = None
class BidStrategyUpdate(BaseModel):
campaign_id: str
platform: Platform
strategy: Literal["LOWEST_COST", "TARGET_CPA", "TARGET_ROAS", "MANUAL_BID", "MANUAL_CPC"]
target_value: float | None = Field(default=None, ge=0)
class BidAction(BaseModel):
action_id: str
campaign_id: str
platform: Platform
old_strategy: str
new_strategy: str
target_value: float | None = None
executed_at: str
status: str = "applied"
_SIMULATED_CAMPAIGNS: list[Campaign] = [
Campaign(
id="meta-camp-001",
name="Luxury Residences - Mumbai HNI",
platform=Platform.META,
status=CampaignStatus.ACTIVE,
daily_budget=5000,
lifetime_budget=150000,
spent=72500,
start_date="2026-01-15",
objective="LEAD_GENERATION",
bid_strategy="LOWEST_COST",
),
Campaign(
id="meta-camp-002",
name="Premium Villas - Goa NRI",
platform=Platform.META,
status=CampaignStatus.ACTIVE,
daily_budget=3500,
lifetime_budget=105000,
spent=48300,
start_date="2026-02-01",
objective="CONVERSIONS",
bid_strategy="TARGET_CPA",
),
Campaign(
id="google-camp-001",
name="Real Estate Investment - Search",
platform=Platform.GOOGLE,
status=CampaignStatus.ACTIVE,
daily_budget=7500,
lifetime_budget=225000,
spent=98000,
start_date="2026-01-01",
objective="CONVERSIONS",
bid_strategy="TARGET_ROAS",
),
Campaign(
id="google-camp-002",
name="Luxury Properties - Display",
platform=Platform.GOOGLE,
status=CampaignStatus.ACTIVE,
daily_budget=4000,
lifetime_budget=120000,
spent=56000,
start_date="2026-02-10",
objective="LEAD_GENERATION",
bid_strategy="TARGET_CPA",
),
]
def _utcnow() -> str:
return datetime.utcnow().isoformat()
def _google_live_ready() -> bool:
required = (
os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
os.getenv("GOOGLE_ADS_CLIENT_ID", ""),
os.getenv("GOOGLE_ADS_CLIENT_SECRET", ""),
os.getenv("GOOGLE_ADS_REFRESH_TOKEN", ""),
os.getenv("GOOGLE_ADS_CUSTOMER_ID", ""),
)
return all(bool(item and not item.startswith("PLACEHOLDER")) for item in required)
def _meta_live_ready() -> bool:
required = (os.getenv("META_ACCESS_TOKEN", ""), os.getenv("META_AD_ACCOUNT_ID", ""))
return all(bool(item and not item.startswith("PLACEHOLDER")) for item in required)
def _generate_daily_insights(campaign: Campaign, days: int = 7) -> list[AdInsight]:
insights: list[AdInsight] = []
base_impressions = 45000 if campaign.platform == Platform.META else 28000
for idx in range(days):
date = (datetime.utcnow() - timedelta(days=idx)).strftime("%Y-%m-%d")
seed = int(hashlib.md5(f"{campaign.id}-{date}".encode()).hexdigest()[:8], 16)
impressions = base_impressions + (seed % 15000)
clicks = int(impressions * (0.02 + (seed % 30) / 1000))
conversions = int(clicks * (0.005 + (seed % 20) / 1000))
spend = round(campaign.daily_budget * (0.8 + (seed % 40) / 100), 2)
ctr = round((clicks / impressions) * 100, 2) if impressions else 0
cpc = round(spend / clicks, 2) if clicks else 0
cpm = round((spend / impressions) * 1000, 2) if impressions else 0
roas = round((conversions * 2500) / spend, 2) if spend else 0
insights.append(
AdInsight(
campaign_id=campaign.id,
campaign_name=campaign.name,
platform=campaign.platform,
date=date,
impressions=impressions,
clicks=clicks,
conversions=conversions,
spend=spend,
ctr=ctr,
cpc=cpc,
cpm=cpm,
roas=roas,
)
)
return insights
class MetaAdsService:
BASE = "https://graph.facebook.com/v21.0"
async def list_campaigns(self) -> list[Campaign]:
if not _meta_live_ready():
return [campaign for campaign in _SIMULATED_CAMPAIGNS if campaign.platform == Platform.META]
access_token = os.getenv("META_ACCESS_TOKEN", "")
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.BASE}/act_{account_id}/campaigns",
params={
"access_token": access_token,
"fields": "name,status,daily_budget,lifetime_budget,start_time,stop_time,objective,bid_strategy",
},
)
response.raise_for_status()
rows = response.json().get("data", [])
return [
Campaign(
id=row["id"],
name=row["name"],
platform=Platform.META,
status=CampaignStatus(row.get("status", "ACTIVE").lower()),
daily_budget=float(row.get("daily_budget", 0)) / 100,
lifetime_budget=float(row.get("lifetime_budget", 0)) / 100,
spent=0.0,
start_date=row.get("start_time", ""),
end_date=row.get("stop_time"),
objective=row.get("objective", ""),
bid_strategy=row.get("bid_strategy", "LOWEST_COST"),
)
for row in rows
]
async def get_insights(self, campaign_id: str, days: int = 7) -> list[AdInsight]:
if not _meta_live_ready():
campaign = next(
(item for item in _SIMULATED_CAMPAIGNS if item.id == campaign_id and item.platform == Platform.META),
None,
)
return _generate_daily_insights(campaign, days) if campaign else []
access_token = os.getenv("META_ACCESS_TOKEN", "")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.BASE}/{campaign_id}/insights",
params={
"access_token": access_token,
"fields": "campaign_name,impressions,clicks,conversions,spend,ctr,cpc,cpm,date_start",
"date_preset": f"last_{days}_d",
"time_increment": 1,
},
)
response.raise_for_status()
rows = response.json().get("data", [])
return [
AdInsight(
campaign_id=campaign_id,
campaign_name=row.get("campaign_name", ""),
platform=Platform.META,
date=row.get("date_start", ""),
impressions=int(row.get("impressions", 0)),
clicks=int(row.get("clicks", 0)),
conversions=int(row.get("conversions", 0)),
spend=float(row.get("spend", 0)),
ctr=float(row.get("ctr", 0)),
cpc=float(row.get("cpc", 0)),
cpm=float(row.get("cpm", 0)),
)
for row in rows
]
async def update_budget(self, update: BudgetUpdate) -> dict:
if not _meta_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == update.campaign_id), None)
if campaign:
if update.daily_budget is not None:
campaign.daily_budget = update.daily_budget
if update.lifetime_budget is not None:
campaign.lifetime_budget = update.lifetime_budget
if update.status is not None:
campaign.status = update.status
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "simulated", "platform": "meta"}
access_token = os.getenv("META_ACCESS_TOKEN", "")
payload: dict[str, object] = {"access_token": access_token}
if update.daily_budget is not None:
payload["daily_budget"] = int(update.daily_budget * 100)
if update.lifetime_budget is not None:
payload["lifetime_budget"] = int(update.lifetime_budget * 100)
if update.status is not None:
payload["status"] = update.status.value.upper()
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(f"{self.BASE}/{update.campaign_id}", data=payload)
response.raise_for_status()
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "live", "platform": "meta"}
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
if not _meta_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == bid.campaign_id), None)
previous = campaign.bid_strategy if campaign else "UNKNOWN"
if campaign:
campaign.bid_strategy = bid.strategy
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.META,
old_strategy=previous,
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
)
access_token = os.getenv("META_ACCESS_TOKEN", "")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.BASE}/{bid.campaign_id}",
data={"bid_strategy": bid.strategy, "access_token": access_token},
)
response.raise_for_status()
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.META,
old_strategy="PREVIOUS",
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
)
class GoogleAdsService:
BASE = "https://googleads.googleapis.com/v18"
async def _get_access_token(self) -> str:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": os.getenv("GOOGLE_ADS_CLIENT_ID", ""),
"client_secret": os.getenv("GOOGLE_ADS_CLIENT_SECRET", ""),
"refresh_token": os.getenv("GOOGLE_ADS_REFRESH_TOKEN", ""),
"grant_type": "refresh_token",
},
)
response.raise_for_status()
return response.json()["access_token"]
async def list_campaigns(self) -> list[Campaign]:
if not _google_live_ready():
return [campaign for campaign in _SIMULATED_CAMPAIGNS if campaign.platform == Platform.GOOGLE]
token = await self._get_access_token()
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID", "")
query = """
SELECT campaign.id, campaign.name, campaign.status,
campaign_budget.amount_micros, campaign.start_date, campaign.end_date,
campaign.advertising_channel_type, campaign.bidding_strategy_type
FROM campaign
ORDER BY campaign.id
"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.BASE}/customers/{customer_id}/googleAds:searchStream",
headers={
"Authorization": f"Bearer {token}",
"developer-token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
},
json={"query": query},
)
response.raise_for_status()
campaigns: list[Campaign] = []
for batch in response.json():
for row in batch.get("results", []):
campaign = row.get("campaign", {})
budget = row.get("campaignBudget", {})
status = campaign.get("status", "ENABLED").lower().replace("enabled", "active")
campaigns.append(
Campaign(
id=str(campaign.get("id", "")),
name=campaign.get("name", ""),
platform=Platform.GOOGLE,
status=CampaignStatus(status),
daily_budget=int(budget.get("amountMicros", 0)) / 1_000_000,
lifetime_budget=0.0,
spent=0.0,
start_date=campaign.get("startDate", ""),
end_date=campaign.get("endDate"),
objective=campaign.get("advertisingChannelType", "SEARCH"),
bid_strategy=campaign.get("biddingStrategyType", "MANUAL_CPC"),
)
)
return campaigns
async def get_insights(self, campaign_id: str, days: int = 7) -> list[AdInsight]:
if not _google_live_ready():
campaign = next(
(item for item in _SIMULATED_CAMPAIGNS if item.id == campaign_id and item.platform == Platform.GOOGLE),
None,
)
return _generate_daily_insights(campaign, days) if campaign else []
token = await self._get_access_token()
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID", "")
query = f"""
SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks,
metrics.conversions, metrics.cost_micros, metrics.ctr,
metrics.average_cpc, metrics.average_cpm, segments.date
FROM campaign
WHERE campaign.id = {campaign_id}
AND segments.date DURING LAST_{days}_DAYS
ORDER BY segments.date DESC
"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.BASE}/customers/{customer_id}/googleAds:searchStream",
headers={
"Authorization": f"Bearer {token}",
"developer-token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
},
json={"query": query},
)
response.raise_for_status()
insights: list[AdInsight] = []
for batch in response.json():
for row in batch.get("results", []):
metrics = row.get("metrics", {})
insights.append(
AdInsight(
campaign_id=campaign_id,
campaign_name=row.get("campaign", {}).get("name", ""),
platform=Platform.GOOGLE,
date=row.get("segments", {}).get("date", ""),
impressions=int(metrics.get("impressions", 0)),
clicks=int(metrics.get("clicks", 0)),
conversions=int(metrics.get("conversions", 0)),
spend=int(metrics.get("costMicros", 0)) / 1_000_000,
ctr=float(metrics.get("ctr", 0)),
cpc=int(metrics.get("averageCpc", 0)) / 1_000_000,
cpm=int(metrics.get("averageCpm", 0)) / 1_000_000,
)
)
return insights
async def update_budget(self, update: BudgetUpdate) -> dict:
if not _google_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == update.campaign_id), None)
if campaign:
if update.daily_budget is not None:
campaign.daily_budget = update.daily_budget
if update.status is not None:
campaign.status = update.status
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "simulated", "platform": "google"}
return {
"status": "ok",
"campaign_id": update.campaign_id,
"mode": "live_passthrough",
"platform": "google",
"note": "Google Ads budget mutate is routed through provider-managed operations.",
}
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
if not _google_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == bid.campaign_id), None)
previous = campaign.bid_strategy if campaign else "UNKNOWN"
if campaign:
campaign.bid_strategy = bid.strategy
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.GOOGLE,
old_strategy=previous,
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
)
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.GOOGLE,
old_strategy="PREVIOUS",
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
status="applied",
)
class AdNetworkService:
def __init__(self) -> None:
self.meta = MetaAdsService()
self.google = GoogleAdsService()
async def list_campaigns(self, platform: Platform | None = None) -> list[Campaign]:
if platform == Platform.META:
return await self.meta.list_campaigns()
if platform == Platform.GOOGLE:
return await self.google.list_campaigns()
meta_campaigns, google_campaigns = await asyncio.gather(
self.meta.list_campaigns(),
self.google.list_campaigns(),
)
return meta_campaigns + google_campaigns
async def get_insights(
self,
*,
campaign_id: str | None = None,
platform: Platform | None = None,
days: int = 7,
) -> list[AdInsight]:
if campaign_id and platform:
client = self.meta if platform == Platform.META else self.google
return await client.get_insights(campaign_id, days)
campaigns = await self.list_campaigns(platform=platform)
tasks = [
(self.meta if campaign.platform == Platform.META else self.google).get_insights(campaign.id, days)
for campaign in campaigns
]
results = await asyncio.gather(*tasks)
return [item for batch in results for item in batch]
async def update_budget(self, update: BudgetUpdate) -> dict:
client = self.meta if update.platform == Platform.META else self.google
return await client.update_budget(update)
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
client = self.meta if bid.platform == Platform.META else self.google
return await client.update_bid_strategy(bid)
ad_network_service = AdNetworkService()

View File

@@ -0,0 +1,217 @@
"""
backend/services/auto_mode_matcher.py - Post-session lead matching for Sentinel auto mode.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import asyncpg
@dataclass
class AutoModeMatchResult:
action: str
lead_id: str
confidence: float
rationale: str
tags_applied: list[str]
def _normalise_plate(plate: str | None) -> str | None:
if not plate:
return None
cleaned = "".join(ch for ch in plate.upper() if ch.isalnum())
return cleaned or None
async def _find_match_by_plate(
conn: asyncpg.Connection,
normalized_plate: str,
) -> tuple[str, float, str] | None:
row = await conn.fetchrow(
"""
SELECT linked_lead_id::text AS lead_id
FROM cctv_events
WHERE regexp_replace(COALESCE(license_plate, ''), '[^A-Za-z0-9]', '', 'g') = $1
AND linked_lead_id IS NOT NULL
ORDER BY captured_at DESC
LIMIT 1
""",
normalized_plate,
)
if row:
return row["lead_id"], 0.96, "matched_existing_plate"
return None
async def _find_match_by_tags(
conn: asyncpg.Connection,
tags: list[str],
) -> tuple[str, float, str] | None:
if not tags:
return None
row = await conn.fetchrow(
"""
SELECT id::text AS lead_id,
COALESCE(cardinality(tags & $1::text[]), 0) AS overlap,
last_active
FROM leads_intelligence
WHERE tags && $1::text[]
AND status IN ('engaged', 'qualified', 'hot')
ORDER BY overlap DESC, last_active DESC
LIMIT 1
""",
tags,
)
if row and row["overlap"] > 0:
confidence = min(0.65 + (0.1 * int(row["overlap"])), 0.85)
return row["lead_id"], confidence, "matched_tag_overlap"
return None
async def _create_auto_lead(
conn: asyncpg.Connection,
*,
wealth_indicator: str,
tags: list[str],
session_id: str,
) -> str:
name = f"Auto Visitor {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
qualification = "whale" if wealth_indicator == "HNI" else "potential"
row = await conn.fetchrow(
"""
INSERT INTO leads_intelligence
(name, source, status, qualification, quantum_dynamics_score, tags, last_message)
VALUES
($1, 'walkin', 'new', $2::qualification_enum, 50, $3::text[], $4)
RETURNING id::text
""",
name,
qualification,
tags,
f"Auto-created from Sentinel auto mode session {session_id}",
)
return row["id"]
async def auto_mode_match_session(
conn: asyncpg.Connection,
*,
session_id: str,
) -> AutoModeMatchResult:
session = await conn.fetchrow(
"""
SELECT id::text, lead_id::text, session_mode, auto_mode_evidence, final_qd_score
FROM perception_sessions
WHERE id = $1::uuid
""",
session_id,
)
if not session:
raise ValueError(f"Session {session_id} not found.")
if session["session_mode"] != "auto":
raise ValueError("auto_mode_match_session can only be used for auto sessions.")
if session["lead_id"]:
return AutoModeMatchResult(
action="already_linked",
lead_id=session["lead_id"],
confidence=1.0,
rationale="session_already_has_lead",
tags_applied=[],
)
evidence: dict[str, Any] = dict(session["auto_mode_evidence"] or {})
normalized_plate = _normalise_plate(evidence.get("license_plate"))
inferred_tags = list(dict.fromkeys((evidence.get("tags") or []) + (evidence.get("nemoclaw_tags") or [])))
wealth_indicator = str(evidence.get("wealth_indicator") or "unknown")
match: tuple[str, float, str] | None = None
if normalized_plate:
match = await _find_match_by_plate(conn, normalized_plate)
if not match:
match = await _find_match_by_tags(conn, inferred_tags)
action = "linked_existing" if match else "created_new"
if match:
lead_id, confidence, rationale = match
else:
lead_id = await _create_auto_lead(
conn,
wealth_indicator=wealth_indicator,
tags=inferred_tags,
session_id=session_id,
)
confidence = 0.55
rationale = "created_new_from_auto_mode"
await conn.execute(
"""
UPDATE perception_sessions
SET lead_id = $1::uuid,
auto_mode_matched_at = NOW(),
auto_mode_evidence = auto_mode_evidence || $2::jsonb
WHERE id = $3::uuid
""",
lead_id,
{
"match_action": action,
"match_confidence": confidence,
"match_rationale": rationale,
},
session_id,
)
await conn.execute(
"""
UPDATE cctv_events
SET linked_lead_id = $1::uuid
WHERE linked_session_id = $2::uuid
AND linked_lead_id IS NULL
""",
lead_id,
session_id,
)
if inferred_tags:
await conn.execute(
"""
UPDATE leads_intelligence
SET tags = ARRAY(
SELECT DISTINCT unnest(COALESCE(tags, ARRAY[]::text[]) || $1::text[])
),
quantum_dynamics_score = COALESCE($2, quantum_dynamics_score),
updated_at = NOW()
WHERE id = $3::uuid
""",
inferred_tags,
session["final_qd_score"],
lead_id,
)
await conn.execute(
"""
INSERT INTO omnichannel_logs (event_type, lead_id, payload)
VALUES ('LEAD_TAGGED', $1::uuid, $2::jsonb)
""",
lead_id,
{
"source": "auto_mode_matcher",
"session_id": session_id,
"tags_added": inferred_tags,
"match_action": action,
"match_confidence": confidence,
"match_rationale": rationale,
},
)
return AutoModeMatchResult(
action=action,
lead_id=lead_id,
confidence=confidence,
rationale=rationale,
tags_applied=inferred_tags,
)

View File

@@ -0,0 +1,3 @@
"""
backend/services/client_graph/__init__.py
"""

View File

@@ -0,0 +1,428 @@
"""
backend/services/client_graph/aggregation_service.py
Client 360 Aggregation Service
Produces Client360Snapshot read models by joining across
crm_people, crm_leads, crm_opportunities, intel_interactions,
intel_reminders, intel_qd_scores, crm_property_interests.
This is a derived read model — never the sole source of truth.
As specified in Doc 07 (Client360Snapshot contract) and Doc 08 (Adapter Spec).
"""
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("velocity.client_graph.aggregation")
def _json_string_list(value: Any) -> list[str]:
"""Normalize canonical array fields that may arrive as jsonb, text[], or JSON text."""
if value is None:
return []
if isinstance(value, list | tuple):
return [str(item) for item in value if item is not None]
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return []
try:
parsed = json.loads(normalized)
except json.JSONDecodeError:
return [normalized]
if isinstance(parsed, list):
return [str(item) for item in parsed if item is not None]
if parsed is None:
return []
return [str(parsed)]
return [str(value)]
def _serialize_person(row: Any) -> dict[str, Any]:
return {
"person_id": str(row["person_id"]),
"full_name": row["full_name"],
"primary_email": row["primary_email"],
"primary_phone": row["primary_phone"],
"buyer_type": row["buyer_type"],
"persona_labels": _json_string_list(row["persona_labels"]),
"source_confidence": float(row["source_confidence"] or 0.0),
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
def _serialize_lead(row: Any) -> dict[str, Any]:
return {
"lead_id": str(row["lead_id"]),
"status": row["status"],
"budget_band": row["budget_band"],
"urgency": row["urgency"],
"financing_posture": row["financing_posture"],
"timeline_to_decision": row["timeline_to_decision"],
"objections": _json_string_list(row["objections"]),
"motivations": _json_string_list(row["motivations"]),
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
def _serialize_opportunity(row: Any) -> dict[str, Any]:
return {
"opportunity_id": str(row["opportunity_id"]),
"stage": row["stage"],
"value": float(row["value"]) if row["value"] else None,
"probability": row["probability"],
"expected_close_date": row["expected_close_date"].isoformat() if row["expected_close_date"] else None,
"next_action": row["next_action"],
"project_id": str(row["project_id"]) if row["project_id"] else None,
"unit_id": str(row["unit_id"]) if row["unit_id"] else None,
}
def _serialize_interaction(row: Any) -> dict[str, Any]:
return {
"interaction_id": str(row["interaction_id"]),
"channel": row["channel"],
"interaction_type": row["interaction_type"],
"happened_at": row["happened_at"].isoformat() if row["happened_at"] else None,
"summary": row["summary"],
}
def _serialize_reminder(row: Any) -> dict[str, Any]:
return {
"reminder_id": str(row["reminder_id"]),
"reminder_type": row["reminder_type"],
"title": row["title"],
"due_at": row["due_at"].isoformat() if row["due_at"] else None,
"status": row["status"],
"priority": row["priority"],
}
def _serialize_qd_score(row: Any) -> dict[str, Any]:
return {
"score_type": row["score_type"],
"current_value": float(row["current_value"]),
"computed_at": row["computed_at"].isoformat() if row["computed_at"] else None,
"reasoning": row["reasoning"],
}
def _serialize_property_interest(row: Any) -> dict[str, Any]:
return {
"interest_id": str(row["interest_id"]),
"project_name": row["project_name"],
"unit_preference": row["unit_preference"],
"configuration": row["configuration"],
"budget_min": float(row["budget_min"]) if row["budget_min"] else None,
"budget_max": float(row["budget_max"]) if row["budget_max"] else None,
"priority": row["priority"],
}
async def get_client_360(conn: Any, tenant_id: str, person_id: str) -> dict[str, Any] | None:
"""
Aggregate a full Client360Snapshot for a given person_id.
This is a read model — derived from canonical tables, never primary truth.
"""
# 1. Core identity
person_row = await conn.fetchrow(
"""
SELECT person_id, full_name, primary_email, primary_phone,
buyer_type, persona_labels, source_confidence, created_at
FROM crm_people
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
person_id,
tenant_id,
)
if not person_row:
return None
identity = _serialize_person(person_row)
# 2. Account links
account_rows = await conn.fetch(
"""
SELECT ca.account_id, ca.account_name, ca.account_type, ca.industry
FROM crm_accounts ca
INNER JOIN crm_leads cl ON cl.account_id = ca.account_id
WHERE cl.person_id = $1::uuid
AND cl.tenant_id = $2
AND ca.tenant_id = $2
LIMIT 5
""",
person_id,
tenant_id,
)
account_links = [
{
"account_id": str(r["account_id"]),
"account_name": r["account_name"],
"account_type": r["account_type"],
"industry": r["industry"],
}
for r in account_rows
]
# 3. Active lead
lead_row = await conn.fetchrow(
"""
SELECT lead_id, status, budget_band, urgency, financing_posture,
timeline_to_decision, objections, motivations, created_at
FROM crm_leads
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY created_at DESC
LIMIT 1
""",
person_id,
tenant_id,
)
lead = _serialize_lead(lead_row) if lead_row else None
# 4. Active opportunities (top 5)
opp_rows = await conn.fetch(
"""
SELECT co.opportunity_id, co.stage, co.value, co.probability,
co.expected_close_date, co.next_action, co.project_id, co.unit_id
FROM crm_opportunities co
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
WHERE cl.person_id = $1::uuid
AND cl.tenant_id = $2
AND co.tenant_id = $2
ORDER BY co.updated_at DESC
LIMIT 5
""",
person_id,
tenant_id,
)
active_opportunities = [_serialize_opportunity(r) for r in opp_rows]
# 5. Recent interactions (last 10)
interaction_rows = await conn.fetch(
"""
SELECT interaction_id, channel, interaction_type, happened_at, summary
FROM intel_interactions
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY happened_at DESC
LIMIT 10
""",
person_id,
tenant_id,
)
recent_interactions = [_serialize_interaction(r) for r in interaction_rows]
# 6. Property interests
interest_rows = await conn.fetch(
"""
SELECT interest_id, project_name, unit_preference, configuration,
budget_min, budget_max, priority
FROM crm_property_interests
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY priority ASC, interest_id ASC
LIMIT 10
""",
person_id,
tenant_id,
)
property_interests = [_serialize_property_interest(r) for r in interest_rows]
# 7. Pending tasks / reminders
task_rows = await conn.fetch(
"""
SELECT reminder_id, reminder_type, title, due_at, status, priority
FROM intel_reminders
WHERE person_id = $1::uuid
AND tenant_id = $2
AND status IN ('pending', 'snoozed')
ORDER BY due_at ASC NULLS LAST
LIMIT 10
""",
person_id,
tenant_id,
)
tasks = [_serialize_reminder(r) for r in task_rows]
# 8. QD overview (all score types)
qd_rows = await conn.fetch(
"""
SELECT score_type, current_value, computed_at, reasoning
FROM intel_qd_scores
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
person_id,
tenant_id,
)
qd_overview = {r["score_type"]: _serialize_qd_score(r) for r in qd_rows}
# 9. Risk flags — heuristic derivation
risk_flags: list[str] = []
if lead and lead.get("urgency") in ("high", "critical") and not active_opportunities:
risk_flags.append("high_urgency_without_active_opportunity")
if not recent_interactions:
risk_flags.append("no_recent_interactions")
if qd_overview.get("intent_score", {}).get("current_value", 1.0) < 0.3:
risk_flags.append("low_intent_score")
if not property_interests:
risk_flags.append("no_property_interests_recorded")
# 10. Recommended next actions — simple heuristic
recommended_next_actions: list[str] = []
if tasks:
overdue = [t for t in tasks if t.get("status") == "pending"]
if overdue:
recommended_next_actions.append(f"Complete pending task: {overdue[0]['title']}")
if lead and lead.get("urgency") in ("high", "critical"):
recommended_next_actions.append("High-urgency client — prioritize callback within 24h")
if not recent_interactions and lead:
recommended_next_actions.append("No recent interactions — schedule follow-up")
return {
"client_ref": person_id,
"snapshot_type": "client_360",
"identity": identity,
"account_links": account_links,
"current_lead": lead,
"active_opportunities": active_opportunities,
"recent_interactions": recent_interactions,
"property_interests": property_interests,
"tasks": tasks,
"qd_overview": qd_overview,
"risk_flags": risk_flags,
"recommended_next_actions": recommended_next_actions,
"note": "Derived read model. Not primary truth. Refresh from canonical tables.",
}
async def get_contact_list(
conn: Any,
tenant_id: str,
search: str | None = None,
buyer_type: str | None = None,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
"""
Paginated contact list with lead status and QD summary.
Implements the 'summary query' pattern from Doc 09.
"""
clauses: list[str] = ["p.tenant_id = $1"]
params: list[Any] = [tenant_id]
if search:
params.append(f"%{search}%")
clauses.append(
f"(p.full_name ILIKE ${len(params)} OR p.primary_email ILIKE ${len(params)} OR p.primary_phone ILIKE ${len(params)})"
)
if buyer_type:
params.append(buyer_type)
clauses.append(f"p.buyer_type = ${len(params)}")
if status:
params.append(status)
clauses.append(f"cl.status = ${len(params)}::crm_lead_status")
where = "WHERE " + " AND ".join(clauses)
params_for_count = params.copy()
params.append(limit)
params.append(offset)
query = f"""
SELECT
p.person_id,
p.full_name,
p.primary_email,
p.primary_phone,
p.buyer_type,
p.legacy_li_id,
p.created_at,
cl.lead_id,
cl.status AS lead_status,
cl.budget_band,
cl.urgency,
pi.project_name AS primary_interest,
COALESCE(qs.intent_value, 0.0) AS intent_score,
COALESCE(qs.engagement_value, qs.intent_value, 0.0) AS engagement_score,
COALESCE(qs.urgency_value, 0.0) AS urgency_score,
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS interaction_count,
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS last_interaction_at,
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.tenant_id = p.tenant_id AND ir.status = 'pending') AS pending_tasks
FROM crm_people p
LEFT JOIN LATERAL (
SELECT lead_id, status, budget_band, urgency
FROM crm_leads
WHERE person_id = p.person_id
AND tenant_id = p.tenant_id
ORDER BY created_at DESC
LIMIT 1
) cl ON TRUE
LEFT JOIN LATERAL (
SELECT project_name
FROM crm_property_interests
WHERE person_id = p.person_id
AND tenant_id = p.tenant_id
ORDER BY priority ASC, created_at DESC
LIMIT 1
) pi ON TRUE
LEFT JOIN LATERAL (
SELECT
MAX(CASE WHEN score_type = 'intent_score' THEN current_value END) AS intent_value,
MAX(CASE WHEN score_type = 'engagement_score' THEN current_value END) AS engagement_value,
MAX(CASE WHEN score_type = 'urgency_score' THEN current_value END) AS urgency_value
FROM intel_qd_scores
WHERE person_id = p.person_id
AND tenant_id = p.tenant_id
) qs ON TRUE
{where}
ORDER BY last_interaction_at DESC NULLS LAST, p.created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
"""
count_query = f"""
SELECT COUNT(*)
FROM crm_people p
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = p.tenant_id
{where}
"""
rows = await conn.fetch(query, *params)
total_row = await conn.fetchrow(count_query, *params_for_count)
total = int(total_row[0]) if total_row else 0
contacts = []
for r in rows:
contacts.append({
"person_id": str(r["person_id"]),
"full_name": r["full_name"],
"primary_email": r["primary_email"],
"primary_phone": r["primary_phone"],
"buyer_type": r["buyer_type"],
"lead_id": str(r["lead_id"]) if r["lead_id"] else None,
"legacy_li_id": str(r["legacy_li_id"]) if r["legacy_li_id"] else None,
"lead_status": r["lead_status"],
"budget_band": r["budget_band"],
"urgency": r["urgency"],
"primary_interest": r["primary_interest"],
"intent_score": float(r["intent_score"]),
"engagement_score": float(r["engagement_score"]),
"urgency_score": float(r["urgency_score"]),
"interaction_count": int(r["interaction_count"]),
"last_interaction_at": r["last_interaction_at"].isoformat() if r["last_interaction_at"] else None,
"pending_tasks": int(r["pending_tasks"]),
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
})
return {
"contacts": contacts,
"total": total,
"limit": limit,
"offset": offset,
}

View File

@@ -0,0 +1,90 @@
"""
Evolution API (https://github.com/EvolutionAPI/evolution-api) adapter.
"""
import httpx
from typing import Any, Dict, List, Optional
from .comms_provider import CommsProvider
class EvolutionProvider(CommsProvider):
def _headers(self) -> Dict[str, str]:
return {"Content-Type": "application/json", "apikey": self.api_key}
async def _request(self, method: str, path: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(method, url, headers=self._headers(), json=json_data)
resp.raise_for_status()
return resp.json()
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
instance = self.instance_id or "default"
payload = {
"number": phone,
"text": message,
"options": {"delay": 1200, "presence": "composing"},
}
result = await self._request("POST", f"/message/sendText/{instance}", payload)
ext_id = result.get("key", {}).get("id") if isinstance(result, dict) else None
return {
"success": True,
"provider": "evolution",
"external_message_id": ext_id,
"status": "sent",
"raw": result,
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Evolution webhook v2 shape:
{
"event": "messages.upsert",
"instance": "default",
"data": {
"key": {"remoteJid": "123@s.whatsapp.net", "fromMe": false, "id": "..."},
"message": {"conversation": "Hello"},
"messageTimestamp": 1710000000, ...
}
}
"""
event = payload.get("event", "")
data = payload.get("data", {})
key = data.get("key", {})
remote_jid = key.get("remoteJid", "")
phone = remote_jid.replace("@s.whatsapp.net", "").replace("@g.us", "")
msg_content = data.get("message", {})
body = msg_content.get("conversation", "") or msg_content.get("extendedTextMessage", {}).get("text", "")
direction = "outbound" if key.get("fromMe") else "inbound"
return {
"provider": "evolution",
"external_message_id": key.get("id"),
"phone_e164": phone,
"direction": direction,
"message_type": "text",
"body": body,
"media_url": None,
"raw": payload,
"timestamp": data.get("messageTimestamp"),
}
async def test_connection(self) -> Dict[str, Any]:
try:
instance = self.instance_id or "default"
info = await self._request("GET", f"/instance/connectionState/{instance}")
return {
"success": True,
"message": f"Evolution instance '{instance}' state retrieved.",
"account_info": info,
}
except Exception as exc:
return {
"success": False,
"message": f"Evolution connection failed: {exc}",
}
async def fetch_templates(self) -> List[Dict[str, Any]]:
return []

View File

@@ -0,0 +1,239 @@
"""Inbound communications ingestion for Velocity CRM."""
from __future__ import annotations
import json
import os
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
PHONEUTILS_AVAILABLE = False
try:
import phonenumbers
from phonenumbers import NumberParseException
PHONEUTILS_AVAILABLE = True
except ImportError:
phonenumbers = None # type: ignore[assignment]
NumberParseException = Exception # type: ignore[assignment]
DEFAULT_COUNTRY = os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91")
def normalize_phone(phone: str, default_region: str = DEFAULT_COUNTRY) -> str | None:
"""Return an E.164-like phone number suitable for provider and CRM matching."""
if not phone:
return None
cleaned = re.sub(r"[^\d+]", "", phone.strip())
if cleaned.startswith("00"):
cleaned = "+" + cleaned[2:]
if not cleaned.startswith("+"):
cleaned = f"+{default_region}{cleaned}"
if PHONEUTILS_AVAILABLE and phonenumbers is not None:
try:
parsed = phonenumbers.parse(cleaned, None)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
pass
return cleaned if re.match(r"^\+\d{7,15}$", cleaned) else None
def _phone_digits(phone: str) -> str:
return re.sub(r"\D+", "", phone or "")
def _crm_channel(channel: str) -> str:
allowed = {"whatsapp", "sms", "call", "email", "website", "walk_in", "other"}
return channel if channel in allowed else "other"
async def get_or_create_thread(
pool,
phone_e164: str,
provider: str,
external_thread_id: str | None = None,
display_name: str | None = None,
channel: str = "whatsapp",
) -> dict[str, Any]:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT thread_id, person_id, status, unread_count
FROM comms_threads
WHERE phone_e164 = $1 AND provider = $2
LIMIT 1
""",
phone_e164,
provider,
)
if row:
return dict(row)
person_id = None
try:
person_row = await conn.fetchrow(
"""
SELECT person_id
FROM crm_people
WHERE primary_phone = $1
OR regexp_replace(COALESCE(primary_phone, ''), '[^0-9]', '', 'g') = $2
LIMIT 1
""",
phone_e164,
_phone_digits(phone_e164),
)
person_id = person_row["person_id"] if person_row else None
except Exception:
person_id = None
new_id = await conn.fetchval(
"""
INSERT INTO comms_threads
(provider, external_thread_id, person_id, phone_e164, display_name, channel, status, unread_count)
VALUES ($1, $2, $3, $4, $5, $6, 'open', 1)
RETURNING thread_id
""",
provider,
external_thread_id,
person_id,
phone_e164,
display_name or phone_e164,
channel,
)
return {
"thread_id": new_id,
"person_id": person_id,
"status": "open",
"unread_count": 1,
"is_new": True,
}
async def store_message(
pool,
thread_id: UUID,
provider: str,
external_message_id: str | None,
direction: str,
message_type: str,
body: str,
media_url: str | None = None,
raw_payload: dict[str, Any] | None = None,
sent_at: datetime | None = None,
) -> UUID:
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, raw_payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING message_id
""",
thread_id,
provider,
external_message_id,
direction,
message_type,
body,
media_url,
"delivered" if direction == "inbound" else "sent",
sent_at or datetime.now(UTC),
json.dumps(raw_payload or {}),
)
unread_delta = 1 if direction == "inbound" else 0
await conn.execute(
"""
UPDATE comms_threads
SET last_message_at = NOW(), unread_count = unread_count + $2, updated_at = NOW()
WHERE thread_id = $1
""",
thread_id,
unread_delta,
)
return msg_id
async def maybe_create_crm_interaction(pool, person_id: UUID, body: str, channel: str = "whatsapp") -> None:
"""Mirror inbound comms into canonical CRM intelligence tables when present."""
if not person_id:
return
try:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT to_regclass('public.intel_interactions') IS NOT NULL")
if not exists:
return
interaction_id = await conn.fetchval(
"""
INSERT INTO intel_interactions
(person_id, channel, interaction_type, happened_at, summary, source_ref, metadata_json)
VALUES ($1, $2::intel_channel, 'message', NOW(), $3, 'comms_ingest', $4::jsonb)
RETURNING interaction_id
""",
person_id,
_crm_channel(channel),
body[:500],
json.dumps({"source": "comms", "direction": "inbound"}),
)
if await conn.fetchval("SELECT to_regclass('public.intel_messages') IS NOT NULL"):
await conn.execute(
"""
INSERT INTO intel_messages
(interaction_id, sender_role, sender_name, message_text, delivered_at, metadata_json)
VALUES ($1, 'lead', NULL, $2, NOW(), $3::jsonb)
""",
interaction_id,
body,
json.dumps({"source": "comms"}),
)
except Exception:
return
async def ingest_inbound_message(pool, normalized_payload: dict[str, Any]) -> dict[str, Any]:
phone = normalize_phone(normalized_payload.get("phone_e164") or normalized_payload.get("phone") or "")
if not phone:
raise ValueError("Missing phone_e164 in payload")
provider = normalized_payload.get("provider", "unknown")
channel = normalized_payload.get("channel", "whatsapp")
thread = await get_or_create_thread(
pool,
phone_e164=phone,
provider=provider,
external_thread_id=normalized_payload.get("external_thread_id"),
display_name=normalized_payload.get("display_name") or phone,
channel=channel,
)
timestamp = normalized_payload.get("timestamp")
sent_at = datetime.fromtimestamp(timestamp, UTC) if timestamp else None
msg_id = await store_message(
pool,
thread_id=thread["thread_id"],
provider=provider,
external_message_id=normalized_payload.get("external_message_id"),
direction=normalized_payload.get("direction", "inbound"),
message_type=normalized_payload.get("message_type", "text"),
body=normalized_payload.get("body", ""),
media_url=normalized_payload.get("media_url"),
raw_payload=normalized_payload.get("raw"),
sent_at=sent_at,
)
if thread.get("person_id") and normalized_payload.get("direction", "inbound") == "inbound":
await maybe_create_crm_interaction(pool, thread["person_id"], normalized_payload.get("body", ""), channel)
return {
"thread_id": str(thread["thread_id"]),
"message_id": str(msg_id),
"person_id": str(thread["person_id"]) if thread.get("person_id") else None,
"is_new_thread": thread.get("is_new", False),
}

View File

@@ -0,0 +1,63 @@
"""
Abstract provider interface for Velocity Comms.
"""
import os
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
class CommsProvider(ABC):
def __init__(self, base_url: str, api_key: str, instance_id: Optional[str] = None):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.instance_id = instance_id
@abstractmethod
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
"""Send a message. Return provider response dict."""
...
@abstractmethod
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Convert provider webhook payload to Velocity canonical format."""
...
@abstractmethod
async def test_connection(self) -> Dict[str, Any]:
"""Test provider connectivity. Return {success, message, account_info}."""
...
async def fetch_templates(self) -> List[Dict[str, Any]]:
"""Optional: fetch message templates."""
return []
async def get_media(self, media_id: str) -> Optional[bytes]:
"""Optional: download media bytes."""
return None
async def send_template(self, phone: str, template_name: str, language: str, components: Optional[List] = None) -> Dict[str, Any]:
"""Optional: send a template message."""
raise NotImplementedError("Templates not supported by this provider.")
class MockProvider(CommsProvider):
"""Mock provider for local development and UI previews."""
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
return {
"success": True,
"provider": "mock",
"external_message_id": f"mock-{os.urandom(4).hex()}",
"status": "sent",
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return payload
async def test_connection(self) -> Dict[str, Any]:
return {
"success": True,
"message": "Mock provider is always healthy.",
"account_info": {"mode": "mock"},
}

View File

@@ -0,0 +1,95 @@
"""
WAHA (https://github.com/devlikeapro/waha) adapter.
WAHA exposes a simple HTTP API for WhatsApp Web.
"""
import httpx
from typing import Any, Dict, Optional
from .comms_provider import CommsProvider
class WahaProvider(CommsProvider):
async def _request(self, method: str, path: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{self.base_url}/api{path}"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-Api-Key"] = self.api_key
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(method, url, headers=headers, json=json_data)
resp.raise_for_status()
return resp.json()
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
chat_id = f"{phone}@c.us"
payload = {
"chatId": chat_id,
"text": message,
"session": self.instance_id or "default",
}
if message_type == "image" and kwargs.get("media_url"):
payload["caption"] = message
payload["media"] = kwargs["media_url"]
result = await self._request("POST", "/sendImage", payload)
else:
result = await self._request("POST", "/sendText", payload)
return {
"success": True,
"provider": "waha",
"external_message_id": result.get("id"),
"status": "sent",
"raw": result,
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
WAHA webhook payload shape (v2024):
{
"event": "message",
"session": "default",
"payload": {
"id": "true_123@c.us_3EB0...",
"timestamp": 1710000000,
"from": "123@c.us",
"to": "456@c.us",
"body": "Hello",
"hasMedia": false, ...
}
}
"""
event = payload.get("event", "")
pl = payload.get("payload", {})
from_jid = pl.get("from", "")
phone = from_jid.replace("@c.us", "").replace("@g.us", "")
direction = "inbound" if event == "message" and not pl.get("fromMe") else "outbound"
return {
"provider": "waha",
"external_message_id": pl.get("id"),
"phone_e164": phone,
"direction": direction,
"message_type": "image" if pl.get("hasMedia") else "text",
"body": pl.get("body", ""),
"media_url": pl.get("mediaUrl"),
"raw": payload,
"timestamp": pl.get("timestamp"),
}
async def test_connection(self) -> Dict[str, Any]:
try:
sessions = await self._request("GET", "/sessions?all=true")
return {
"success": True,
"message": f"Connected to WAHA. Sessions: {len(sessions)}",
"account_info": {"sessions": sessions},
}
except Exception as exc:
return {
"success": False,
"message": f"WAHA connection failed: {exc}",
}
async def get_media(self, media_id: str) -> Optional[bytes]:
return None

View File

@@ -0,0 +1,3 @@
"""
backend/services/imports/__init__.py
"""

View File

@@ -0,0 +1,286 @@
"""
backend/services/imports/ingest_service.py
CRM Import Ingestion Service
Implements the RawImportBatch → ImportMappingManifest → NormalizedEntityProposal pipeline
as specified in Doc 08 (Adapter Spec) and Doc 07 (Contracts and Schema Blueprint).
Flow:
1. receive CSV upload, store raw batch record
2. parse headers and infer column mapping
3. validate row structure, detect unresolved columns
4. create NormalizedEntityProposal records for review
5. queue for human approval before canonical commit
"""
from __future__ import annotations
import csv
import io
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger("velocity.imports.ingest")
# ── Column mapping heuristics ─────────────────────────────────────────────────
# Maps common source column names → canonical crm_people / crm_leads fields.
CANONICAL_COLUMN_MAP: dict[str, str] = {
# Identity
"name": "full_name",
"full name": "full_name",
"client name": "full_name",
"contact name": "full_name",
"first name": "full_name",
"customer name": "full_name",
# Email
"email": "primary_email",
"email address": "primary_email",
"e-mail": "primary_email",
# Phone
"phone": "primary_phone",
"mobile": "primary_phone",
"contact number": "primary_phone",
"mobile number": "primary_phone",
"phone number": "primary_phone",
# Budget
"budget": "budget_band",
"budget range": "budget_band",
"investment budget": "budget_band",
# Project interest
"project": "project_name",
"project name": "project_name",
"interested in": "project_name",
"property interest": "project_name",
# Source
"source": "source_system",
"lead source": "source_system",
"channel": "source_system",
# Status / Stage
"status": "status",
"lead status": "status",
"stage": "status",
"funnel stage": "status",
# Notes
"notes": "notes",
"remarks": "notes",
"comment": "notes",
"comments": "notes",
# Buyer type
"type": "buyer_type",
"client type": "buyer_type",
"category": "buyer_type",
}
REQUIRED_CANONICAL_FIELDS = {"full_name"}
HIGH_RISK_FIELDS = {"primary_email", "primary_phone"}
def _normalize_header(h: str) -> str:
return h.strip().lower().replace("_", " ")
def infer_column_mapping(headers: list[str]) -> dict[str, Any]:
"""
Produce an ImportMappingManifest-compatible mapping dict.
Returns: {
mapped: {source_col → canonical_field},
unmapped: [source_col, ...],
confidence: 0.0-1.0
}
"""
mapped: dict[str, str] = {}
unmapped: list[str] = []
for h in headers:
normalized = _normalize_header(h)
canonical = CANONICAL_COLUMN_MAP.get(normalized)
if canonical:
mapped[h] = canonical
else:
unmapped.append(h)
mapped_count = len(mapped)
total = len(headers)
confidence = mapped_count / total if total > 0 else 0.0
return {
"mapped": mapped,
"unmapped": unmapped,
"mapped_count": mapped_count,
"unmapped_count": len(unmapped),
"confidence": round(confidence, 3),
}
def parse_csv_content(content: str) -> dict[str, Any]:
"""
Parse CSV content, detect headers, and extract rows.
Returns: {headers, rows, row_count, parse_errors}
"""
reader = csv.DictReader(io.StringIO(content))
headers = reader.fieldnames or []
rows: list[dict[str, Any]] = []
parse_errors: list[str] = []
for i, row in enumerate(reader):
try:
rows.append(dict(row))
except Exception as e:
parse_errors.append(f"Row {i + 2}: {str(e)}")
return {
"headers": list(headers),
"rows": rows,
"row_count": len(rows),
"parse_errors": parse_errors,
}
def build_normalized_proposals(
rows: list[dict[str, Any]],
mapping: dict[str, str],
batch_id: str,
source_system: str = "csv_upload",
) -> list[dict[str, Any]]:
"""
Convert raw CSV rows to NormalizedEntityProposal payloads.
One proposal per row — each must be approved before canonical commit.
"""
proposals: list[dict[str, Any]] = []
now = datetime.now(timezone.utc).isoformat()
for i, row in enumerate(rows):
canonical: dict[str, Any] = {}
unresolved: list[str] = []
confidence = 1.0
for src_col, canonical_field in mapping.items():
val = row.get(src_col, "").strip()
if val:
canonical[canonical_field] = val
else:
unresolved.append(src_col)
# Validate required fields
review_required = False
missing_required = [f for f in REQUIRED_CANONICAL_FIELDS if not canonical.get(f)]
if missing_required:
review_required = True
confidence = max(0.0, confidence - 0.4)
# Flag high-risk fields (email/phone) if empty
missing_high_risk = [f for f in HIGH_RISK_FIELDS if not canonical.get(f)]
if missing_high_risk:
confidence = max(0.0, confidence - 0.1 * len(missing_high_risk))
proposal: dict[str, Any] = {
"proposal_id": str(uuid.uuid4()),
"batch_id": batch_id,
"row_number": i + 2,
"entity_type": "crm_person_with_lead",
"canonical_payload": canonical,
"raw_row": row,
"unresolved_fields": unresolved,
"missing_required": missing_required,
"confidence": round(confidence, 3),
"review_required": review_required,
"status": "proposed",
"created_at": now,
"source_system": source_system,
}
proposals.append(proposal)
return proposals
def create_import_batch_record(
filename: str,
row_count: int,
mapping_manifest: dict[str, Any],
source_system: str = "csv_upload",
uploaded_by_id: str | None = None,
tenant_id: str | None = None,
) -> dict[str, Any]:
"""
Build the workflow_import_batches record payload.
"""
now = datetime.now(timezone.utc).isoformat()
return {
"batch_id": str(uuid.uuid4()),
"source_system": source_system,
"uploaded_filename": filename,
"mime_type": "text/csv",
"row_count": row_count,
"mapped_count": mapping_manifest.get("mapped_count", 0),
"unresolved_count": mapping_manifest.get("unmapped_count", 0),
"uploaded_by": uploaded_by_id,
"tenant_id": tenant_id,
"lifecycle": "parsed",
"mapping_manifest": mapping_manifest,
"created_at": now,
"updated_at": now,
}
async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
"""
Insert a workflow_import_batches row and return batch_id.
"""
await conn.execute(
"""
INSERT INTO workflow_import_batches (
batch_id, tenant_id, source_system, uploaded_filename, mime_type, row_count,
mapped_count, unresolved_count, uploaded_by, lifecycle, mapping_manifest,
created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7, $8,
$9::uuid, $10::import_lifecycle, $11::jsonb, NOW(), NOW()
)
""",
batch["batch_id"],
batch["tenant_id"],
batch["source_system"],
batch.get("uploaded_filename", "unknown.csv"),
batch.get("mime_type", "text/csv"),
batch.get("row_count", 0),
batch.get("mapped_count", 0),
batch.get("unresolved_count", 0),
batch.get("uploaded_by"),
batch.get("lifecycle", "parsed"),
json.dumps(batch.get("mapping_manifest", {})),
)
return batch["batch_id"]
async def persist_proposals_as_workflow_actions(
conn: Any, proposals: list[dict[str, Any]], tenant_id: str
) -> int:
"""
Insert proposals into workflow_actions table for human review.
Returns inserted count.
"""
inserted = 0
for p in proposals:
await conn.execute(
"""
INSERT INTO workflow_actions (
action_id, tenant_id, action_type, target_domain, proposal_payload,
reasoning_summary, confidence, status, approval_required,
created_by_agent, created_at, updated_at
) VALUES (
$1::uuid, $2, 'import_proposal', 'crm', $3::jsonb,
$4, $5, 'pending'::wf_status, $6, 'ingest_service', NOW(), NOW()
)
""",
p["proposal_id"],
tenant_id,
json.dumps(p),
f"Import row {p['row_number']}: {p['canonical_payload'].get('full_name', 'unknown')}",
p["confidence"],
p["review_required"],
)
inserted += 1
return inserted

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import os
from typing import Any
import httpx
class MCPRegistry:
def __init__(self) -> None:
self._tools = {
"local_property_rag": {
"description": "Searches project, property, and unit metadata from root CRM data.",
"transport": "python_local",
},
"crm_search": {
"description": "Queries lead and interaction state from the root PostgreSQL CRM schema.",
"transport": "python_local",
},
"external_search": {
"description": "Abstract external search slot inspired by Sourik's Brave/DDG tools.",
"transport": "adapter_slot",
},
}
def list_tools(self) -> list[dict[str, Any]]:
return [{"name": name, **meta} for name, meta in self._tools.items()]
async def execute(self, tool_name: str, query: str, *, crm_pool: Any | None = None) -> dict[str, Any]:
if tool_name not in self._tools:
raise KeyError(f"Unknown MCP tool '{tool_name}'.")
if tool_name == "external_search":
return await self._external_search(query)
if tool_name == "crm_search":
return await self._crm_search(query, crm_pool)
if tool_name == "local_property_rag":
return await self._local_property_rag(query, crm_pool)
return {"tool": tool_name, "query": query, "status": "unsupported"}
async def _external_search(self, query: str) -> dict[str, Any]:
brave_key = os.getenv("BRAVE_API_KEY", "")
if brave_key and not brave_key.startswith("PLACEHOLDER"):
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(
"https://api.search.brave.com/res/v1/web/search",
headers={"Accept": "application/json", "X-Subscription-Token": brave_key},
params={"q": query, "count": 5},
)
response.raise_for_status()
payload = response.json()
results = [
{
"title": item.get("title"),
"url": item.get("url"),
"snippet": item.get("description"),
}
for item in payload.get("web", {}).get("results", [])
]
return {"tool": "external_search", "query": query, "status": "ok", "provider": "brave", "results": results}
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_html": 1, "no_redirect": 1},
)
response.raise_for_status()
payload = response.json()
results: list[dict[str, Any]] = []
abstract = payload.get("AbstractText")
if abstract:
results.append(
{
"title": payload.get("Heading") or query,
"url": payload.get("AbstractURL"),
"snippet": abstract,
}
)
for topic in payload.get("RelatedTopics", [])[:5]:
if isinstance(topic, dict) and topic.get("Text"):
results.append(
{
"title": topic.get("Text", "")[:80],
"url": topic.get("FirstURL"),
"snippet": topic.get("Text"),
}
)
return {"tool": "external_search", "query": query, "status": "ok", "provider": "duckduckgo", "results": results}
async def _crm_search(self, query: str, crm_pool: Any | None) -> dict[str, Any]:
if crm_pool is None:
return {"tool": "crm_search", "query": query, "status": "unavailable", "reason": "crm_pool_missing"}
async with crm_pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, name, email, phone, source, qualification, score, kanban_status, budget, unit_interest
FROM leads
WHERE LOWER(name) LIKE $1
OR LOWER(COALESCE(email, '')) LIKE $1
OR LOWER(COALESCE(phone, '')) LIKE $1
OR LOWER(COALESCE(notes, '')) LIKE $1
ORDER BY score DESC, updated_at DESC
LIMIT 10
""",
f"%{query.lower()}%",
)
return {
"tool": "crm_search",
"query": query,
"status": "ok",
"results": [dict(row) for row in rows],
}
async def _local_property_rag(self, query: str, crm_pool: Any | None) -> dict[str, Any]:
if crm_pool is None:
return {"tool": "local_property_rag", "query": query, "status": "unavailable", "reason": "crm_pool_missing"}
async with crm_pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, name, source, budget, unit_interest, metadata
FROM leads
WHERE LOWER(COALESCE(unit_interest, '')) LIKE $1
OR LOWER(COALESCE(notes, '')) LIKE $1
ORDER BY score DESC, updated_at DESC
LIMIT 10
""",
f"%{query.lower()}%",
)
return {
"tool": "local_property_rag",
"query": query,
"status": "ok",
"results": [dict(row) for row in rows],
}
mcp_registry = MCPRegistry()

View File

@@ -0,0 +1,354 @@
"""
backend/services/nemoclaw_client.py - NemoClaw inference client.
Production path:
1. Shared SGLang / OpenAI-compatible coding runtime.
Compatibility:
- Legacy NEMOCLAW_* env names are still honored.
- Legacy OLLAMA_BASE_URL can still seed the base URL, but Ollama is no longer
a production fallback path.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from dataclasses import dataclass, field
from typing import Optional
import httpx
logger = logging.getLogger("velocity.nemoclaw")
NEMOCLAW_TIMEOUT = float(os.getenv("NEMOCLAW_TIMEOUT_S", "45.0"))
NEMOCLAW_TEMPERATURE = float(os.getenv("NEMOCLAW_TEMPERATURE", "0.2"))
SGLANG_BASE_URL = os.getenv(
"SGLANG_BASE_URL",
os.getenv(
"NEMOCLAW_BASE_URL",
os.getenv("LLM_BASE_URL", os.getenv("OLLAMA_BASE_URL", "https://llm.desineuron.in")),
),
).rstrip("/")
SGLANG_CHAT_URL = os.getenv(
"SGLANG_CHAT_URL",
os.getenv("NEMOCLAW_CHAT_URL", f"{SGLANG_BASE_URL}/v1/chat/completions"),
)
SGLANG_MODELS_URL = os.getenv("SGLANG_MODELS_URL", f"{SGLANG_BASE_URL}/v1/models")
SGLANG_MODEL = os.getenv(
"SGLANG_MODEL",
os.getenv("NEMOCLAW_MODEL", os.getenv("OLLAMA_MODEL", "qwen3.6:35b-a3b")),
)
SGLANG_API_TOKEN = os.getenv("SGLANG_API_TOKEN", os.getenv("NEMOCLAW_API_TOKEN", ""))
_PROMPT_DIR = os.getenv("NEMOCLAW_PROMPT_DIR", "/opt/dlami/nvme/nemoclaw/prompts")
def _load_system_prompt(name: str) -> str:
local_fallback = os.path.join(
os.path.dirname(__file__), "..", "nemoclaw_prompts", f"{name}.md"
)
for path in (os.path.join(_PROMPT_DIR, f"{name}.md"), local_fallback):
try:
with open(path, encoding="utf-8") as handle:
return "\n".join(
line for line in handle.read().splitlines() if not line.startswith("#")
).strip()
except FileNotFoundError:
continue
logger.warning("Prompt '%s' not found, using inline fallback.", name)
return _PROMPTS.get(name, "")
_PROMPTS = {
"qd_calculator": (
"You are a behavioral intelligence analyst for a luxury real estate sales platform.\n"
"Compute a Quantum Dynamics score between 1 and 100 using blend shapes, CRM context, "
"and the active scene label when present.\n"
'Respond with JSON only: {"qd_score": <int>, "reasoning": "<one sentence>", "confidence": <float>}'
),
"lead_tagger": (
"You are a lead intelligence analyst. Classify a real estate lead as HNI or NRI.\n"
'Respond with JSON only: {"tags_to_add": [...], "tags_to_remove": []}'
),
"cctv_profiler": (
"You are a visitor profiling analyst for a luxury real estate development CCTV system.\n"
'Respond with JSON only: {"wealth_indicator": "HNI"|"standard"|"unknown", '
'"vehicle_class": "luxury"|"standard"|"unknown", "tags_to_add": [...], "notes": "<string>"}'
),
}
@dataclass
class QDResult:
qd_score: int
reasoning: str
confidence: float
@dataclass
class TagResult:
tags_to_add: list[str] = field(default_factory=list)
tags_to_remove: list[str] = field(default_factory=list)
@dataclass
class CCTVProfileResult:
wealth_indicator: str
vehicle_class: str
tags_to_add: list[str] = field(default_factory=list)
notes: str = ""
async def _attempt_chat(
*,
label: str,
url: str,
model: str,
system_content: str,
user_content: str,
timeout: float,
headers: dict[str, str],
) -> dict:
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
],
"temperature": NEMOCLAW_TEMPERATURE,
"response_format": {"type": "json_object"},
"max_tokens": 1024,
}
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
body = response.json()
raw_content = body["choices"][0]["message"]["content"]
logger.debug("NemoClaw response via %s: %s", label, raw_content[:200])
return _parse_model_response(raw_content)
def _extract_text(raw_content: object) -> str:
if isinstance(raw_content, str):
return raw_content
if isinstance(raw_content, list):
parts: list[str] = []
for item in raw_content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts).strip()
return str(raw_content)
def _parse_model_response(raw_content: object) -> dict:
text = _extract_text(raw_content).strip()
if not text:
return {}
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
candidate = text[start : end + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
pass
parsed: dict[str, object] = {}
int_match = re.search(r'"qd_score"\s*:\s*(\d+)', text)
if int_match:
parsed["qd_score"] = int(int_match.group(1))
conf_match = re.search(r'"confidence"\s*:\s*([0-9]*\.?[0-9]+)', text)
if conf_match:
parsed["confidence"] = float(conf_match.group(1))
reason_match = re.search(r'"reasoning"\s*:\s*"([^"]*)"', text)
if reason_match:
parsed["reasoning"] = reason_match.group(1)
wealth_match = re.search(r'"wealth_indicator"\s*:\s*"([^"]*)"', text)
if wealth_match:
parsed["wealth_indicator"] = wealth_match.group(1)
vehicle_match = re.search(r'"vehicle_class"\s*:\s*"([^"]*)"', text)
if vehicle_match:
parsed["vehicle_class"] = vehicle_match.group(1)
notes_match = re.search(r'"notes"\s*:\s*"([^"]*)"', text)
if notes_match:
parsed["notes"] = notes_match.group(1)
tags_match = re.search(r'"tags_to_add"\s*:\s*\[(.*?)\]', text, flags=re.S)
if tags_match:
parsed["tags_to_add"] = re.findall(r'"([^"]+)"', tags_match.group(1))
remove_tags_match = re.search(r'"tags_to_remove"\s*:\s*\[(.*?)\]', text, flags=re.S)
if remove_tags_match:
parsed["tags_to_remove"] = re.findall(r'"([^"]+)"', remove_tags_match.group(1))
if parsed:
logger.warning("Recovered partial NemoClaw JSON payload from malformed model output.")
return parsed
raise json.JSONDecodeError("Unable to parse model JSON", text, 0)
async def _nemoclaw_chat(
system_content: str,
user_content: str,
timeout: float = NEMOCLAW_TIMEOUT,
) -> dict:
if not SGLANG_CHAT_URL:
raise RuntimeError(
"No NemoClaw inference endpoint is configured. Set SGLANG_BASE_URL or NEMOCLAW_BASE_URL."
)
headers = {"Content-Type": "application/json"}
if SGLANG_API_TOKEN:
headers["Authorization"] = f"Bearer {SGLANG_API_TOKEN}"
t_start = time.monotonic()
try:
result = await _attempt_chat(
label="sglang",
url=SGLANG_CHAT_URL,
model=SGLANG_MODEL,
system_content=system_content,
user_content=user_content,
timeout=timeout,
headers=headers,
)
logger.info(
"NemoClaw inference via sglang model=%s elapsed=%.2fs",
SGLANG_MODEL,
time.monotonic() - t_start,
)
return result
except (httpx.ConnectError, httpx.TimeoutException) as exc:
raise RuntimeError(f"NemoClaw SGLang endpoint unreachable: {exc}") from exc
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"NemoClaw SGLang HTTP {exc.response.status_code}: {exc.response.text[:300]}"
) from exc
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
raise RuntimeError(f"NemoClaw SGLang returned invalid JSON: {exc}") from exc
async def score_qd(
*,
lead_id: str,
batch_id: str,
blend_shapes: dict[str, float],
video_ts_ms: int,
scene_label: Optional[str] = None,
crm_context: dict,
current_qd_score: Optional[int] = None,
) -> QDResult:
system_prompt = _load_system_prompt("qd_calculator")
user_content = json.dumps(
{
"lead_id": lead_id,
"batch_id": batch_id,
"video_ts_ms": video_ts_ms,
"scene_label": scene_label,
"current_qd_score": current_qd_score,
"crm_context": crm_context,
"blend_shapes": blend_shapes,
},
indent=2,
)
data = await _nemoclaw_chat(system_prompt, user_content)
raw_score = int(data.get("qd_score", current_qd_score or 50))
return QDResult(
qd_score=max(1, min(100, raw_score)),
reasoning=str(data.get("reasoning", "")),
confidence=float(data.get("confidence", 0.7)),
)
async def tag_lead(
*,
lead_id: str,
phone: str,
budget: Optional[str],
message_text: str,
) -> TagResult:
system_prompt = _load_system_prompt("lead_tagger")
user_content = (
f"Lead ID: {lead_id}\n"
f"Phone: {phone}\n"
f"Budget indicator: {budget or 'unknown'}\n"
f"First message: {message_text}"
)
try:
data = await _nemoclaw_chat(system_prompt, user_content)
except Exception as exc:
logger.error("Lead tagging failed for %s: %s", lead_id, exc)
return TagResult()
return TagResult(
tags_to_add=data.get("tags_to_add", []),
tags_to_remove=data.get("tags_to_remove", []),
)
async def profile_cctv_visitor(
*,
license_plate: Optional[str],
zone: str,
face_description: Optional[str] = None,
vehicle_description: Optional[str] = None,
) -> CCTVProfileResult:
system_prompt = _load_system_prompt("cctv_profiler")
user_content = json.dumps(
{
"license_plate": license_plate,
"zone": zone,
"face_description": face_description,
"vehicle_description": vehicle_description,
},
indent=2,
)
try:
data = await _nemoclaw_chat(system_prompt, user_content, timeout=20.0)
except Exception as exc:
logger.error("CCTV profiling failed (zone=%s): %s", zone, exc)
return CCTVProfileResult(wealth_indicator="unknown", vehicle_class="unknown")
return CCTVProfileResult(
wealth_indicator=data.get("wealth_indicator", "unknown"),
vehicle_class=data.get("vehicle_class", "unknown"),
tags_to_add=data.get("tags_to_add", []),
notes=data.get("notes", ""),
)
async def health_check() -> dict:
headers = {"Content-Type": "application/json"}
if SGLANG_API_TOKEN:
headers["Authorization"] = f"Bearer {SGLANG_API_TOKEN}"
results: dict[str, str] = {
"model": SGLANG_MODEL,
"primary_url": SGLANG_CHAT_URL,
"models_url": SGLANG_MODELS_URL,
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
models_response = await client.get(SGLANG_MODELS_URL, headers=headers)
models_response.raise_for_status()
chat_response = await client.post(
SGLANG_CHAT_URL,
json={
"model": SGLANG_MODEL,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 5,
},
headers=headers,
)
chat_response.raise_for_status()
results["sglang"] = "ok"
except Exception as exc:
results["sglang"] = f"error: {exc}"
return results

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import hashlib
import hmac
import os
from typing import Any
class NemoclawRuntime:
def claim_event(self, source_id: str, payload: dict[str, Any]) -> dict[str, Any]:
claim = hashlib.sha256(f"{source_id}:{payload}".encode("utf-8")).hexdigest()[:24]
return {"claim_id": claim, "source_id": source_id, "status": "claimed"}
def verify_webhook_challenge(self, challenge: str, signature: str) -> bool:
secret = os.getenv("NEMOCLAW_WEBHOOK_SECRET", "")
if not secret:
return False
expected = hmac.new(secret.encode("utf-8"), challenge.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def build_workflow_dispatch(
self,
*,
prompt: str,
tenant_id: str,
actor_role: str,
component_templates: list[str],
) -> dict[str, Any]:
return {
"runtime": "python_native_nemoclaw",
"tenantId": tenant_id,
"actorRole": actor_role,
"workflow": "oracle_canvas_generation",
"prompt": prompt,
"componentTemplates": component_templates,
"executionBackend": "comfyui_orchestrated",
}
nemoclaw_runtime = NemoclawRuntime()

View File

@@ -0,0 +1,443 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
import httpx
logger = logging.getLogger("velocity.runtime_llm")
SGLANG_BASE_URL = os.getenv(
"SGLANG_BASE_URL",
os.getenv("LLM_BASE_URL", os.getenv("OLLAMA_BASE_URL", "https://llm.desineuron.in")),
).rstrip("/")
SGLANG_CHAT_URL = os.getenv("SGLANG_CHAT_URL", f"{SGLANG_BASE_URL}/v1/chat/completions")
SGLANG_MODELS_URL = os.getenv("SGLANG_MODELS_URL", f"{SGLANG_BASE_URL}/v1/models")
SGLANG_DEFAULT_MODEL = os.getenv(
"SGLANG_MODEL",
os.getenv("OLLAMA_MODEL", "qwen3.6:35b-a3b"),
)
SGLANG_API_TOKEN = os.getenv("SGLANG_API_TOKEN", "")
RUNTIME_LLM_TIMEOUT_S = float(os.getenv("RUNTIME_LLM_TIMEOUT_S", "90.0"))
RUNTIME_LLM_CONCURRENCY = int(os.getenv("RUNTIME_LLM_BATCH_CONCURRENCY", "2"))
def _utc_now() -> datetime:
return datetime.now(UTC)
def _utc_iso() -> str:
return _utc_now().isoformat()
@dataclass
class RuntimeProvider:
provider_id: str
base_url: str
chat_url: str
default_model: str
auth_token: str | None = None
supports_batch: bool = True
@property
def headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
class RuntimeLLMService:
def __init__(self) -> None:
self._jobs: dict[str, dict[str, Any]] = {}
def _provider_catalog(self) -> list[RuntimeProvider]:
if not SGLANG_CHAT_URL:
return []
return [
RuntimeProvider(
provider_id="sglang",
base_url=SGLANG_BASE_URL,
chat_url=SGLANG_CHAT_URL,
default_model=SGLANG_DEFAULT_MODEL,
auth_token=SGLANG_API_TOKEN or None,
)
]
def get_provider(self, provider_id: str | None) -> RuntimeProvider:
providers = {provider.provider_id: provider for provider in self._provider_catalog()}
if provider_id in {"ollama", "nemoclaw"}:
provider_id = "sglang"
if provider_id:
provider = providers.get(provider_id)
if provider is None:
raise ValueError(f"Unknown provider '{provider_id}'.")
return provider
if "sglang" in providers:
return providers["sglang"]
raise ValueError("No runtime LLM providers are configured.")
async def list_providers(self) -> list[dict[str, Any]]:
providers: list[dict[str, Any]] = []
for provider in self._provider_catalog():
models: list[str] = [provider.default_model]
status = "offline"
error: str | None = None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(SGLANG_MODELS_URL, headers=provider.headers)
response.raise_for_status()
payload = response.json()
models = [
str(item.get("id", "")).strip()
for item in payload.get("data", [])
if item.get("id")
]
if provider.default_model not in models:
models.insert(0, provider.default_model)
status = "online"
except Exception as exc: # pragma: no cover - network/runtime dependent
error = str(exc)
providers.append(
{
"id": provider.provider_id,
"status": status,
"baseUrl": provider.base_url,
"defaultModel": provider.default_model,
"models": models,
"supportsBatch": provider.supports_batch,
"error": error,
}
)
return providers
async def chat(
self,
*,
provider_id: str | None,
model: str | None,
system_prompt: str | None,
messages: list[dict[str, str]],
temperature: float = 0.2,
response_format: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
provider = self.get_provider(provider_id)
selected_model = model or provider.default_model
prepared_messages = list(messages)
if system_prompt:
prepared_messages = [{"role": "system", "content": system_prompt}] + prepared_messages
payload: dict[str, Any] = {
"model": selected_model,
"messages": prepared_messages,
"temperature": temperature,
}
if response_format == "json":
payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=RUNTIME_LLM_TIMEOUT_S) as client:
response = await client.post(provider.chat_url, json=payload, headers=provider.headers)
response.raise_for_status()
body = response.json()
choice = (body.get("choices") or [{}])[0]
message = choice.get("message") or {}
content = message.get("content")
text = self._extract_text(content)
parsed_json: dict[str, Any] | None = None
if response_format == "json":
try:
parsed_json = json.loads(text) if text else {}
except json.JSONDecodeError:
parsed_json = None
return {
"provider": provider.provider_id,
"model": selected_model,
"message": {
"role": "assistant",
"content": text,
"parsedJson": parsed_json,
},
"usage": body.get("usage"),
"metadata": metadata or {},
"completedAt": _utc_iso(),
}
async def submit_batch(
self,
*,
provider_id: str | None,
model: str | None,
job_type: str,
items: list[dict[str, Any]],
metadata: dict[str, Any] | None,
pool: Any | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
provider = self.get_provider(provider_id)
selected_model = model or provider.default_model
job_id = str(uuid.uuid4())
created_at = _utc_iso()
normalized_items = [
{
"request_id": str(item.get("request_id") or f"item_{idx+1}"),
"messages": item.get("messages") or [],
"system_prompt": item.get("system_prompt"),
"temperature": float(item.get("temperature", 0.2)),
"response_format": item.get("response_format"),
"metadata": item.get("metadata") or {},
}
for idx, item in enumerate(items)
]
job_record = {
"job_id": job_id,
"provider": provider.provider_id,
"model": selected_model,
"job_type": job_type,
"status": "queued",
"submitted_count": len(normalized_items),
"completed_count": 0,
"failed_count": 0,
"metadata": metadata or {},
"items": normalized_items,
"results": [],
"created_at": created_at,
"updated_at": created_at,
"started_at": None,
"completed_at": None,
"actor_id": actor_id,
}
self._jobs[job_id] = job_record
await self._persist_job(job_record, pool=pool)
asyncio.create_task(self._run_batch(job_id, pool=pool))
return {
"job_id": job_id,
"status": job_record["status"],
"provider": provider.provider_id,
"model": selected_model,
"submitted_count": len(normalized_items),
"created_at": created_at,
}
async def _run_batch(self, job_id: str, *, pool: Any | None = None) -> None:
job = self._jobs.get(job_id)
if not job:
return
job["status"] = "running"
job["started_at"] = _utc_iso()
job["updated_at"] = _utc_iso()
await self._persist_job(job, pool=pool)
semaphore = asyncio.Semaphore(RUNTIME_LLM_CONCURRENCY)
async def _execute_item(item: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
try:
response = await self.chat(
provider_id=job["provider"],
model=job["model"],
system_prompt=item.get("system_prompt"),
messages=item.get("messages") or [],
temperature=float(item.get("temperature", 0.2)),
response_format=item.get("response_format"),
metadata=item.get("metadata") or {},
)
return {
"request_id": item["request_id"],
"status": "completed",
"response": response,
"error": None,
}
except Exception as exc: # pragma: no cover - network/runtime dependent
logger.error("runtime_llm batch item failed job=%s request=%s error=%s", job_id, item["request_id"], exc)
return {
"request_id": item["request_id"],
"status": "failed",
"response": None,
"error": str(exc),
}
results = await asyncio.gather(*[_execute_item(item) for item in job["items"]])
job["results"] = results
job["completed_count"] = sum(1 for result in results if result["status"] == "completed")
job["failed_count"] = sum(1 for result in results if result["status"] == "failed")
job["status"] = "completed" if job["failed_count"] == 0 else ("failed" if job["completed_count"] == 0 else "completed_with_errors")
job["completed_at"] = _utc_iso()
job["updated_at"] = _utc_iso()
await self._persist_job(job, pool=pool)
async def get_job(self, job_id: str, *, pool: Any | None = None) -> dict[str, Any] | None:
if job_id in self._jobs:
return self._jobs[job_id]
if pool is not None:
loaded = await self._load_job_from_db(job_id, pool=pool)
if loaded:
self._jobs[job_id] = loaded
return loaded
return None
async def list_job_results(self, job_id: str, *, pool: Any | None = None) -> list[dict[str, Any]] | None:
job = await self.get_job(job_id, pool=pool)
if not job:
return None
return list(job.get("results") or [])
async def _persist_job(self, job: dict[str, Any], *, pool: Any | None = None) -> None:
if pool is None:
return
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO workflow_agent_runs (
run_id,
agent_name,
trigger_type,
trigger_ref,
input_payload,
output_payload,
status,
duration_ms,
error_detail,
started_at,
completed_at
)
VALUES (
$1::uuid,
'runtime_llm',
$2,
$3,
$4::jsonb,
$5::jsonb,
$6,
$7,
$8,
$9::timestamptz,
$10::timestamptz
)
ON CONFLICT (run_id)
DO UPDATE SET
input_payload = EXCLUDED.input_payload,
output_payload = EXCLUDED.output_payload,
status = EXCLUDED.status,
duration_ms = EXCLUDED.duration_ms,
error_detail = EXCLUDED.error_detail,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at
""",
job["job_id"],
job["job_type"],
job.get("actor_id"),
json.dumps(
{
"provider": job["provider"],
"model": job["model"],
"metadata": job.get("metadata") or {},
"items": job.get("items") or [],
}
),
json.dumps(
{
"results": job.get("results") or [],
"submitted_count": job.get("submitted_count", 0),
"completed_count": job.get("completed_count", 0),
"failed_count": job.get("failed_count", 0),
"created_at": job.get("created_at"),
"updated_at": job.get("updated_at"),
}
),
job["status"],
self._duration_ms(job.get("started_at"), job.get("completed_at")),
self._job_error_detail(job),
job.get("started_at"),
job.get("completed_at"),
)
async def _load_job_from_db(self, job_id: str, *, pool: Any) -> dict[str, Any] | None:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
run_id::text AS job_id,
trigger_type AS job_type,
trigger_ref AS actor_id,
input_payload,
output_payload,
status,
started_at,
completed_at
FROM workflow_agent_runs
WHERE run_id = $1::uuid AND agent_name = 'runtime_llm'
""",
job_id,
)
if not row:
return None
input_payload = dict(row["input_payload"] or {})
output_payload = dict(row["output_payload"] or {})
return {
"job_id": row["job_id"],
"provider": input_payload.get("provider"),
"model": input_payload.get("model"),
"job_type": row["job_type"],
"status": row["status"],
"submitted_count": int(output_payload.get("submitted_count", len(input_payload.get("items") or []))),
"completed_count": int(output_payload.get("completed_count", 0)),
"failed_count": int(output_payload.get("failed_count", 0)),
"metadata": input_payload.get("metadata") or {},
"items": input_payload.get("items") or [],
"results": output_payload.get("results") or [],
"created_at": output_payload.get("created_at") or (row["started_at"].isoformat() if row["started_at"] else None),
"updated_at": output_payload.get("updated_at") or (row["completed_at"].isoformat() if row["completed_at"] else None),
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"actor_id": row["actor_id"],
}
@staticmethod
def _extract_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict):
text = part.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts).strip()
return str(content or "")
@staticmethod
def _duration_ms(started_at: str | None, completed_at: str | None) -> int | None:
if not started_at or not completed_at:
return None
try:
start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
end = datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return None
return max(0, int((end - start).total_seconds() * 1000))
@staticmethod
def _job_error_detail(job: dict[str, Any]) -> str | None:
failed = [result for result in job.get("results") or [] if result.get("status") == "failed"]
if not failed:
return None
return "; ".join(f"{item.get('request_id')}: {item.get('error')}" for item in failed[:5])
runtime_llm_service = RuntimeLLMService()

View File

@@ -0,0 +1,147 @@
# ============================================================
# Velocity-OS — GitLab CI/CD Pipeline
# Build → Sign → Push to ECR → Notify Ingress Box
# ============================================================
stages:
- lint
- build
- sign
- notify
variables:
DOCKER_DRIVER: overlay2
DOCKER_BUILDKIT: "1"
AWS_REGION: "ap-south-1"
ECR_REGISTRY: "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
IMAGE_TAG: "${CI_COMMIT_SHORT_SHA}"
# ── Lint + Type Check ─────────────────────────────────────────
lint:webos:
stage: lint
image: node:20-alpine
cache:
key: "${CI_COMMIT_REF_SLUG}-node"
paths: [webos/node_modules/]
script:
- cd webos && npm ci && npm run type-check && npm run lint
rules:
- changes: [webos/**/*]
lint:core:
stage: lint
image: python:3.11-slim
script:
- pip install ruff mypy -q
- cd core && ruff check . && mypy . --ignore-missing-imports
rules:
- changes: [core/**/*]
# ── Build + Push Images ───────────────────────────────────────
.build_template: &build_template
stage: build
image: docker:24-dind
services: [docker:24-dind]
before_script:
- aws ecr get-login-password --region $AWS_REGION |
docker login --username AWS --password-stdin $ECR_REGISTRY
script:
- |
docker build \
--cache-from ${ECR_REGISTRY}/velocity-os/${SERVICE}:latest \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--label git.sha=${CI_COMMIT_SHA} \
--label git.ref=${CI_COMMIT_REF_NAME} \
-t ${ECR_REGISTRY}/velocity-os/${SERVICE}:${IMAGE_TAG} \
-t ${ECR_REGISTRY}/velocity-os/${SERVICE}:latest \
./${SERVICE}
docker push ${ECR_REGISTRY}/velocity-os/${SERVICE}:${IMAGE_TAG}
docker push ${ECR_REGISTRY}/velocity-os/${SERVICE}:latest
build:core:
<<: *build_template
variables:
SERVICE: core
rules:
- changes: [core/**/*]
build:webos:
<<: *build_template
variables:
SERVICE: webos
rules:
- changes: [webos/**/*]
build:media-engine:
<<: *build_template
variables:
SERVICE: media-engine
rules:
- changes: [media-engine/**/*]
build:agents:
<<: *build_template
variables:
SERVICE: agents
rules:
- changes: [agents/**/*]
# ── Sign Images with cosign ───────────────────────────────────
.sign_template: &sign_template
stage: sign
image: ghcr.io/sigstore/cosign:v2.4.0
script:
- |
IMAGE="${ECR_REGISTRY}/velocity-os/${SERVICE}:${IMAGE_TAG}"
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE || \
aws ecr describe-images \
--repository-name velocity-os/${SERVICE} \
--image-ids imageTag=${IMAGE_TAG} \
--region ${AWS_REGION} \
--query 'imageDetails[0].imageDigest' --output text)
cosign sign --yes "${ECR_REGISTRY}/velocity-os/${SERVICE}@${DIGEST}"
sign:core:
<<: *sign_template
variables:
SERVICE: core
needs: [build:core]
sign:webos:
<<: *sign_template
variables:
SERVICE: webos
needs: [build:webos]
sign:media-engine:
<<: *sign_template
variables:
SERVICE: media-engine
needs: [build:media-engine]
sign:agents:
<<: *sign_template
variables:
SERVICE: agents
needs: [build:agents]
# ── Notify Ingress Box ────────────────────────────────────────
notify:ingress-box:
stage: notify
image: alpine:latest
before_script:
- apk add --no-cache curl openssh-client
script:
# Trigger the poll_and_transfer.sh on the ingress box via SSH
# INGRESS_BOX_IP and SSH key set in GitLab CI/CD variables
- |
ssh -i "${INGRESS_SSH_KEY_FILE}" \
-o StrictHostKeyChecking=no \
ubuntu@${INGRESS_BOX_IP} \
"sudo systemctl start velocity-ingress-poll.service"
needs:
- sign:core
- sign:webos
- sign:media-engine
- sign:agents
rules:
- if: '$CI_COMMIT_BRANCH == "main"'

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# ============================================================
# Velocity-OS — ECR Registry Provisioner + Image Push Script
# Assumes: aws cli v2, docker, cosign installed on build host
# Run from the Velocity-OS repo root in CI or locally.
# ============================================================
set -euo pipefail
# ── Configuration ────────────────────────────────────────────
AWS_REGION="${AWS_REGION:-ap-south-1}"
AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:?Must set AWS_ACCOUNT_ID}"
ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
REGISTRY_PREFIX="velocity-os"
# Image tags from git (deterministic, immutable)
GIT_SHA=$(git rev-parse --short HEAD)
GIT_TAG=$(git tag --points-at HEAD | head -n1 || echo "")
IMAGE_TAG="${GIT_TAG:-$GIT_SHA}"
SERVICES=("core" "webos" "media-engine" "agents")
# ── Step 1: Provision ECR repositories (idempotent) ──────────
echo "=== Provisioning ECR repositories ==="
for svc in "${SERVICES[@]}"; do
REPO_NAME="${REGISTRY_PREFIX}/${svc}"
echo " Ensuring repo: ${REPO_NAME}"
aws ecr describe-repositories \
--repository-names "${REPO_NAME}" \
--region "${AWS_REGION}" \
--no-cli-pager \
> /dev/null 2>&1 || \
aws ecr create-repository \
--repository-name "${REPO_NAME}" \
--region "${AWS_REGION}" \
--image-scanning-configuration scanOnPush=true \
--image-tag-mutability IMMUTABLE \
--encryption-configuration encryptionType=AES256 \
--no-cli-pager
done
# ── Step 2: ECR Login ─────────────────────────────────────────
echo "=== Authenticating to ECR ==="
aws ecr get-login-password --region "${AWS_REGION}" | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
# ── Step 3: Build + Push + Sign each image ───────────────────
echo "=== Building, pushing, and signing images ==="
for svc in "${SERVICES[@]}"; do
LOCAL_IMAGE="velocity-os/${svc}:${IMAGE_TAG}"
REMOTE_IMAGE="${ECR_REGISTRY}/${REGISTRY_PREFIX}/${svc}:${IMAGE_TAG}"
REMOTE_LATEST="${ECR_REGISTRY}/${REGISTRY_PREFIX}/${svc}:latest"
echo ""
echo "--- Service: ${svc} ---"
# Build
echo " Building ${LOCAL_IMAGE}..."
docker build \
--cache-from "${REMOTE_LATEST}" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--label "git.sha=${GIT_SHA}" \
--label "git.tag=${GIT_TAG}" \
--label "build.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t "${LOCAL_IMAGE}" \
-t "${REMOTE_IMAGE}" \
-t "${REMOTE_LATEST}" \
"./${svc}"
# Push (sha-tagged first for immutability, then latest)
echo " Pushing ${REMOTE_IMAGE}..."
docker push "${REMOTE_IMAGE}"
docker push "${REMOTE_LATEST}"
# Sign with cosign (Sigstore keyless or KMS key)
echo " Signing ${REMOTE_IMAGE} with cosign..."
IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "${REMOTE_IMAGE}" || \
aws ecr describe-images \
--repository-name "${REGISTRY_PREFIX}/${svc}" \
--image-ids imageTag="${IMAGE_TAG}" \
--region "${AWS_REGION}" \
--query 'imageDetails[0].imageDigest' \
--output text)
cosign sign --yes "${ECR_REGISTRY}/${REGISTRY_PREFIX}/${svc}@${IMAGE_DIGEST}"
echo "${svc} pushed and signed: ${REMOTE_IMAGE}"
done
echo ""
echo "=== All images built, pushed, and signed. ==="
echo "ECR Registry: ${ECR_REGISTRY}"
echo "Image tag: ${IMAGE_TAG}"

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# ============================================================
# Velocity-OS — Ingress Box: Air-Gap Transfer Agent
# Runs on a LAN-connected node (Raspberry Pi / VM).
# Polls ECR every 5 minutes for new signed images.
# Verifies cosign signature. Transfers to air-gapped workstation.
# Triggers K3s rolling restart on new image.
#
# Install as systemd service:
# sudo cp poll_and_transfer.service /etc/systemd/system/
# sudo systemctl enable --now poll_and_transfer
# ============================================================
set -euo pipefail
# ── Configuration ────────────────────────────────────────────
AWS_REGION="${AWS_REGION:-ap-south-1}"
AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:?Must set AWS_ACCOUNT_ID}"
ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
REGISTRY_PREFIX="velocity-os"
SERVICES=("core" "webos" "media-engine" "agents")
# Air-gapped workstation (LAN only — no internet)
WORKSTATION_IP="${WORKSTATION_IP:-192.168.1.100}"
WORKSTATION_USER="${WORKSTATION_USER:-ubuntu}"
WORKSTATION_SSH_KEY="${WORKSTATION_SSH_KEY:-/home/ingress/.ssh/velocity_workstation_ed25519}"
# State file: tracks last-transferred digest per service
STATE_DIR="/var/lib/velocity-ingress"
mkdir -p "${STATE_DIR}"
# Temp dir for image tarballs
TRANSFER_DIR="/tmp/velocity-transfer"
mkdir -p "${TRANSFER_DIR}"
# ── Functions ─────────────────────────────────────────────────
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
get_latest_digest() {
local repo="${REGISTRY_PREFIX}/$1"
aws ecr describe-images \
--repository-name "${repo}" \
--image-ids imageTag=latest \
--region "${AWS_REGION}" \
--query 'imageDetails[0].imageDigest' \
--output text 2>/dev/null || echo "NONE"
}
transfer_image() {
local svc="$1"
local digest="$2"
local full_image="${ECR_REGISTRY}/${REGISTRY_PREFIX}/${svc}@${digest}"
local tar_file="${TRANSFER_DIR}/${svc}.tar"
log " [${svc}] Pulling from ECR..."
docker pull "${ECR_REGISTRY}/${REGISTRY_PREFIX}/${svc}:latest"
log " [${svc}] Verifying cosign signature..."
cosign verify \
--certificate-identity-regexp ".*" \
--certificate-oidc-issuer-regexp ".*" \
"${full_image}" || {
log " [${svc}] ERROR: Signature verification FAILED. Refusing transfer."
return 1
}
log " [${svc}] Saving image to tarball..."
docker save "${ECR_REGISTRY}/${REGISTRY_PREFIX}/${svc}:latest" \
-o "${tar_file}"
log " [${svc}] Transferring to workstation via SCP..."
scp -i "${WORKSTATION_SSH_KEY}" \
-o StrictHostKeyChecking=yes \
"${tar_file}" \
"${WORKSTATION_USER}@${WORKSTATION_IP}:/tmp/${svc}.tar"
log " [${svc}] Importing into K3s containerd + rolling restart..."
ssh -i "${WORKSTATION_SSH_KEY}" \
-o StrictHostKeyChecking=yes \
"${WORKSTATION_USER}@${WORKSTATION_IP}" \
"sudo k3s ctr images import /tmp/${svc}.tar && \
sudo kubectl rollout restart deployment/${svc} -n velocity-os && \
rm /tmp/${svc}.tar"
# Record transferred digest
echo "${digest}" > "${STATE_DIR}/${svc}.last_digest"
log " [${svc}] ✓ Transfer complete. Digest: ${digest}"
rm -f "${tar_file}"
}
# ── Main poll loop ────────────────────────────────────────────
log "=== Velocity-OS Ingress Box polling ECR ==="
# Login to ECR (token expires every 12h; cron re-runs this)
aws ecr get-login-password --region "${AWS_REGION}" | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
for svc in "${SERVICES[@]}"; do
log "[${svc}] Checking for updates..."
CURRENT_DIGEST=$(get_latest_digest "${svc}")
LAST_DIGEST=$(cat "${STATE_DIR}/${svc}.last_digest" 2>/dev/null || echo "NONE")
if [[ "${CURRENT_DIGEST}" == "NONE" ]]; then
log " [${svc}] No image found in ECR. Skipping."
continue
fi
if [[ "${CURRENT_DIGEST}" == "${LAST_DIGEST}" ]]; then
log " [${svc}] Up to date. No transfer needed."
continue
fi
log " [${svc}] New digest detected: ${CURRENT_DIGEST}"
transfer_image "${svc}" "${CURRENT_DIGEST}" || \
log " [${svc}] Transfer FAILED. Will retry next cycle."
done
log "=== Poll cycle complete ==="

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import boto3
def load_env_file(path: Path) -> dict[str, str]:
data: dict[str, str] = {}
if not path.exists():
return data
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
data[key.strip()] = value.strip()
return data
def env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def resolve_target_instance(ec2) -> dict | None:
explicit_instance_id = env("COMFY_INSTANCE_ID")
if explicit_instance_id:
reservations = ec2.describe_instances(InstanceIds=[explicit_instance_id])["Reservations"]
for reservation in reservations:
for instance in reservation["Instances"]:
if instance["State"]["Name"] == "running":
return instance
return None
tag_key = env("COMFY_INSTANCE_TAG_KEY", "DesineuronRole")
tag_value = env("COMFY_INSTANCE_TAG_VALUE", "comfyui")
filters = [
{"Name": "instance-state-name", "Values": ["running"]},
{"Name": f"tag:{tag_key}", "Values": [tag_value]},
]
reservations = ec2.describe_instances(Filters=filters)["Reservations"]
instances = [instance for reservation in reservations for instance in reservation["Instances"]]
if not instances:
return None
instances.sort(key=lambda row: row["LaunchTime"], reverse=True)
return instances[0]
def upsert_route(hostname: str, private_ip: str, port: int) -> subprocess.CompletedProcess[str]:
ingress_host = env("INGRESS_SSH_HOST")
ingress_user = env("INGRESS_SSH_USER", "ec2-user")
ingress_port = env("INGRESS_SSH_PORT", "22")
ingress_key = env("INGRESS_SSH_KEY_PATH")
helper = env("INGRESS_ROUTE_HELPER", "/usr/local/bin/manage_desineuron_routes.py")
payload = json.dumps(
{
"hostname": hostname,
"scheme": "http",
"target_host": private_ip,
"target_port": port,
}
)
command = (
f"sudo {helper} upsert '{payload}'"
" && sudo caddy validate --config /etc/caddy/Caddyfile"
" && sudo systemctl reload caddy"
)
return subprocess.run(
[
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-i",
ingress_key,
"-p",
ingress_port,
f"{ingress_user}@{ingress_host}",
command,
],
capture_output=True,
text=True,
check=False,
)
def main() -> int:
ops_env = load_env_file(Path(env("OPS_ENV_FILE", "/opt/desineuron-ops-control-plane/.env")))
for key in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"]:
if key not in os.environ and key in ops_env:
os.environ[key] = ops_env[key]
os.environ.setdefault("AWS_DEFAULT_REGION", ops_env.get("OPS_DEFAULT_REGION", "us-east-1"))
os.environ.setdefault("INGRESS_SSH_HOST", ops_env.get("OPS_INGRESS_SSH_HOST", ""))
os.environ.setdefault("INGRESS_SSH_USER", ops_env.get("OPS_INGRESS_SSH_USER", "ec2-user"))
os.environ.setdefault("INGRESS_SSH_PORT", ops_env.get("OPS_INGRESS_SSH_PORT", "22"))
normalized_key_path = ops_env.get("OPS_SSH_KEY_PATH", "/opt/desineuron-ops-control-plane/state/desineuron-l4-node.pem")
if normalized_key_path.startswith("/app/state/"):
normalized_key_path = normalized_key_path.replace("/app/state/", "/opt/desineuron-ops-control-plane/state/")
os.environ.setdefault("INGRESS_SSH_KEY_PATH", normalized_key_path)
os.environ.setdefault("INGRESS_ROUTE_HELPER", ops_env.get("OPS_INGRESS_ROUTE_HELPER", "/usr/local/bin/manage_desineuron_routes.py"))
region = os.environ["AWS_DEFAULT_REGION"]
hostname = env("COMFY_ROUTE_HOSTNAME", "comfy.desineuron.in")
port = int(env("COMFY_ROUTE_PORT", "8188"))
state_file = Path(env("COMFY_ROUTE_STATE_FILE", "/var/lib/desineuron-comfy-route-sync/current_target.txt"))
ec2 = boto3.client("ec2", region_name=region)
instance = resolve_target_instance(ec2)
if not instance:
print("No running comfyui target instance found", file=sys.stderr)
return 1
private_ip = instance.get("PrivateIpAddress")
if not private_ip:
print("Target instance has no private IP", file=sys.stderr)
return 1
current = state_file.read_text(encoding="utf-8").strip() if state_file.exists() else ""
if current == private_ip:
print(json.dumps({"status": "noop", "hostname": hostname, "target_host": private_ip}))
return 0
result = upsert_route(hostname, private_ip, port)
if result.returncode != 0:
print(result.stdout)
print(result.stderr, file=sys.stderr)
return result.returncode
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text(private_ip, encoding="utf-8")
print(json.dumps({"status": "updated", "hostname": hostname, "target_host": private_ip}))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import boto3
def load_env_file(path: Path) -> dict[str, str]:
data: dict[str, str] = {}
if not path.exists():
return data
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
data[key.strip()] = value.strip()
return data
def env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def resolve_target_instance(ec2) -> dict | None:
explicit_instance_id = env("LLM_INSTANCE_ID")
if explicit_instance_id:
reservations = ec2.describe_instances(InstanceIds=[explicit_instance_id])["Reservations"]
for reservation in reservations:
for instance in reservation["Instances"]:
if instance["State"]["Name"] == "running":
return instance
return None
# We assume the LLM runtime runs on the same GPU instance as comfyui initially
tag_key = env("LLM_INSTANCE_TAG_KEY", "DesineuronRole")
tag_value = env("LLM_INSTANCE_TAG_VALUE", "comfyui")
filters = [
{"Name": "instance-state-name", "Values": ["running"]},
{"Name": f"tag:{tag_key}", "Values": [tag_value]},
]
reservations = ec2.describe_instances(Filters=filters)["Reservations"]
instances = [instance for reservation in reservations for instance in reservation["Instances"]]
if not instances:
return None
instances.sort(key=lambda row: row["LaunchTime"], reverse=True)
return instances[0]
def upsert_route(hostname: str, private_ip: str, port: int) -> subprocess.CompletedProcess[str]:
ingress_host = env("INGRESS_SSH_HOST")
ingress_user = env("INGRESS_SSH_USER", "ec2-user")
ingress_port = env("INGRESS_SSH_PORT", "22")
ingress_key = env("INGRESS_SSH_KEY_PATH")
helper = env("INGRESS_ROUTE_HELPER", "/usr/local/bin/manage_desineuron_routes.py")
payload = json.dumps(
{
"hostname": hostname,
"scheme": "http",
"target_host": private_ip,
"target_port": port,
}
)
command = (
f"sudo {helper} upsert '{payload}'"
" && sudo caddy validate --config /etc/caddy/Caddyfile"
" && sudo systemctl reload caddy"
)
return subprocess.run(
[
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-i",
ingress_key,
"-p",
ingress_port,
f"{ingress_user}@{ingress_host}",
command,
],
capture_output=True,
text=True,
check=False,
)
def main() -> int:
ops_env = load_env_file(Path(env("OPS_ENV_FILE", "/opt/desineuron-ops-control-plane/.env")))
for key in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"]:
if key not in os.environ and key in ops_env:
os.environ[key] = ops_env[key]
os.environ.setdefault("AWS_DEFAULT_REGION", ops_env.get("OPS_DEFAULT_REGION", "us-east-1"))
os.environ.setdefault("INGRESS_SSH_HOST", ops_env.get("OPS_INGRESS_SSH_HOST", ""))
os.environ.setdefault("INGRESS_SSH_USER", ops_env.get("OPS_INGRESS_SSH_USER", "ec2-user"))
os.environ.setdefault("INGRESS_SSH_PORT", ops_env.get("OPS_INGRESS_SSH_PORT", "22"))
normalized_key_path = ops_env.get("OPS_SSH_KEY_PATH", "/opt/desineuron-ops-control-plane/state/desineuron-l4-node.pem")
if normalized_key_path.startswith("/app/state/"):
normalized_key_path = normalized_key_path.replace("/app/state/", "/opt/desineuron-ops-control-plane/state/")
os.environ.setdefault("INGRESS_SSH_KEY_PATH", normalized_key_path)
os.environ.setdefault("INGRESS_ROUTE_HELPER", ops_env.get("OPS_INGRESS_ROUTE_HELPER", "/usr/local/bin/manage_desineuron_routes.py"))
region = os.environ["AWS_DEFAULT_REGION"]
hostname = env("LLM_ROUTE_HOSTNAME", "llm.desineuron.in")
port = int(env("LLM_ROUTE_PORT", "11434"))
state_file = Path(env("LLM_ROUTE_STATE_FILE", "/var/lib/desineuron-llm-route-sync/current_target.txt"))
ec2 = boto3.client("ec2", region_name=region)
instance = resolve_target_instance(ec2)
if not instance:
print("No running LLM target instance found", file=sys.stderr)
return 1
private_ip = instance.get("PrivateIpAddress")
if not private_ip:
print("Target instance has no private IP", file=sys.stderr)
return 1
desired_state = f"{private_ip}:{port}"
current = state_file.read_text(encoding="utf-8").strip() if state_file.exists() else ""
if current == desired_state:
print(
json.dumps(
{"status": "noop", "hostname": hostname, "target_host": private_ip, "target_port": port}
)
)
return 0
result = upsert_route(hostname, private_ip, port)
if result.returncode != 0:
print(result.stdout)
print(result.stderr, file=sys.stderr)
return result.returncode
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text(desired_state, encoding="utf-8")
print(
json.dumps(
{"status": "updated", "hostname": hostname, "target_host": private_ip, "target_port": port}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import boto3
def load_env_file(path: Path) -> dict[str, str]:
data: dict[str, str] = {}
if not path.exists():
return data
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
data[key.strip()] = value.strip()
return data
def env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def resolve_target_instance(ec2) -> dict | None:
explicit_instance_id = env("VELOCITY_INSTANCE_ID")
if explicit_instance_id:
reservations = ec2.describe_instances(InstanceIds=[explicit_instance_id])["Reservations"]
for reservation in reservations:
for instance in reservation["Instances"]:
if instance["State"]["Name"] == "running":
return instance
return None
tag_key = env("VELOCITY_INSTANCE_TAG_KEY", "DesineuronRole")
tag_value = env("VELOCITY_INSTANCE_TAG_VALUE", "velocity-backend")
filters = [
{"Name": "instance-state-name", "Values": ["running"]},
{"Name": f"tag:{tag_key}", "Values": [tag_value]},
]
reservations = ec2.describe_instances(Filters=filters)["Reservations"]
instances = [instance for reservation in reservations for instance in reservation["Instances"]]
if not instances:
return None
instances.sort(key=lambda row: row["LaunchTime"], reverse=True)
return instances[0]
def upsert_route(hostname: str, private_ip: str, port: int) -> subprocess.CompletedProcess[str]:
ingress_host = env("INGRESS_SSH_HOST")
ingress_user = env("INGRESS_SSH_USER", "ec2-user")
ingress_port = env("INGRESS_SSH_PORT", "22")
ingress_key = env("INGRESS_SSH_KEY_PATH")
helper = env("INGRESS_ROUTE_HELPER", "/usr/local/bin/manage_desineuron_routes.py")
payload = json.dumps(
{
"hostname": hostname,
"scheme": "http",
"target_host": private_ip,
"target_port": port,
}
)
command = (
f"sudo {helper} upsert '{payload}'"
" && sudo caddy validate --config /etc/caddy/Caddyfile"
" && sudo systemctl reload caddy"
)
return subprocess.run(
[
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-i",
ingress_key,
"-p",
ingress_port,
f"{ingress_user}@{ingress_host}",
command,
],
capture_output=True,
text=True,
check=False,
)
def main() -> int:
ops_env = load_env_file(Path(env("OPS_ENV_FILE", "/opt/desineuron-ops-control-plane/.env")))
for key in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"]:
if key not in os.environ and key in ops_env:
os.environ[key] = ops_env[key]
os.environ.setdefault("AWS_DEFAULT_REGION", ops_env.get("OPS_DEFAULT_REGION", "us-east-1"))
os.environ.setdefault("INGRESS_SSH_HOST", ops_env.get("OPS_INGRESS_SSH_HOST", ""))
os.environ.setdefault("INGRESS_SSH_USER", ops_env.get("OPS_INGRESS_SSH_USER", "ec2-user"))
os.environ.setdefault("INGRESS_SSH_PORT", ops_env.get("OPS_INGRESS_SSH_PORT", "22"))
normalized_key_path = ops_env.get("OPS_SSH_KEY_PATH", "/opt/desineuron-ops-control-plane/state/desineuron-l4-node.pem")
if normalized_key_path.startswith("/app/state/"):
normalized_key_path = normalized_key_path.replace("/app/state/", "/opt/desineuron-ops-control-plane/state/")
os.environ.setdefault("INGRESS_SSH_KEY_PATH", normalized_key_path)
os.environ.setdefault("INGRESS_ROUTE_HELPER", ops_env.get("OPS_INGRESS_ROUTE_HELPER", "/usr/local/bin/manage_desineuron_routes.py"))
region = os.environ["AWS_DEFAULT_REGION"]
hostname = env("VELOCITY_ROUTE_HOSTNAME", "api.desineuron.in")
port = int(env("VELOCITY_ROUTE_PORT", "8001"))
state_file = Path(env("VELOCITY_ROUTE_STATE_FILE", "/var/lib/desineuron-velocity-route-sync/current_target.txt"))
ec2 = boto3.client("ec2", region_name=region)
instance = resolve_target_instance(ec2)
if not instance:
print("No running velocity-backend target instance found", file=sys.stderr)
return 1
private_ip = instance.get("PrivateIpAddress")
if not private_ip:
print("Target instance has no private IP", file=sys.stderr)
return 1
current = state_file.read_text(encoding="utf-8").strip() if state_file.exists() else ""
if current == private_ip:
print(json.dumps({"status": "noop", "hostname": hostname, "target_host": private_ip}))
return 0
result = upsert_route(hostname, private_ip, port)
if result.returncode != 0:
print(result.stdout)
print(result.stderr, file=sys.stderr)
return result.returncode
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text(private_ip, encoding="utf-8")
print(json.dumps({"status": "updated", "hostname": hostname, "target_host": private_ip}))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,342 @@
# ============================================================
# Velocity-OS — K3s Deployments
# All services in velocity-os namespace.
# GPU: RTX 6000 Blackwell 96GB VRAM — MIG partitioned.
# MIG slice 0 (48GB): SGLang LLM inference (core-api)
# MIG slice 1 (48GB): ComfyUI media generation (media-engine)
# ============================================================
---
# ── PostgreSQL (StatefulSet for stable identity) ─────────────
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: velocity-os
labels:
app: postgres
tier: database
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
tier: database
spec:
containers:
- name: postgres
image: ${ECR_REGISTRY}/postgres:15-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: velocity-secrets
key: POSTGRES_DB
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: velocity-secrets
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: velocity-secrets
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command: [pg_isready, -U, velocity]
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command: [pg_isready, -U, velocity]
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: pvc-postgres-data
---
# ── Redis (session cache, future queue) ──────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: velocity-os
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: ${ECR_REGISTRY}/redis:7-alpine
ports:
- containerPort: 6379
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "250m"
args: ["--maxmemory", "400mb", "--maxmemory-policy", "allkeys-lru"]
---
# ── Core API (FastAPI) ────────────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: core-api
namespace: velocity-os
labels:
app: core-api
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: core-api
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: core-api
tier: backend
spec:
# MIG slice 0: SGLang LLM inference
# The core-api pod requests MIG slice via resource limit
runtimeClassName: nvidia
containers:
- name: core-api
image: ${ECR_REGISTRY}/velocity-os/core:latest
ports:
- containerPort: 8443
envFrom:
- secretRef:
name: velocity-secrets
- configMapRef:
name: velocity-config
resources:
requests:
memory: "1Gi"
cpu: "500m"
# RTX 6000 Blackwell MIG 3g.48gb (SGLang slice)
nvidia.com/mig-3g.48gb: "1"
limits:
memory: "2Gi"
cpu: "1000m"
nvidia.com/mig-3g.48gb: "1"
volumeMounts:
- name: asset-store
mountPath: /opt/assets
- name: model-cache
mountPath: /opt/models
readOnly: true
livenessProbe:
httpGet:
path: /health
port: 8443
initialDelaySeconds: 20
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8443
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: asset-store
persistentVolumeClaim:
claimName: pvc-asset-store
- name: model-cache
persistentVolumeClaim:
claimName: pvc-model-cache
---
# ── WebOS (Nginx static + React) ─────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: webos
namespace: velocity-os
labels:
app: webos
tier: frontend
spec:
replicas: 2
selector:
matchLabels:
app: webos
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: webos
tier: frontend
spec:
containers:
- name: webos
image: ${ECR_REGISTRY}/velocity-os/webos:latest
ports:
- containerPort: 80
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "250m"
livenessProbe:
httpGet:
path: /health.txt
port: 80
initialDelaySeconds: 5
periodSeconds: 10
---
# ── Media Engine (Dream Weaver Gateway) ──────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: media-engine
namespace: velocity-os
labels:
app: media-engine
tier: ai
spec:
replicas: 1
selector:
matchLabels:
app: media-engine
template:
metadata:
labels:
app: media-engine
tier: ai
spec:
# MIG slice 1: ComfyUI media generation
runtimeClassName: nvidia
containers:
- name: media-engine
image: ${ECR_REGISTRY}/velocity-os/media-engine:latest
ports:
- containerPort: 8290
envFrom:
- secretRef:
name: velocity-secrets
- configMapRef:
name: velocity-config
resources:
requests:
memory: "2Gi"
cpu: "1000m"
# RTX 6000 Blackwell MIG 3g.48gb (ComfyUI slice)
nvidia.com/mig-3g.48gb: "1"
limits:
memory: "4Gi"
cpu: "2000m"
nvidia.com/mig-3g.48gb: "1"
volumeMounts:
- name: model-cache
mountPath: /opt/models
readOnly: true
- name: asset-store
mountPath: /opt/assets
livenessProbe:
httpGet:
path: /health
port: 8290
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: pvc-model-cache
- name: asset-store
persistentVolumeClaim:
claimName: pvc-asset-store
---
# ── DB Init Job (runs once: schema apply + seed) ─────────────
apiVersion: batch/v1
kind: Job
metadata:
name: db-init
namespace: velocity-os
labels:
app: db-init
spec:
# Never auto-restart; operator re-runs manually if needed
backoffLimit: 0
template:
metadata:
labels:
app: db-init
spec:
restartPolicy: Never
initContainers:
# Wait for postgres to be ready before running init
- name: wait-for-postgres
image: ${ECR_REGISTRY}/postgres:15-alpine
command: [sh, -c, "until pg_isready -h postgres -U $(POSTGRES_USER); do echo waiting...; sleep 2; done"]
envFrom:
- secretRef:
name: velocity-secrets
containers:
- name: db-init
image: ${ECR_REGISTRY}/velocity-os/core:latest
command:
- sh
- -c
- |
echo "=== Applying schemas ==="
psql $DATABASE_URL -f /app/db/schema.sql
psql $DATABASE_URL -f /app/db/schema_addendum.sql
psql $DATABASE_URL -f /app/db/schema_comms.sql
psql $DATABASE_URL -f /app/db/schema_crm_canonical.sql
psql $DATABASE_URL -f /app/oracle/schema_oracle.sql
psql $DATABASE_URL -f /app/oracle/schema_extension_v2.sql
echo "=== Seeding synthetic CRM v2 ==="
python /app/scripts/seed_synthetic_crm.py
echo "=== DB init complete ==="
envFrom:
- secretRef:
name: velocity-secrets
- configMapRef:
name: velocity-config

View File

@@ -0,0 +1,107 @@
# ============================================================
# Velocity-OS — NVIDIA MIG Configuration for K3s
# Target GPU: NVIDIA RTX 6000 Blackwell (96GB VRAM)
#
# MIG Strategy: Partition 96GB into two equal 48GB slices:
# MIG slice 0 (3g.48gb): SGLang LLM inference (core-api)
# MIG slice 1 (3g.48gb): ComfyUI media generation (media-engine)
#
# Result: Concurrent zero-contention GPU execution.
# No operator toggle required.
#
# Prerequisites on workstation:
# - nvidia-driver >= 550
# - CUDA >= 12.4
# - k3s with nvidia-container-toolkit
# - NVIDIA device plugin with MIG support
# ============================================================
# ── Step 1: Enable MIG mode on the GPU ───────────────────────
# Run on workstation (one-time, survives reboot via service):
# sudo nvidia-smi -i 0 --mig-mode=ENABLE
# sudo reboot
# ── Step 2: Create MIG instances ─────────────────────────────
# Run after reboot:
# sudo nvidia-smi mig -cgi "3g.48gb,3g.48gb" -C
# This creates:
# GPU instance 0: 3g.48gb (48GB) → MIG device 0
# GPU instance 1: 3g.48gb (48GB) → MIG device 1
# Verify: nvidia-smi -L
---
# ── K3s: NVIDIA Device Plugin with MIG strategy ──────────────
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: nvidia-device-plugin
namespace: kube-system
labels:
app: nvidia-device-plugin
spec:
selector:
matchLabels:
app: nvidia-device-plugin
template:
metadata:
labels:
app: nvidia-device-plugin
spec:
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- name: nvidia-device-plugin
image: nvcr.io/nvidia/k8s-device-plugin:v0.15.0
env:
# "mixed" strategy: expose both full GPU and MIG devices
- name: MIG_STRATEGY
value: "mixed"
- name: FAIL_ON_INIT_ERROR
value: "false"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: device-plugin
mountPath: /var/lib/kubelet/device-plugins
volumes:
- name: device-plugin
hostPath:
path: /var/lib/kubelet/device-plugins
---
# ── Node label: MIG-capable workstation ──────────────────────
# Apply once: kubectl label node velocity-workstation nvidia.com/mig.strategy=mixed
# This ensures GPU pods only schedule on the correct node.
apiVersion: v1
kind: ConfigMap
metadata:
name: mig-setup-instructions
namespace: velocity-os
data:
README: |
RTX 6000 Blackwell MIG Setup (run on workstation before deploying):
1. Enable MIG mode:
sudo nvidia-smi -i 0 --mig-mode=ENABLE && sudo reboot
2. Create two 3g.48gb instances (post-reboot):
sudo nvidia-smi mig -cgi "3g.48gb,3g.48gb" -C
3. Label K3s node:
kubectl label node velocity-workstation \
nvidia.com/mig.strategy=mixed \
kubernetes.io/hostname=velocity-workstation
4. Verify resource availability:
kubectl describe node velocity-workstation | grep nvidia
Expected output:
nvidia.com/mig-3g.48gb: 2 (2 slices available)
Deployment assignments:
core-api → nvidia.com/mig-3g.48gb: 1 (SGLang, slice 0)
media-engine → nvidia.com/mig-3g.48gb: 1 (ComfyUI, slice 1)

View File

@@ -0,0 +1,64 @@
{
email admin@desineuron.in
log {
output file /var/log/caddy/admin.log
format json
}
}
office.desineuron.in, git.desineuron.in, cloud.desineuron.in, projects.desineuron.in, talk.desineuron.in, vpn.desineuron.in {
tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
log {
output file /var/log/caddy/access.log
format json
}
reverse_proxy https://127.0.0.1:8443 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
transport http {
tls_insecure_skip_verify
}
}
}
velocity.desineuron.in {
log {
output file /var/log/caddy/access.log
format json
}
import /etc/caddy/managed/llm_upstream.caddy_inc
reverse_proxy https://127.0.0.1:8443 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
transport http {
tls_insecure_skip_verify
}
}
}
ops.desineuron.in {
log {
output file /var/log/caddy/access.log
format json
}
reverse_proxy https://127.0.0.1:8443 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
transport http {
tls_insecure_skip_verify
}
}
}
import /etc/caddy/managed/*.caddy

View File

@@ -0,0 +1,158 @@
# ============================================================
# Velocity-OS — K3s Traefik Ingress
# Domain: velocity.local | TLS: self-signed via cert-manager
# ============================================================
# ── cert-manager ClusterIssuer (self-signed for velocity.local) ──
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: velocity-selfsigned-issuer
spec:
selfSigned: {}
---
# Self-signed CA Certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: velocity-local-ca
namespace: velocity-infra
spec:
isCA: true
commonName: velocity-local-ca
secretName: velocity-local-ca-secret
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: velocity-selfsigned-issuer
kind: ClusterIssuer
group: cert-manager.io
---
# CA-backed ClusterIssuer for velocity.local
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: velocity-ca-issuer
spec:
ca:
secretName: velocity-local-ca-secret
---
# TLS Certificate for velocity.local
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: velocity-local-tls
namespace: velocity-os
spec:
secretName: velocity-local-tls-secret
duration: 8760h # 1 year
renewBefore: 720h # renew 30 days before expiry
subject:
organizations: [Desineuron]
commonName: velocity.local
dnsNames:
- velocity.local
- "*.velocity.local"
issuerRef:
name: velocity-ca-issuer
kind: ClusterIssuer
group: cert-manager.io
---
# ── Main Ingress ─────────────────────────────────────────────
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: velocity-os-ingress
namespace: velocity-os
annotations:
# Traefik (K3s built-in)
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
# WebSocket support (Sentinel, Oracle canvas, Catalyst)
traefik.ingress.kubernetes.io/router.middlewares: velocity-os-ws-headers@kubernetescrd
spec:
tls:
- hosts:
- velocity.local
secretName: velocity-local-tls-secret
rules:
- host: velocity.local
http:
paths:
# API (FastAPI backend)
- path: /api
pathType: Prefix
backend:
service:
name: core-api
port:
number: 8443
# WebSockets (must route before generic /api catch)
- path: /ws
pathType: Prefix
backend:
service:
name: core-api
port:
number: 8443
# Dream Weaver gateway
- path: /dream-weaver
pathType: Prefix
backend:
service:
name: media-engine
port:
number: 8290
# Vault public links (no auth)
- path: /vault
pathType: Prefix
backend:
service:
name: core-api
port:
number: 8443
# WebOS (React SPA — catch-all last)
- path: /
pathType: Prefix
backend:
service:
name: webos
port:
number: 80
---
# ── Traefik Middleware: WebSocket upgrade headers ─────────────
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: ws-headers
namespace: velocity-os
spec:
headers:
customRequestHeaders:
Connection: "Upgrade"
Upgrade: "websocket"
---
# ── Traefik Middleware: Security headers ─────────────────────
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: velocity-os
spec:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
forceSTSHeader: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: strict-origin-when-cross-origin
frameDeny: true

View File

@@ -0,0 +1,27 @@
# ============================================================
# Velocity-OS — K3s Namespaces
# ============================================================
---
apiVersion: v1
kind: Namespace
metadata:
name: velocity-os
labels:
app.kubernetes.io/managed-by: velocity-os
environment: production
---
apiVersion: v1
kind: Namespace
metadata:
name: velocity-agents
labels:
app.kubernetes.io/managed-by: velocity-os
environment: production
---
apiVersion: v1
kind: Namespace
metadata:
name: velocity-infra
labels:
app.kubernetes.io/managed-by: velocity-os
environment: production

View File

@@ -0,0 +1,82 @@
# ============================================================
# Velocity-OS — K3s Services
# ClusterIP for internal, none for headless StatefulSet
# ============================================================
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: velocity-os
labels:
app: postgres
spec:
clusterIP: None # Headless for StatefulSet stable DNS
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: velocity-os
labels:
app: redis
spec:
type: ClusterIP
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: core-api
namespace: velocity-os
labels:
app: core-api
spec:
type: ClusterIP
selector:
app: core-api
ports:
- name: http
port: 8443
targetPort: 8443
---
apiVersion: v1
kind: Service
metadata:
name: webos
namespace: velocity-os
labels:
app: webos
spec:
type: ClusterIP
selector:
app: webos
ports:
- name: http
port: 80
targetPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: media-engine
namespace: velocity-os
labels:
app: media-engine
spec:
type: ClusterIP
selector:
app: media-engine
ports:
- name: http
port: 8290
targetPort: 8290

View File

@@ -0,0 +1,132 @@
# ============================================================
# Velocity-OS — K3s StorageClasses + PersistentVolumes
# Target: RTX 6000 Blackwell workstation NVMe drive
# ============================================================
---
# StorageClass: local-nvme (no provisioner — manually bound PVs)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-nvme
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
---
# PV: PostgreSQL data (50Gi on NVMe)
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-postgres-data
labels:
app: postgres
spec:
capacity:
storage: 50Gi
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: local-nvme
local:
path: /opt/dlami/nvme/data/postgres
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [velocity-workstation]
---
# PV: AI model cache (500Gi — Wan 2.2, Qwen-Image, Qwen3.6)
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-model-cache
labels:
app: model-cache
spec:
capacity:
storage: 500Gi
accessModes: [ReadOnlyMany]
persistentVolumeReclaimPolicy: Retain
storageClassName: local-nvme
local:
path: /opt/dlami/nvme/models
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [velocity-workstation]
---
# PV: Generated asset store (200Gi)
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-asset-store
labels:
app: asset-store
spec:
capacity:
storage: 200Gi
accessModes: [ReadWriteMany]
persistentVolumeReclaimPolicy: Retain
storageClassName: local-nvme
local:
path: /opt/dlami/nvme/assets
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [velocity-workstation]
---
# PVCs
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-postgres-data
namespace: velocity-os
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-nvme
resources:
requests:
storage: 50Gi
selector:
matchLabels:
app: postgres
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-model-cache
namespace: velocity-os
spec:
accessModes: [ReadOnlyMany]
storageClassName: local-nvme
resources:
requests:
storage: 500Gi
selector:
matchLabels:
app: model-cache
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-asset-store
namespace: velocity-os
spec:
accessModes: [ReadWriteMany]
storageClassName: local-nvme
resources:
requests:
storage: 200Gi
selector:
matchLabels:
app: asset-store

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import boto3
DEFAULT_CHECKPOINTS = {
"realvisxlV50_v50LightningBakedvae.safetensors": (
"s3://project-velocity/models/realvisxlV50_v50LightningBakedvae.safetensors"
),
}
def load_env_file(path: Path) -> dict[str, str]:
data: dict[str, str] = {}
if not path.exists():
return data
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
data[key.strip()] = value.strip()
return data
def env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def resolve_target_instance(ec2) -> dict | None:
explicit_instance_id = env("COMFY_INSTANCE_ID")
if explicit_instance_id:
reservations = ec2.describe_instances(InstanceIds=[explicit_instance_id])["Reservations"]
else:
tag_key = env("COMFY_INSTANCE_TAG_KEY", "DesineuronRole")
tag_value = env("COMFY_INSTANCE_TAG_VALUE", "comfyui")
reservations = ec2.describe_instances(
Filters=[
{"Name": "instance-state-name", "Values": ["running"]},
{"Name": f"tag:{tag_key}", "Values": [tag_value]},
]
)["Reservations"]
instances = [
instance
for reservation in reservations
for instance in reservation["Instances"]
if instance["State"]["Name"] == "running"
]
if not instances:
return None
instances.sort(key=lambda row: row["LaunchTime"], reverse=True)
return instances[0]
def parse_checkpoints() -> dict[str, str]:
raw = env("COMFY_CHECKPOINTS_JSON")
if not raw:
return dict(DEFAULT_CHECKPOINTS)
parsed = json.loads(raw)
if not isinstance(parsed, dict):
raise ValueError("COMFY_CHECKPOINTS_JSON must be a JSON object of filename to source URI")
return {str(name): str(source) for name, source in parsed.items()}
def remote_hydration_script(checkpoints: dict[str, str]) -> str:
payload = json.dumps(checkpoints)
return f"""#!/usr/bin/env bash
set -euo pipefail
CHECKPOINT_DIR="${{COMFY_CHECKPOINT_DIR:-/opt/dlami/nvme/ComfyUI/models/checkpoints}}"
mkdir -p "$CHECKPOINT_DIR"
if ! mountpoint -q /opt/dlami/nvme; then
echo "GPU NVMe mount /opt/dlami/nvme is not mounted" >&2
exit 2
fi
changed=0
python3 - <<'PY' > /tmp/desineuron-comfy-checkpoints.tsv
import json
for name, source in json.loads({payload!r}).items():
print(f"{{name}}\\t{{source}}")
PY
while IFS=$'\\t' read -r filename source; do
target="$CHECKPOINT_DIR/$filename"
if [ ! -s "$target" ]; then
tmp="$target.part"
rm -f "$tmp"
aws s3 cp "$source" "$tmp" --no-progress
mv "$tmp" "$target"
chmod 0644 "$target"
changed=1
fi
done < /tmp/desineuron-comfy-checkpoints.tsv
rm -f /tmp/desineuron-comfy-checkpoints.tsv
if [ "$changed" = "1" ]; then
sudo systemctl restart comfyui
fi
sleep 3
curl -fsS http://127.0.0.1:8188/models/checkpoints
"""
def main() -> int:
ops_env = load_env_file(Path(env("OPS_ENV_FILE", "/opt/desineuron-ops-control-plane/.env")))
for key in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"]:
if key not in os.environ and key in ops_env:
os.environ[key] = ops_env[key]
os.environ.setdefault("AWS_DEFAULT_REGION", ops_env.get("OPS_DEFAULT_REGION", "us-east-1"))
key_path = env(
"GPU_SSH_KEY_PATH",
ops_env.get("OPS_SSH_KEY_PATH", "/opt/desineuron-ops-control-plane/state/desineuron-l4-node.pem"),
)
if key_path.startswith("/app/state/"):
key_path = key_path.replace("/app/state/", "/opt/desineuron-ops-control-plane/state/")
ssh_user = env("GPU_SSH_USER", "ubuntu")
ec2 = boto3.client("ec2", region_name=os.environ["AWS_DEFAULT_REGION"])
instance = resolve_target_instance(ec2)
if not instance:
print("No running ComfyUI GPU instance found", file=sys.stderr)
return 1
target_host = instance.get("PublicIpAddress") or instance.get("PrivateIpAddress")
if not target_host:
print("Target GPU instance has no reachable IP", file=sys.stderr)
return 1
checkpoints = parse_checkpoints()
command = [
"sudo",
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"ConnectTimeout=15",
"-i",
key_path,
f"{ssh_user}@{target_host}",
"bash -s",
]
result = subprocess.run(
command,
input=remote_hydration_script(checkpoints),
text=True,
capture_output=True,
check=False,
)
if result.stdout:
print(result.stdout.strip())
if result.returncode != 0:
if result.stderr:
print(result.stderr.strip(), file=sys.stderr)
return result.returncode
return 0
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More