forked from sagnik/Velocity-OS
Initial commit: Velocity-OS migration
This commit is contained in:
99
.gitignore
vendored
Normal file
99
.gitignore
vendored
Normal 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
83
README.md
Normal 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*
|
||||
0
agents/docker-compose.agents.yml
Normal file
0
agents/docker-compose.agents.yml
Normal file
0
agents/skills/skills/meta_ads_manager.py
Normal file
0
agents/skills/skills/meta_ads_manager.py
Normal file
0
agents/skills/skills/social_publisher.py
Normal file
0
agents/skills/skills/social_publisher.py
Normal file
0
agents/skills/skills/whatsapp_connector.ts
Normal file
0
agents/skills/skills/whatsapp_connector.ts
Normal file
54
core/.env.example
Normal file
54
core/.env.example
Normal 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
64
core/Dockerfile
Normal 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"]
|
||||
529
core/api/api/routes_admin_surface.py
Normal file
529
core/api/api/routes_admin_surface.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
routes_admin_surface.py
|
||||
───────────────────────
|
||||
Admin Control Plane API
|
||||
|
||||
Roles: Only 'admin' or 'superadmin' may access these endpoints.
|
||||
|
||||
Endpoints:
|
||||
GET /admin-surface/health — system health overview
|
||||
GET /admin-surface/queues — queue depth snapshot
|
||||
GET /admin-surface/installs — surface session / install overview
|
||||
POST /admin-surface/actions — submit an admin action
|
||||
GET /admin-surface/actions — list admin action history
|
||||
GET /admin-surface/actions/{id} — get a specific action
|
||||
GET /admin-surface/logs — recent audit event log
|
||||
GET /admin-surface/templates — template catalog summary (admin view)
|
||||
POST /admin-surface/templates/{id}/publish — publish a template
|
||||
POST /admin-surface/templates/{id}/archive — archive a template
|
||||
GET /admin-surface/synthetic-jobs — list synthetic generation jobs
|
||||
POST /admin-surface/synthetic-jobs/{id}/cancel — cancel a synthetic job
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger("velocity.admin_surface")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── RBAC guard ────────────────────────────────────────────────────────────────
|
||||
|
||||
ADMIN_ROLES = {"admin", "superadmin", "ADMIN", "SUPERADMIN"}
|
||||
|
||||
|
||||
def require_admin(user=Depends(get_current_user)):
|
||||
normalized_role = user.role.upper()
|
||||
if normalized_role not in {"ADMIN", "SUPERADMIN"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required.",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pool(request: Request):
|
||||
pool = request.app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_ACTION_TYPES = {
|
||||
"user_create", "user_deactivate", "user_role_change",
|
||||
"tenant_config_update", "inventory_batch_approve", "inventory_batch_reject",
|
||||
"template_publish", "template_archive",
|
||||
"synthetic_job_trigger", "synthetic_job_cancel",
|
||||
"system_health_check", "queue_drain", "debug_event_export",
|
||||
"install_register", "install_deregister",
|
||||
}
|
||||
|
||||
|
||||
class AdminActionRequest(BaseModel):
|
||||
action_type: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
payload: dict = Field(default_factory=dict)
|
||||
idempotency_key: Optional[str] = None
|
||||
|
||||
|
||||
# ── System Health ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/health", summary="System health overview")
|
||||
async def get_health(
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Returns an aggregated health snapshot covering DB pool, queue depths,
|
||||
and basic surface session counts.
|
||||
"""
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
# DB round-trip latency
|
||||
import time
|
||||
t0 = time.monotonic()
|
||||
await conn.fetchval("SELECT 1")
|
||||
db_latency_ms = round((time.monotonic() - t0) * 1000, 2)
|
||||
|
||||
# Pending jobs
|
||||
pending_transcriptions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE status='pending'"
|
||||
)
|
||||
pending_synthetic_jobs = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM oracle_synthetic_generation_jobs WHERE status IN ('pending','running')"
|
||||
)
|
||||
pending_admin_actions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM admin_action_events WHERE status='pending'"
|
||||
)
|
||||
pending_inventory_batches = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE status IN ('pending','validating','processing')"
|
||||
)
|
||||
|
||||
# Active surface sessions (last 30 min)
|
||||
active_sessions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM surface_sessions WHERE last_active_at > NOW() - INTERVAL '30 minutes'"
|
||||
)
|
||||
|
||||
# Surface breakdown
|
||||
surface_breakdown = await conn.fetch(
|
||||
"""
|
||||
SELECT surface_type, COUNT(*) as count
|
||||
FROM surface_sessions
|
||||
WHERE last_active_at > NOW() - INTERVAL '30 minutes'
|
||||
GROUP BY surface_type
|
||||
"""
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"database": {
|
||||
"connected": True,
|
||||
"latency_ms": db_latency_ms,
|
||||
},
|
||||
"queues": {
|
||||
"pending_transcriptions": pending_transcriptions,
|
||||
"pending_synthetic_jobs": pending_synthetic_jobs,
|
||||
"pending_admin_actions": pending_admin_actions,
|
||||
"pending_inventory_batches": pending_inventory_batches,
|
||||
},
|
||||
"active_sessions": {
|
||||
"total": active_sessions,
|
||||
"by_surface": {r["surface_type"]: r["count"] for r in surface_breakdown},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Queue Visibility ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/queues", summary="Queue depth snapshot")
|
||||
async def get_queues(
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
transcription_queue = await conn.fetch(
|
||||
"""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM edge_transcription_jobs
|
||||
GROUP BY status ORDER BY status
|
||||
"""
|
||||
)
|
||||
synthetic_queue = await conn.fetch(
|
||||
"""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM oracle_synthetic_generation_jobs
|
||||
GROUP BY status ORDER BY status
|
||||
"""
|
||||
)
|
||||
inventory_queue = await conn.fetch(
|
||||
"""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM inventory_import_batches
|
||||
GROUP BY status ORDER BY status
|
||||
"""
|
||||
)
|
||||
admin_queue = await conn.fetch(
|
||||
"""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM admin_action_events
|
||||
GROUP BY status ORDER BY status
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"transcription_jobs": {r["status"]: r["count"] for r in transcription_queue},
|
||||
"synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue},
|
||||
"inventory_batches": {r["status"]: r["count"] for r in inventory_queue},
|
||||
"admin_actions": {r["status"]: r["count"] for r in admin_queue},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Install / Surface Overview ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/installs", summary="Surface session and install overview")
|
||||
async def get_installs(
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT surface_type, app_version, COUNT(*) as session_count,
|
||||
MAX(last_active_at) as last_seen
|
||||
FROM surface_sessions
|
||||
GROUP BY surface_type, app_version
|
||||
ORDER BY surface_type, app_version
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"installs": [dict(r) for r in rows],
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Admin Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/actions", status_code=status.HTTP_201_CREATED, summary="Submit an admin action")
|
||||
async def submit_action(
|
||||
request: Request,
|
||||
body: AdminActionRequest,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Submit a bounded admin action. All actions are persisted with full audit trail.
|
||||
Supported action_types are enumerated in VALID_ACTION_TYPES.
|
||||
|
||||
Actions are not auto-executed — they transition to 'pending' and must be
|
||||
processed by the appropriate backend job or confirmed by a second admin.
|
||||
(This prevents destructive mass-actions from running unreviewed.)
|
||||
"""
|
||||
if body.action_type not in VALID_ACTION_TYPES:
|
||||
raise HTTPException(400, f"Invalid action_type. Valid: {sorted(VALID_ACTION_TYPES)}")
|
||||
|
||||
action_id = body.idempotency_key or str(uuid.uuid4())
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO admin_action_events (
|
||||
tenant_id, action_id, action_type, target_type, target_id,
|
||||
requested_by, payload
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)
|
||||
RETURNING action_event_id, status, created_at
|
||||
""",
|
||||
admin.role, action_id, body.action_type, body.target_type,
|
||||
body.target_id, admin.user_id, json.dumps(body.payload),
|
||||
)
|
||||
except Exception as exc:
|
||||
if "unique" in str(exc).lower():
|
||||
raise HTTPException(409, "Action with this idempotency key already exists")
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"Admin action submitted: %s by %s → %s/%s",
|
||||
body.action_type, admin.user_id, body.target_type, body.target_id,
|
||||
)
|
||||
return {
|
||||
"action_event_id": str(row["action_event_id"]),
|
||||
"action_id": action_id,
|
||||
"status": row["status"],
|
||||
"created_at": str(row["created_at"]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/actions", summary="List admin action history")
|
||||
async def list_actions(
|
||||
request: Request,
|
||||
action_type: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
where = "WHERE tenant_id = $1"
|
||||
params: list[Any] = [admin.role]
|
||||
idx = 2
|
||||
|
||||
if action_type:
|
||||
where += f" AND action_type = ${idx}"; params.append(action_type); idx += 1
|
||||
if status_filter:
|
||||
where += f" AND status = ${idx}"; params.append(status_filter); idx += 1
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT action_event_id, action_id, action_type, target_type, target_id,
|
||||
requested_by, status, result_message, executed_at, created_at
|
||||
FROM admin_action_events
|
||||
{where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${idx} OFFSET ${idx+1}
|
||||
""",
|
||||
*params, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM admin_action_events {where}", *params,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "actions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/actions/{action_event_id}", summary="Get a specific admin action")
|
||||
async def get_action(
|
||||
action_event_id: str,
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM admin_action_events WHERE action_event_id=$1 AND tenant_id=$2",
|
||||
action_event_id, admin.role,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Admin action not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ── Audit Log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/logs", summary="Recent Oracle audit events")
|
||||
async def get_audit_logs(
|
||||
request: Request,
|
||||
entity_type: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
if entity_type:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT audit_event_id, entity_type, entity_id, action, actor_id,
|
||||
actor_type, correlation_id, details, created_at
|
||||
FROM oracle_audit_events
|
||||
WHERE tenant_id=$1 AND entity_type=$2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
admin.role, entity_type, limit, offset,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT audit_event_id, entity_type, entity_id, action, actor_id,
|
||||
actor_type, correlation_id, details, created_at
|
||||
FROM oracle_audit_events
|
||||
WHERE tenant_id=$1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
admin.role, limit, offset,
|
||||
)
|
||||
return {"logs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── Template Administration ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/templates", summary="Template catalog admin view")
|
||||
async def get_templates_admin(
|
||||
request: Request,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
where = "WHERE tenant_id = $1"
|
||||
params: list[Any] = [admin.role]
|
||||
idx = 2
|
||||
|
||||
if status_filter:
|
||||
where += f" AND status = ${idx}"; params.append(status_filter); idx += 1
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT t.template_id, t.name, t.category, t.status, t.origin,
|
||||
t.version, t.use_count, t.chapter_id, t.subchapter_id,
|
||||
ch.name as chapter_name, sub.name as subchapter_name,
|
||||
t.created_at, t.updated_at
|
||||
FROM oracle_component_templates t
|
||||
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
|
||||
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
|
||||
{where}
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT ${idx} OFFSET ${idx+1}
|
||||
""",
|
||||
*params, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM oracle_component_templates {where}", *params,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/publish", summary="Publish a template")
|
||||
async def publish_template(
|
||||
template_id: str,
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE oracle_component_templates
|
||||
SET status='catalog_active', updated_at=NOW()
|
||||
WHERE template_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
template_id, admin.role,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Template not found")
|
||||
logger.info("Template %s published by admin %s", template_id, admin.user_id)
|
||||
return {"status": "published"}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/archive", summary="Archive a template")
|
||||
async def archive_template(
|
||||
template_id: str,
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE oracle_component_templates
|
||||
SET status='archived', updated_at=NOW()
|
||||
WHERE template_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
template_id, admin.role,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Template not found")
|
||||
logger.info("Template %s archived by admin %s", template_id, admin.user_id)
|
||||
return {"status": "archived"}
|
||||
|
||||
|
||||
# ── Template Chapter Admin ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/template-chapters", summary="List template chapters (admin view)")
|
||||
async def list_chapters_admin(
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active,
|
||||
COUNT(sub.subchapter_id) as subchapter_count
|
||||
FROM oracle_template_chapters ch
|
||||
LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id
|
||||
WHERE ch.tenant_id=$1
|
||||
GROUP BY ch.chapter_id
|
||||
ORDER BY ch.sort_order ASC
|
||||
""",
|
||||
admin.role,
|
||||
)
|
||||
return {"chapters": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── Synthetic Jobs Admin ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/synthetic-jobs", summary="List synthetic generation jobs")
|
||||
async def list_synthetic_jobs(
|
||||
request: Request,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
if status_filter:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT job_id, template_id, model, status, requested_count,
|
||||
accepted_count, created_by, started_at, completed_at, created_at
|
||||
FROM oracle_synthetic_generation_jobs
|
||||
WHERE tenant_id=$1 AND status=$2
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4
|
||||
""",
|
||||
admin.role, status_filter, limit, offset,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT job_id, template_id, model, status, requested_count,
|
||||
accepted_count, created_by, started_at, completed_at, created_at
|
||||
FROM oracle_synthetic_generation_jobs
|
||||
WHERE tenant_id=$1
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3
|
||||
""",
|
||||
admin.role, limit, offset,
|
||||
)
|
||||
return {"jobs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/synthetic-jobs/{job_id}/cancel", summary="Cancel a synthetic generation job")
|
||||
async def cancel_synthetic_job(
|
||||
job_id: str,
|
||||
request: Request,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE oracle_synthetic_generation_jobs
|
||||
SET status='cancelled', updated_at=NOW()
|
||||
WHERE job_id=$1 AND tenant_id=$2 AND status IN ('pending','running')
|
||||
""",
|
||||
job_id, admin.role,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Job not found or already in terminal state")
|
||||
return {"status": "cancelled"}
|
||||
512
core/api/api/routes_catalyst.py
Normal file
512
core/api/api/routes_catalyst.py
Normal file
@@ -0,0 +1,512 @@
|
||||
"""
|
||||
routes_catalyst.py
|
||||
Meta Marketing API wrappers for The Catalyst module.
|
||||
|
||||
Routes:
|
||||
POST /api/catalyst/campaigns/create — Bulk campaign creation
|
||||
POST /api/catalyst/creative/sync — Upload ComfyUI assets to Meta
|
||||
GET /api/catalyst/insights/realtime — Poll Ads Insights API
|
||||
POST /api/catalyst/audiences/lookalike — Push CRM leads → Meta Custom Audience
|
||||
POST /api/catalyst/auth/meta — OAuth token acquisition
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.services.ad_network_service import (
|
||||
AdInsight,
|
||||
BidStrategyUpdate,
|
||||
BudgetUpdate,
|
||||
Platform,
|
||||
ad_network_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_sdk() -> tuple[Any, str]:
|
||||
"""
|
||||
Initialise the facebook-business SDK lazily.
|
||||
Returns (FacebookAdsApi instance, ad_account_id).
|
||||
Raises HTTPException 503 if credentials are missing or SDK init fails.
|
||||
"""
|
||||
try:
|
||||
from facebook_business.api import FacebookAdsApi # type: ignore
|
||||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||||
app_id = os.getenv("META_APP_ID", "")
|
||||
app_secret = os.getenv("META_APP_SECRET", "")
|
||||
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
|
||||
|
||||
if not access_token or access_token.startswith("PLACEHOLDER"):
|
||||
raise ValueError("META_ACCESS_TOKEN is not configured.")
|
||||
if not account_id or account_id.startswith("PLACEHOLDER"):
|
||||
raise ValueError("META_AD_ACCOUNT_ID is not configured.")
|
||||
|
||||
FacebookAdsApi.init(app_id, app_secret, access_token)
|
||||
return FacebookAdsApi.get_default_api(), account_id
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="facebook-business SDK not installed. Run: pip install facebook-business",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _get_supabase():
|
||||
"""Initialise the Supabase client lazily."""
|
||||
try:
|
||||
from supabase import create_client # type: ignore
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
||||
if not url or url.startswith("PLACEHOLDER"):
|
||||
raise ValueError("SUPABASE_URL is not configured.")
|
||||
return create_client(url, key)
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="supabase SDK not installed. Run: pip install supabase",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _ok(data: Any, meta: dict | None = None) -> dict:
|
||||
return {"status": "ok", "data": data, "meta": meta or {}}
|
||||
|
||||
|
||||
def _sha256_hash(value: str) -> str:
|
||||
"""SHA-256 hash an email for Meta's hashed audience upload."""
|
||||
return hashlib.sha256(value.strip().lower().encode()).hexdigest()
|
||||
|
||||
|
||||
# ── Request / Response Models ─────────────────────────────────────────────────
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
name: str = Field(..., description="Campaign display name")
|
||||
platform: Platform = Field(default=Platform.META, description="Target ad network platform")
|
||||
objective: str = Field("OUTCOME_LEADS", description="Meta campaign objective enum")
|
||||
budget_daily: int = Field(..., gt=0, description="Daily budget in cents (AED × 100)")
|
||||
status: str = Field("PAUSED", description="Initial campaign status — start PAUSED for review")
|
||||
special_ad_categories: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignCreateResponse(BaseModel):
|
||||
campaign_id: str
|
||||
name: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class CreativeSyncRequest(BaseModel):
|
||||
asset_url: str = Field(..., description="Public URL of the ComfyUI-rendered image or video")
|
||||
asset_name: str = Field(..., description="Human-readable asset name")
|
||||
asset_type: str = Field(..., description="'image' or 'video'")
|
||||
ad_account_id: str | None = Field(None, description="Override ad account ID (optional)")
|
||||
|
||||
|
||||
class LookalikeAudienceRequest(BaseModel):
|
||||
country: str = Field("AE", description="ISO 3166-1 alpha-2 country code for lookalike")
|
||||
ratio: float = Field(0.01, ge=0.01, le=0.20, description="Lookalike ratio (1%–20%)")
|
||||
crm_filter_status: str = Field("Closed/Won", description="Supabase lead status to filter on")
|
||||
|
||||
|
||||
class MetaAuthRequest(BaseModel):
|
||||
short_lived_token: str = Field(..., description="Short-lived user access token from Meta OAuth")
|
||||
|
||||
|
||||
@router.get("/campaigns", summary="List unified campaign summaries for the Catalyst marketing tab")
|
||||
async def list_campaigns(platform: Platform | None = Query(default=None)) -> dict:
|
||||
campaigns = await ad_network_service.list_campaigns(platform=platform)
|
||||
insights = await ad_network_service.get_insights(platform=platform, days=7)
|
||||
rollup: dict[str, dict[str, float]] = {}
|
||||
for insight in insights:
|
||||
insight_campaign_id = insight.campaign_id if isinstance(insight, AdInsight) else insight.get("campaign_id")
|
||||
if not insight_campaign_id:
|
||||
continue
|
||||
spent = insight.spend if isinstance(insight, AdInsight) else float(insight.get("spend", 0))
|
||||
impressions = insight.impressions if isinstance(insight, AdInsight) else int(insight.get("impressions", 0))
|
||||
clicks = insight.clicks if isinstance(insight, AdInsight) else int(insight.get("clicks", 0))
|
||||
conversions = insight.conversions if isinstance(insight, AdInsight) else int(insight.get("conversions", 0))
|
||||
slot = rollup.setdefault(
|
||||
insight_campaign_id,
|
||||
{
|
||||
"spent": 0.0,
|
||||
"impressions": 0.0,
|
||||
"clicks": 0.0,
|
||||
"conversions": 0.0,
|
||||
},
|
||||
)
|
||||
slot["spent"] += spent
|
||||
slot["impressions"] += impressions
|
||||
slot["clicks"] += clicks
|
||||
slot["conversions"] += conversions
|
||||
data = [
|
||||
{
|
||||
"id": campaign.id,
|
||||
"name": campaign.name,
|
||||
"platform": campaign.platform.value,
|
||||
"status": campaign.status.value,
|
||||
"budget": campaign.daily_budget,
|
||||
"spent": round(rollup.get(campaign.id, {}).get("spent", campaign.spent), 2),
|
||||
"impressions": int(rollup.get(campaign.id, {}).get("impressions", 0)),
|
||||
"clicks": int(rollup.get(campaign.id, {}).get("clicks", 0)),
|
||||
"conversions": int(rollup.get(campaign.id, {}).get("conversions", 0)),
|
||||
"objective": campaign.objective,
|
||||
"bid_strategy": campaign.bid_strategy,
|
||||
}
|
||||
for campaign in campaigns
|
||||
]
|
||||
source = "ad_network_service_live" if platform else "ad_network_service_unified"
|
||||
return _ok(data, meta={"count": len(data), "source": source})
|
||||
|
||||
|
||||
# ── 1. POST /campaigns/create ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/campaigns/create", summary="Create Meta or Google marketing campaigns")
|
||||
async def create_campaigns(
|
||||
request: Request,
|
||||
payload: CampaignCreateRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
Triggers `facebook_business.adobjects.campaign.Campaign` to create a campaign
|
||||
under the configured Ad Account.
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
if payload.platform == Platform.GOOGLE:
|
||||
campaign_id = f"google-camp-{uuid.uuid4().hex[:8]}"
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created Google Ads campaign '{payload.name}'.",
|
||||
payload.name,
|
||||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||||
)
|
||||
return _ok(
|
||||
CampaignCreateResponse(
|
||||
campaign_id=campaign_id,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
).model_dump(),
|
||||
meta={"platform": "google", "mode": "simulated_or_provider_managed"},
|
||||
)
|
||||
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.campaign import Campaign # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
params = {
|
||||
Campaign.Field.name: payload.name,
|
||||
Campaign.Field.objective: payload.objective,
|
||||
Campaign.Field.status: payload.status,
|
||||
Campaign.Field.daily_budget: payload.budget_daily,
|
||||
Campaign.Field.special_ad_categories: payload.special_ad_categories,
|
||||
}
|
||||
campaign = account.create_campaign(params=params)
|
||||
|
||||
# Broadcast live event via WebSocket
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created campaign '{payload.name}' (objective: {payload.objective}).",
|
||||
payload.name,
|
||||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||||
)
|
||||
|
||||
return _ok(
|
||||
CampaignCreateResponse(
|
||||
campaign_id=campaign["id"],
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
).model_dump(),
|
||||
meta={"account_id": account_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Campaign creation failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 2. POST /creative/sync ────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/creative/sync", summary="Upload ComfyUI asset to Meta Ad Library")
|
||||
async def sync_creative(
|
||||
request: Request,
|
||||
payload: CreativeSyncRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
Uploads an image or video URL (from ComfyUI / Wan 2.2 / Qwen-Image 2512) to
|
||||
the Meta Ad Library (Creative Hub) and returns the Meta Asset ID.
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
account_id = payload.ad_account_id or account_id
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.advideo import AdVideo # type: ignore
|
||||
from facebook_business.adobjects.adimage import AdImage # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
|
||||
if payload.asset_type == "video":
|
||||
# Video upload via file_url
|
||||
result = account.create_ad_video(params={
|
||||
AdVideo.Field.name: payload.asset_name,
|
||||
AdVideo.Field.file_url: payload.asset_url,
|
||||
})
|
||||
meta_asset_id = result["id"]
|
||||
else:
|
||||
# Image upload via url
|
||||
result = account.create_ad_image(params={
|
||||
"filename": payload.asset_name,
|
||||
"url": payload.asset_url,
|
||||
})
|
||||
# AdImage returns a hash dict — extract hash key
|
||||
meta_asset_id = list(result["images"].values())[0]["hash"] \
|
||||
if "images" in result else result.get("id", "unknown")
|
||||
|
||||
return _ok({
|
||||
"meta_asset_id": meta_asset_id,
|
||||
"asset_name": payload.asset_name,
|
||||
"asset_type": payload.asset_type,
|
||||
"uploaded_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Creative sync failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 3. GET /insights/realtime ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/insights/realtime", summary="Poll unified Meta and Google Ads insights")
|
||||
async def get_realtime_insights(
|
||||
campaign_id: str | None = None,
|
||||
platform: Platform | None = Query(default=None),
|
||||
days: int = Query(default=7, ge=1, le=90),
|
||||
) -> dict:
|
||||
try:
|
||||
insights = await ad_network_service.get_insights(campaign_id=campaign_id, platform=platform, days=days)
|
||||
except Exception as exc:
|
||||
logger.error("Insights fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
data = [item.model_dump() if isinstance(item, AdInsight) else item for item in insights]
|
||||
return _ok(data, meta={"count": len(data), "days": days, "platform": platform.value if platform else "all"})
|
||||
|
||||
|
||||
@router.put("/budget", summary="Update Meta or Google Ads budget and campaign status")
|
||||
async def update_campaign_budget(request: Request, payload: BudgetUpdate) -> dict:
|
||||
try:
|
||||
result = await ad_network_service.update_budget(payload)
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"budget_update",
|
||||
f"Updated {payload.platform.value} budget for {payload.campaign_id}.",
|
||||
payload.campaign_id,
|
||||
f"daily={payload.daily_budget} lifetime={payload.lifetime_budget}",
|
||||
)
|
||||
return _ok(result)
|
||||
except Exception as exc:
|
||||
logger.error("Budget update failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
@router.put("/bid-strategy", summary="Apply Meta or Google Ads bid strategy changes")
|
||||
async def update_bid_strategy(request: Request, payload: BidStrategyUpdate) -> dict:
|
||||
try:
|
||||
action = await ad_network_service.update_bid_strategy(payload)
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"bid_strategy_update",
|
||||
f"Updated {payload.platform.value} bid strategy for {payload.campaign_id}.",
|
||||
payload.campaign_id,
|
||||
payload.strategy,
|
||||
)
|
||||
return _ok(action.model_dump())
|
||||
except Exception as exc:
|
||||
logger.error("Bid strategy update failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 4. POST /audiences/lookalike ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/audiences/lookalike", summary="Push Supabase CRM leads → Meta Lookalike Audience")
|
||||
async def create_lookalike_audience(
|
||||
request: Request,
|
||||
payload: LookalikeAudienceRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
1. Queries the Supabase `leads` table for rows matching `status = payload.crm_filter_status`.
|
||||
2. SHA-256 hashes their email addresses.
|
||||
3. Creates (or updates) a Meta Custom Audience with the hashed emails.
|
||||
4. Creates a Lookalike Audience from that Custom Audience.
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
supabase = _get_supabase()
|
||||
|
||||
# ── Step 1: Fetch qualified leads from Supabase CRM ──
|
||||
try:
|
||||
response = supabase.table("leads") \
|
||||
.select("id, email, name") \
|
||||
.eq("status", payload.crm_filter_status) \
|
||||
.execute()
|
||||
leads = response.data or []
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Supabase query failed: {exc}")
|
||||
|
||||
if not leads:
|
||||
return _ok({"message": f"No leads found with status '{payload.crm_filter_status}'."})
|
||||
|
||||
# ── Step 2: Hash emails ──
|
||||
hashed_emails = [
|
||||
_sha256_hash(lead["email"])
|
||||
for lead in leads
|
||||
if lead.get("email")
|
||||
]
|
||||
if not hashed_emails:
|
||||
raise HTTPException(status_code=422, detail="No valid email addresses found in the filtered leads.")
|
||||
|
||||
# ── Step 3: Create / update Meta Custom Audience ──
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.customaudience import CustomAudience # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
audience_name = f"Velocity CRM — {payload.crm_filter_status} Leads"
|
||||
|
||||
# Create custom audience
|
||||
custom_audience = account.create_custom_audience(params={
|
||||
CustomAudience.Field.name: audience_name,
|
||||
CustomAudience.Field.subtype: "CUSTOM",
|
||||
CustomAudience.Field.description: f"Auto-generated from Velocity CRM — {len(hashed_emails)} leads",
|
||||
"customer_file_source": "USER_PROVIDED_ONLY",
|
||||
})
|
||||
audience_id = custom_audience["id"]
|
||||
|
||||
# Add users via hashed emails
|
||||
custom_audience.create_users_replace(params={
|
||||
"payload": {
|
||||
"schema": ["EMAIL_SHA256"],
|
||||
"data": [[h] for h in hashed_emails],
|
||||
}
|
||||
})
|
||||
|
||||
# ── Step 4: Create Lookalike Audience ──
|
||||
lookalike = account.create_lookalike_audience(params={
|
||||
"name": f"Velocity Lookalike — {payload.crm_filter_status} ({int(payload.ratio * 100)}%)",
|
||||
"origin_audience_id": audience_id,
|
||||
"lookalike_spec": {
|
||||
"type": "similarity",
|
||||
"ratio": payload.ratio,
|
||||
"country": payload.country,
|
||||
},
|
||||
})
|
||||
|
||||
# Broadcast live event
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created Lookalike Audience from {len(hashed_emails)} CRM Closed/Won leads.",
|
||||
None,
|
||||
f"+{len(hashed_emails):,} leads",
|
||||
)
|
||||
|
||||
return _ok({
|
||||
"custom_audience_id": audience_id,
|
||||
"lookalike_audience_id": lookalike["id"],
|
||||
"leads_processed": len(hashed_emails),
|
||||
"country": payload.country,
|
||||
"ratio": payload.ratio,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Audience creation failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 5. POST /auth/meta ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/auth/meta", summary="Exchange short-lived token for System User token")
|
||||
async def meta_oauth(payload: MetaAuthRequest) -> dict:
|
||||
"""
|
||||
Exchanges a short-lived Meta user token for a long-lived token using the
|
||||
`/oauth/access_token` endpoint, then stores it in Supabase for persistence.
|
||||
|
||||
Requires: META_APP_ID, META_APP_SECRET
|
||||
"""
|
||||
import httpx
|
||||
|
||||
app_id = os.getenv("META_APP_ID", "")
|
||||
app_secret = os.getenv("META_APP_SECRET", "")
|
||||
api_ver = os.getenv("META_API_VERSION", "v21.0")
|
||||
|
||||
if app_id.startswith("PLACEHOLDER") or app_secret.startswith("PLACEHOLDER"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="META_APP_ID or META_APP_SECRET not configured.",
|
||||
)
|
||||
|
||||
url = f"https://graph.facebook.com/{api_ver}/oauth/access_token"
|
||||
params = {
|
||||
"grant_type": "fb_exchange_token",
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
"fb_exchange_token": payload.short_lived_token,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, params=params, timeout=15.0)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Meta OAuth error: {resp.text}",
|
||||
)
|
||||
|
||||
token_data = resp.json()
|
||||
long_lived_token = token_data.get("access_token")
|
||||
|
||||
if not long_lived_token:
|
||||
raise HTTPException(status_code=502, detail="No access_token in Meta response.")
|
||||
|
||||
# Persist to Supabase (best-effort — don't block on failure)
|
||||
try:
|
||||
supabase = _get_supabase()
|
||||
supabase.table("catalyst_settings").upsert({
|
||||
"key": "META_ACCESS_TOKEN",
|
||||
"value": long_lived_token,
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}).execute()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist Meta token to Supabase: %s", exc)
|
||||
|
||||
return _ok({
|
||||
"access_token": long_lived_token,
|
||||
"token_type": token_data.get("token_type", "bearer"),
|
||||
"expires_in": token_data.get("expires_in"),
|
||||
})
|
||||
588
core/api/api/routes_comms.py
Normal file
588
core/api/api/routes_comms.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Velocity Conversations API.
|
||||
|
||||
Native WhatsApp-first communications surface for Velocity WebOS. The routes are
|
||||
provider-abstracted and CRM-aware, while remaining safe to run in mock mode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.comms_evolution_provider import EvolutionProvider
|
||||
from backend.services.comms_ingest import ingest_inbound_message
|
||||
from backend.services.comms_provider import MockProvider
|
||||
from backend.services.comms_waha_provider import WahaProvider
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SCHEMA_READY = False
|
||||
|
||||
|
||||
class SendMessageBody(BaseModel):
|
||||
messageType: str = "text"
|
||||
body: str
|
||||
mediaUrl: str | None = None
|
||||
templateName: str | None = None
|
||||
templateLanguage: str | None = None
|
||||
|
||||
|
||||
class LinkPersonBody(BaseModel):
|
||||
personId: str
|
||||
|
||||
|
||||
class NoteBody(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class TaskBody(BaseModel):
|
||||
title: str
|
||||
dueAt: str | None = None
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
provider: str | None = None
|
||||
providerBaseUrl: str | None = None
|
||||
providerApiKey: str | None = None
|
||||
instanceId: str | None = None
|
||||
phoneNumberId: str | None = None
|
||||
webhookCallbackUrl: str | None = None
|
||||
webhookSecret: str | None = None
|
||||
defaultAssignmentUserId: str | None = None
|
||||
autoLinkByPhone: bool | None = None
|
||||
createCrmInteractionOnInbound: bool | None = None
|
||||
defaultCountryCode: str | None = None
|
||||
transcriptionProvider: str | None = None
|
||||
|
||||
|
||||
class TranscribeBody(BaseModel):
|
||||
callId: str | None = None
|
||||
recordingUrl: str | None = None
|
||||
|
||||
|
||||
def _get_provider():
|
||||
return _provider_from_config({})
|
||||
|
||||
|
||||
def _provider_from_config(config: dict[str, Any], provider_override: str | None = None):
|
||||
provider = (provider_override or config.get("provider") or os.getenv("COMMS_PROVIDER", "mock")).strip().lower()
|
||||
base_url = (config.get("provider_base_url") or os.getenv("COMMS_PROVIDER_BASE_URL", "")).strip()
|
||||
api_key = (config.get("provider_api_key") or os.getenv("COMMS_PROVIDER_API_KEY", "")).strip()
|
||||
instance_id = (config.get("instance_id") or os.getenv("COMMS_INSTANCE_ID", "")).strip() or None
|
||||
|
||||
if provider == "waha":
|
||||
return WahaProvider(base_url, api_key, instance_id)
|
||||
if provider == "evolution":
|
||||
return EvolutionProvider(base_url, api_key, instance_id)
|
||||
return MockProvider("", "", "mock")
|
||||
|
||||
|
||||
async def _load_config(pool) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
|
||||
return _json_obj(row["value_json"]) if row else {}
|
||||
|
||||
|
||||
async def _get_provider_for_pool(pool, provider_override: str | None = None):
|
||||
return _provider_from_config(await _load_config(pool), provider_override)
|
||||
|
||||
|
||||
def _camel_settings(config: dict[str, Any], updated_at: datetime | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"provider": config.get("provider", os.getenv("COMMS_PROVIDER", "mock")),
|
||||
"providerBaseUrl": config.get("provider_base_url", os.getenv("COMMS_PROVIDER_BASE_URL", "")),
|
||||
"providerApiKey": config.get("provider_api_key", ""),
|
||||
"instanceId": config.get("instance_id", os.getenv("COMMS_INSTANCE_ID", "")),
|
||||
"phoneNumberId": config.get("phone_number_id", ""),
|
||||
"webhookCallbackUrl": config.get("webhook_callback_url", "/api/comms/webhooks/{provider}"),
|
||||
"webhookSecretSet": bool(config.get("webhook_secret_hash") or config.get("webhook_secret_set")),
|
||||
"defaultAssignmentUserId": config.get("default_assignment_user_id"),
|
||||
"autoLinkByPhone": bool(config.get("auto_link_by_phone", True)),
|
||||
"createCrmInteractionOnInbound": bool(config.get("create_crm_interaction_on_inbound", True)),
|
||||
"defaultCountryCode": str(config.get("default_country_code", os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91"))),
|
||||
"mediaStorageDir": config.get("media_storage_dir", os.getenv("COMMS_MEDIA_STORAGE_DIR", "/opt/dlami/nvme/assets/comms")),
|
||||
"transcriptionProvider": config.get("transcription_provider", os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none")),
|
||||
**({"updatedAt": updated_at.isoformat()} if updated_at else {}),
|
||||
}
|
||||
|
||||
|
||||
def _snake_settings(body: SettingsPatch) -> dict[str, Any]:
|
||||
mapping = {
|
||||
"provider": "provider",
|
||||
"providerBaseUrl": "provider_base_url",
|
||||
"providerApiKey": "provider_api_key",
|
||||
"instanceId": "instance_id",
|
||||
"phoneNumberId": "phone_number_id",
|
||||
"webhookCallbackUrl": "webhook_callback_url",
|
||||
"defaultAssignmentUserId": "default_assignment_user_id",
|
||||
"autoLinkByPhone": "auto_link_by_phone",
|
||||
"createCrmInteractionOnInbound": "create_crm_interaction_on_inbound",
|
||||
"defaultCountryCode": "default_country_code",
|
||||
"transcriptionProvider": "transcription_provider",
|
||||
}
|
||||
raw = body.model_dump(exclude_unset=True)
|
||||
updates: dict[str, Any] = {}
|
||||
for src, dst in mapping.items():
|
||||
if src in raw:
|
||||
updates[dst] = raw[src]
|
||||
if body.webhookSecret is not None:
|
||||
updates["webhook_secret_hash"] = hashlib.sha256(body.webhookSecret.encode()).hexdigest() if body.webhookSecret else ""
|
||||
updates["webhook_secret_set"] = bool(body.webhookSecret)
|
||||
return updates
|
||||
|
||||
|
||||
def _json_obj(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _record_value(row: Any, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
async def _ensure_schema(pool) -> None:
|
||||
global _SCHEMA_READY
|
||||
if _SCHEMA_READY:
|
||||
return
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS comms_threads (
|
||||
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider TEXT NOT NULL DEFAULT 'mock',
|
||||
external_thread_id TEXT,
|
||||
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
|
||||
phone_e164 TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
channel TEXT NOT NULL DEFAULT 'whatsapp',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
assigned_user_id UUID NULL,
|
||||
last_message_at TIMESTAMPTZ,
|
||||
unread_count INT NOT NULL DEFAULT 0,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone_provider ON comms_threads(provider, phone_e164);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_threads_person ON comms_threads(person_id) WHERE person_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_threads_status ON comms_threads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_threads_last_message ON comms_threads(last_message_at DESC NULLS LAST);
|
||||
CREATE TABLE IF NOT EXISTS comms_messages (
|
||||
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
thread_id UUID NOT NULL REFERENCES comms_threads(thread_id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'mock',
|
||||
external_message_id TEXT,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound', 'system')),
|
||||
message_type TEXT NOT NULL DEFAULT 'text',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
media_url TEXT,
|
||||
media_mime_type TEXT,
|
||||
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
||||
sent_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
read_at TIMESTAMPTZ,
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_messages_thread ON comms_messages(thread_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_messages_external ON comms_messages(external_message_id) WHERE external_message_id IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS comms_call_logs (
|
||||
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL,
|
||||
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'mock',
|
||||
external_call_id TEXT,
|
||||
phone_e164 TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
duration_seconds INT,
|
||||
recording_url TEXT,
|
||||
transcript_id UUID,
|
||||
transcript_text TEXT,
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_phone ON comms_call_logs(phone_e164);
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_thread ON comms_call_logs(thread_id) WHERE thread_id IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS comms_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO comms_settings (key, value_json)
|
||||
VALUES ('config', '{"provider":"mock","auto_link_by_phone":true,"create_crm_interaction_on_inbound":true,"default_country_code":"91","transcription_provider":"none"}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
"""
|
||||
)
|
||||
_SCHEMA_READY = True
|
||||
|
||||
|
||||
async def _pool(request: Request):
|
||||
pool = request.app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable")
|
||||
await _ensure_schema(pool)
|
||||
return pool
|
||||
|
||||
|
||||
def _row_thread(row) -> dict[str, Any]:
|
||||
return {
|
||||
"threadId": str(row["thread_id"]),
|
||||
"provider": row["provider"],
|
||||
"externalThreadId": row["external_thread_id"],
|
||||
"personId": str(row["person_id"]) if row["person_id"] else None,
|
||||
"phoneE164": row["phone_e164"],
|
||||
"displayName": row["display_name"],
|
||||
"channel": row["channel"],
|
||||
"status": row["status"],
|
||||
"assignedUserId": str(row["assigned_user_id"]) if row["assigned_user_id"] else None,
|
||||
"lastMessageAt": row["last_message_at"].isoformat() if row["last_message_at"] else None,
|
||||
"unreadCount": row["unread_count"],
|
||||
"metadataJson": _json_obj(row["metadata_json"]),
|
||||
"createdAt": row["created_at"].isoformat(),
|
||||
"updatedAt": row["updated_at"].isoformat(),
|
||||
"lastMessagePreview": _record_value(row, "last_message_preview"),
|
||||
"crmPerson": {
|
||||
"id": str(row["person_id"]),
|
||||
"fullName": row["crm_full_name"],
|
||||
"primaryPhone": row["crm_primary_phone"],
|
||||
"primaryEmail": row["crm_primary_email"],
|
||||
"buyerType": row["crm_buyer_type"],
|
||||
"leadStatus": row["crm_lead_status"],
|
||||
"projectName": row["crm_project_name"],
|
||||
} if row["person_id"] else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/threads")
|
||||
async def list_threads(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = await _pool(request)
|
||||
limit = max(1, min(limit, 100))
|
||||
offset = max(0, offset)
|
||||
conditions = ["1=1"]
|
||||
values: list[Any] = []
|
||||
if status:
|
||||
values.append(status)
|
||||
conditions.append(f"t.status = ${len(values)}")
|
||||
if search:
|
||||
values.append(f"%{search}%")
|
||||
conditions.append(f"(t.phone_e164 ILIKE ${len(values)} OR t.display_name ILIKE ${len(values)} OR p.full_name ILIKE ${len(values)} OR p.primary_email ILIKE ${len(values)})")
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT t.*,
|
||||
p.full_name AS crm_full_name,
|
||||
p.primary_email AS crm_primary_email,
|
||||
p.primary_phone AS crm_primary_phone,
|
||||
p.buyer_type AS crm_buyer_type,
|
||||
COALESCE(l.status, '') AS crm_lead_status,
|
||||
(
|
||||
SELECT pi.project_name FROM crm_property_interests pi
|
||||
WHERE pi.person_id = p.person_id
|
||||
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) AS crm_project_name,
|
||||
(
|
||||
SELECT m.body FROM comms_messages m
|
||||
WHERE m.thread_id = t.thread_id
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
) AS last_message_preview
|
||||
FROM comms_threads t
|
||||
LEFT JOIN crm_people p ON t.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status FROM crm_leads l
|
||||
WHERE l.person_id = p.person_id
|
||||
ORDER BY l.updated_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) l ON TRUE
|
||||
WHERE {where_clause}
|
||||
ORDER BY t.last_message_at DESC NULLS LAST, t.updated_at DESC
|
||||
LIMIT ${len(values)+1} OFFSET ${len(values)+2}
|
||||
""",
|
||||
*values,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM comms_threads t LEFT JOIN crm_people p ON t.person_id = p.person_id WHERE {where_clause}",
|
||||
*values,
|
||||
)
|
||||
unread = await conn.fetchval("SELECT COALESCE(SUM(unread_count),0)::int FROM comms_threads WHERE status = 'open'")
|
||||
|
||||
return {"threads": [_row_thread(row) for row in rows], "total": total or 0, "unreadTotal": unread or 0}
|
||||
|
||||
|
||||
@router.get("/threads/{thread_id}")
|
||||
async def get_thread(thread_id: str, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT t.*, p.full_name AS crm_full_name, p.primary_email AS crm_primary_email,
|
||||
p.primary_phone AS crm_primary_phone, p.buyer_type AS crm_buyer_type,
|
||||
COALESCE(l.status, '') AS crm_lead_status,
|
||||
(
|
||||
SELECT pi.project_name FROM crm_property_interests pi
|
||||
WHERE pi.person_id = p.person_id
|
||||
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) AS crm_project_name,
|
||||
NULL::text AS last_message_preview
|
||||
FROM comms_threads t
|
||||
LEFT JOIN crm_people p ON t.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status FROM crm_leads l
|
||||
WHERE l.person_id = p.person_id
|
||||
ORDER BY l.updated_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) l ON TRUE
|
||||
WHERE t.thread_id = $1::uuid
|
||||
""",
|
||||
thread_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
return _row_thread(row)
|
||||
|
||||
|
||||
@router.get("/threads/{thread_id}/messages")
|
||||
async def list_messages(
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = await _pool(request)
|
||||
limit = max(1, min(limit, 200))
|
||||
offset = max(0, offset)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM comms_messages
|
||||
WHERE thread_id = $1::uuid
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
thread_id,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
messages = [
|
||||
{
|
||||
"messageId": str(row["message_id"]),
|
||||
"threadId": str(row["thread_id"]),
|
||||
"provider": row["provider"],
|
||||
"externalMessageId": row["external_message_id"],
|
||||
"direction": row["direction"],
|
||||
"messageType": row["message_type"],
|
||||
"body": row["body"],
|
||||
"mediaUrl": row["media_url"],
|
||||
"mediaMimeType": row["media_mime_type"],
|
||||
"deliveryStatus": row["delivery_status"],
|
||||
"sentAt": row["sent_at"].isoformat() if row["sent_at"] else None,
|
||||
"deliveredAt": row["delivered_at"].isoformat() if row["delivered_at"] else None,
|
||||
"readAt": row["read_at"].isoformat() if row["read_at"] else None,
|
||||
"rawPayload": _json_obj(row["raw_payload"]),
|
||||
"createdAt": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
return {"messages": messages, "thread": await get_thread(thread_id, request)}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/messages")
|
||||
async def send_message(
|
||||
thread_id: str,
|
||||
body: SendMessageBody,
|
||||
request: Request,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
thread = await conn.fetchrow("SELECT * FROM comms_threads WHERE thread_id = $1::uuid", thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
provider = await _get_provider_for_pool(pool)
|
||||
result = await provider.send_message(
|
||||
phone=thread["phone_e164"],
|
||||
message=body.body,
|
||||
message_type=body.messageType,
|
||||
media_url=body.mediaUrl,
|
||||
)
|
||||
async with pool.acquire() as conn:
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages
|
||||
(thread_id, provider, external_message_id, direction, message_type, body, media_url, delivery_status, sent_at)
|
||||
VALUES ($1::uuid, $2, $3, 'outbound', $4, $5, $6, 'sent', NOW())
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
os.getenv("COMMS_PROVIDER", "mock").lower(),
|
||||
result.get("external_message_id"),
|
||||
body.messageType,
|
||||
body.body,
|
||||
body.mediaUrl,
|
||||
)
|
||||
await conn.execute("UPDATE comms_threads SET last_message_at = NOW(), updated_at = NOW() WHERE thread_id = $1::uuid", thread_id)
|
||||
return {"messageId": str(msg_id), "providerResult": result}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/link-person")
|
||||
async def link_person(
|
||||
thread_id: str,
|
||||
body: LinkPersonBody,
|
||||
request: Request,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid)", body.personId)
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="CRM person not found")
|
||||
updated = await conn.execute(
|
||||
"UPDATE comms_threads SET person_id = $1::uuid, updated_at = NOW() WHERE thread_id = $2::uuid",
|
||||
body.personId,
|
||||
thread_id,
|
||||
)
|
||||
return {"success": updated.endswith("1"), "threadId": thread_id, "personId": body.personId}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/notes")
|
||||
async def add_note(thread_id: str, body: NoteBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
|
||||
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
f"Note: {body.content}",
|
||||
)
|
||||
return {"messageId": str(msg_id)}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/tasks")
|
||||
async def add_task(thread_id: str, body: TaskBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
text = f"Task: {body.title}" + (f" (Due: {body.dueAt})" if body.dueAt else "")
|
||||
async with pool.acquire() as conn:
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
|
||||
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
text,
|
||||
)
|
||||
return {"messageId": str(msg_id)}
|
||||
|
||||
|
||||
@router.post("/webhooks/{provider}")
|
||||
async def receive_webhook(provider: str, request: Request):
|
||||
pool = await _pool(request)
|
||||
raw_body = await request.body()
|
||||
secret = os.getenv("COMMS_WEBHOOK_SECRET", "").strip()
|
||||
if secret:
|
||||
signature = request.headers.get("x-velocity-signature", "")
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
raise HTTPException(status_code=401, detail="Invalid comms webhook signature")
|
||||
payload = await request.json()
|
||||
provider_impl = await _get_provider_for_pool(pool, provider)
|
||||
normalized = await provider_impl.normalize_webhook(payload)
|
||||
normalized["provider"] = provider
|
||||
return {"received": True, "ingest": await ingest_inbound_message(pool, normalized)}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT value_json, updated_at FROM comms_settings WHERE key = 'config'")
|
||||
config = _json_obj(row["value_json"]) if row else {}
|
||||
result = _camel_settings(config, row["updated_at"] if row else None)
|
||||
if result.get("providerApiKey"):
|
||||
result["providerApiKey"] = "********" + str(result["providerApiKey"])[-4:]
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def patch_settings(body: SettingsPatch, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
updates = _snake_settings(body)
|
||||
if updates.get("provider_api_key", "").startswith("*"):
|
||||
updates.pop("provider_api_key", None)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
|
||||
config = _json_obj(row["value_json"]) if row else {}
|
||||
config.update(updates)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO comms_settings (key, value_json, updated_at)
|
||||
VALUES ('config', $1::jsonb, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW()
|
||||
""",
|
||||
json.dumps(config),
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/provider/test")
|
||||
async def test_provider(request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
return await (await _get_provider_for_pool(pool)).test_connection()
|
||||
|
||||
|
||||
@router.post("/recordings/transcribe")
|
||||
async def transcribe_recording(body: TranscribeBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
if body.callId:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE comms_call_logs SET transcript_text = $1 WHERE call_id = $2::uuid",
|
||||
"Transcription pending. Configure COMMS_TRANSCRIPTION_PROVIDER to enable processing.",
|
||||
body.callId,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "pending",
|
||||
"message": "Transcription intake recorded. A real transcription worker/provider is still required.",
|
||||
"callId": body.callId,
|
||||
"recordingUrl": body.recordingUrl,
|
||||
}
|
||||
1389
core/api/api/routes_crm.py
Normal file
1389
core/api/api/routes_crm.py
Normal file
File diff suppressed because it is too large
Load Diff
1521
core/api/api/routes_crm_imports.py
Normal file
1521
core/api/api/routes_crm_imports.py
Normal file
File diff suppressed because it is too large
Load Diff
403
core/api/api/routes_inventory.py
Normal file
403
core/api/api/routes_inventory.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
routes_inventory.py
|
||||
───────────────────
|
||||
Inventory Pipeline API
|
||||
|
||||
Endpoints:
|
||||
POST /inventory/import-batches — create a new import batch
|
||||
GET /inventory/import-batches — list import batches
|
||||
GET /inventory/import-batches/{batch_id} — get batch status
|
||||
POST /inventory/properties — create a single property
|
||||
GET /inventory/properties — list properties
|
||||
GET /inventory/properties/{property_id} — get a property
|
||||
PATCH /inventory/properties/{property_id} — update a property
|
||||
DELETE /inventory/properties/{property_id} — archive a property
|
||||
POST /inventory/properties/{property_id}/media — attach media to a property
|
||||
GET /inventory/properties/{property_id}/media — list media for a property
|
||||
DELETE /inventory/media/{media_asset_id} — remove a media asset
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger("velocity.inventory")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pool(request: Request):
|
||||
pool = request.app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"}
|
||||
VALID_PROPERTY_STATUSES = {"active", "archived", "draft", "under_review"}
|
||||
VALID_MEDIA_TYPES = {"image", "video", "floorplan", "brochure", "360", "vr"}
|
||||
|
||||
|
||||
class ImportBatchCreate(BaseModel):
|
||||
source_type: str
|
||||
source_file_ref: Optional[str] = None
|
||||
total_rows: int = 0
|
||||
|
||||
|
||||
class PropertyCreate(BaseModel):
|
||||
batch_id: Optional[str] = None
|
||||
source_id: Optional[str] = None
|
||||
project_name: str
|
||||
developer_name: str
|
||||
location: dict = Field(default_factory=dict) # {city, district, lat, lng}
|
||||
property_type: str
|
||||
price_bands: list[dict] = Field(default_factory=list)
|
||||
unit_mix: list[dict] = Field(default_factory=list)
|
||||
amenities: list[str] = Field(default_factory=list)
|
||||
status: str = "draft"
|
||||
validation_state: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PropertyUpdate(BaseModel):
|
||||
project_name: Optional[str] = None
|
||||
developer_name: Optional[str] = None
|
||||
location: Optional[dict] = None
|
||||
property_type: Optional[str] = None
|
||||
price_bands: Optional[list[dict]] = None
|
||||
unit_mix: Optional[list[dict]] = None
|
||||
amenities: Optional[list[str]] = None
|
||||
status: Optional[str] = None
|
||||
validation_state: Optional[dict] = None
|
||||
|
||||
|
||||
class MediaAssetCreate(BaseModel):
|
||||
media_type: str
|
||||
url: str
|
||||
thumbnail_url: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Import Batches ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/import-batches", status_code=status.HTTP_201_CREATED,
|
||||
summary="Create an inventory import batch")
|
||||
async def create_import_batch(
|
||||
request: Request,
|
||||
body: ImportBatchCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
if body.source_type not in VALID_SOURCE_TYPES:
|
||||
raise HTTPException(400, f"Invalid source_type. Valid: {sorted(VALID_SOURCE_TYPES)}")
|
||||
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO inventory_import_batches
|
||||
(tenant_id, source_type, submitted_by, total_rows, source_file_ref)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING batch_id, status, created_at
|
||||
""",
|
||||
_tenant_scope(user), body.source_type, user.user_id, body.total_rows, body.source_file_ref,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/import-batches", summary="List import batches")
|
||||
async def list_import_batches(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT batch_id, source_type, submitted_by, status, total_rows,
|
||||
accepted_rows, rejected_rows, created_at, completed_at
|
||||
FROM inventory_import_batches
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
_tenant_scope(user), limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", _tenant_scope(user),
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/import-batches/{batch_id}", summary="Get import batch status")
|
||||
async def get_import_batch(
|
||||
batch_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
batch_id, _tenant_scope(user),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ── Properties ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/properties", status_code=status.HTTP_201_CREATED, summary="Create a property")
|
||||
async def create_property(
|
||||
request: Request,
|
||||
body: PropertyCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_PROPERTY_STATUSES:
|
||||
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}")
|
||||
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO inventory_properties (
|
||||
tenant_id, batch_id, source_id, project_name, developer_name,
|
||||
location, property_type, price_bands, unit_mix, amenities,
|
||||
status, validation_state
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6::jsonb, $7, $8::jsonb, $9::jsonb, $10,
|
||||
$11, $12::jsonb
|
||||
)
|
||||
RETURNING property_id, created_at
|
||||
""",
|
||||
_tenant_scope(user), body.batch_id, body.source_id, body.project_name, body.developer_name,
|
||||
json.dumps(body.location), body.property_type, json.dumps(body.price_bands),
|
||||
json.dumps(body.unit_mix), body.amenities,
|
||||
body.status, json.dumps(body.validation_state),
|
||||
)
|
||||
return {"property_id": str(row["property_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
@router.get("/properties", summary="List inventory properties")
|
||||
async def list_properties(
|
||||
request: Request,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
property_type: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
where_clause = "WHERE tenant_id = $1"
|
||||
params: list[Any] = [_tenant_scope(user)]
|
||||
idx = 2
|
||||
|
||||
if status_filter:
|
||||
where_clause += f" AND status = ${idx}"
|
||||
params.append(status_filter)
|
||||
idx += 1
|
||||
if property_type:
|
||||
where_clause += f" AND property_type = ${idx}"
|
||||
params.append(property_type)
|
||||
idx += 1
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT property_id, project_name, developer_name, property_type,
|
||||
location, price_bands, unit_mix, status, ingested_at, created_at
|
||||
FROM inventory_properties
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${idx} OFFSET ${idx+1}
|
||||
""",
|
||||
*params, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM inventory_properties {where_clause}", *params,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "properties": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/properties/{property_id}", summary="Get a property")
|
||||
async def get_property(
|
||||
property_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.patch("/properties/{property_id}", summary="Update a property")
|
||||
async def update_property(
|
||||
property_id: str,
|
||||
request: Request,
|
||||
body: PropertyUpdate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
updates: list[str] = []
|
||||
values: list[Any] = []
|
||||
idx = 1
|
||||
|
||||
def _add(col: str, val: Any, cast: str = ""):
|
||||
nonlocal idx
|
||||
updates.append(f"{col} = ${idx}{cast}")
|
||||
values.append(val)
|
||||
idx += 1
|
||||
|
||||
if body.project_name is not None: _add("project_name", body.project_name)
|
||||
if body.developer_name is not None: _add("developer_name", body.developer_name)
|
||||
if body.location is not None: _add("location", json.dumps(body.location), "::jsonb")
|
||||
if body.property_type is not None: _add("property_type", body.property_type)
|
||||
if body.price_bands is not None: _add("price_bands", json.dumps(body.price_bands), "::jsonb")
|
||||
if body.unit_mix is not None: _add("unit_mix", json.dumps(body.unit_mix), "::jsonb")
|
||||
if body.amenities is not None: _add("amenities", body.amenities)
|
||||
if body.status is not None:
|
||||
if body.status not in VALID_PROPERTY_STATUSES:
|
||||
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}")
|
||||
_add("status", body.status)
|
||||
if body.validation_state is not None:
|
||||
_add("validation_state", json.dumps(body.validation_state), "::jsonb")
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
_add("updated_at", datetime.now(timezone.utc))
|
||||
values.extend([property_id, _tenant_scope(user)])
|
||||
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
f"""
|
||||
UPDATE inventory_properties
|
||||
SET {', '.join(updates)}
|
||||
WHERE property_id=${idx} AND tenant_id=${idx+1}
|
||||
""",
|
||||
*values,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Property not found")
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/properties/{property_id}", summary="Archive a property")
|
||||
async def archive_property(
|
||||
property_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE inventory_properties
|
||||
SET status='archived', updated_at=NOW()
|
||||
WHERE property_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Property not found")
|
||||
return {"status": "archived"}
|
||||
|
||||
|
||||
# ── Media Assets ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/properties/{property_id}/media", status_code=status.HTTP_201_CREATED,
|
||||
summary="Attach media to a property")
|
||||
async def add_media(
|
||||
property_id: str,
|
||||
request: Request,
|
||||
body: MediaAssetCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
if body.media_type not in VALID_MEDIA_TYPES:
|
||||
raise HTTPException(400, f"Invalid media_type. Valid: {sorted(VALID_MEDIA_TYPES)}")
|
||||
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
# Verify property belongs to tenant
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(404, "Property not found")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO inventory_media_assets
|
||||
(property_id, tenant_id, media_type, url, thumbnail_url, sort_order, metadata, uploaded_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
|
||||
RETURNING media_asset_id, created_at
|
||||
""",
|
||||
property_id, _tenant_scope(user), body.media_type, body.url, body.thumbnail_url,
|
||||
body.sort_order, json.dumps(body.metadata), user.user_id,
|
||||
)
|
||||
return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
@router.get("/properties/{property_id}/media", summary="List media for a property")
|
||||
async def list_media(
|
||||
property_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT media_asset_id, media_type, url, thumbnail_url, sort_order, metadata, created_at
|
||||
FROM inventory_media_assets
|
||||
WHERE property_id=$1 AND tenant_id=$2
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
""",
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
return {"media": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/media/{media_asset_id}", summary="Remove a media asset")
|
||||
async def delete_media(
|
||||
media_asset_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2",
|
||||
media_asset_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(404, "Media asset not found")
|
||||
return {"status": "deleted"}
|
||||
682
core/api/api/routes_mobile_edge.py
Normal file
682
core/api/api/routes_mobile_edge.py
Normal file
@@ -0,0 +1,682 @@
|
||||
"""
|
||||
routes_mobile_edge.py
|
||||
─────────────────────
|
||||
Mobile Edge API — serves iPhone Edge and Android Phone Edge apps.
|
||||
|
||||
Surfaces:
|
||||
GET /mobile-edge/events — communication events for a lead
|
||||
POST /mobile-edge/events — log a new communication event
|
||||
GET /mobile-edge/memory — memory facts for a lead
|
||||
POST /mobile-edge/imports — operator-assisted import of a recording/note
|
||||
POST /mobile-edge/notes — quick note attached to a lead
|
||||
GET /mobile-edge/calendar — calendar events for the authed user
|
||||
POST /mobile-edge/calendar — create a calendar event
|
||||
PATCH /mobile-edge/calendar/{id} — update a calendar event
|
||||
DELETE /mobile-edge/calendar/{id} — cancel a calendar event
|
||||
GET /mobile-edge/transcripts/{id} — transcript segments for an event
|
||||
GET /mobile-edge/insights/{lead_id}— insight recommendations for a lead
|
||||
POST /mobile-edge/insights/{id}/act — act on or dismiss an insight
|
||||
GET /mobile-edge/alerts — active alerts for the authed user
|
||||
POST /mobile-edge/session — register a surface session heartbeat
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger("velocity.mobile_edge")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pool(request: Request):
|
||||
pool = request.app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
# ── Pydantic models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_CHANNELS = {
|
||||
"pstn", "whatsapp_message", "whatsapp_voice", "whatsapp_video",
|
||||
"email", "facebook_message", "instagram_message", "in_app_voip", "manual_note",
|
||||
}
|
||||
|
||||
VALID_CAPTURE_MODES = {"direct_api", "provider_routed", "operator_import", "operator_note"}
|
||||
|
||||
VALID_DIRECTIONS = {"inbound", "outbound"}
|
||||
|
||||
VALID_CONSENT = {"unknown", "granted", "denied", "not_required"}
|
||||
|
||||
VALID_CALENDAR_STATUSES = {"tentative", "confirmed", "done", "cancelled"}
|
||||
|
||||
|
||||
class CommunicationEventCreate(BaseModel):
|
||||
lead_id: str
|
||||
channel: str
|
||||
direction: str = "inbound"
|
||||
provider: Optional[str] = None
|
||||
capture_mode: str
|
||||
consent_state: str = "unknown"
|
||||
duration_seconds: Optional[int] = None
|
||||
summary: Optional[str] = None
|
||||
raw_reference: Optional[str] = None
|
||||
recording_ref: Optional[str] = None
|
||||
provider_metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ImportCreate(BaseModel):
|
||||
lead_id: str
|
||||
channel: str
|
||||
capture_mode: str = "operator_import"
|
||||
recording_ref: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
consent_state: str = "granted"
|
||||
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
lead_id: str
|
||||
note_text: str
|
||||
fact_type: str = "custom"
|
||||
effective_date: Optional[str] = None
|
||||
|
||||
|
||||
class CalendarEventCreate(BaseModel):
|
||||
lead_id: Optional[str] = None
|
||||
source_event_id: Optional[str] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
start_at: str # ISO8601
|
||||
end_at: str # ISO8601
|
||||
all_day: bool = False
|
||||
status: str = "confirmed"
|
||||
reminder_minutes: list[int] = Field(default_factory=lambda: [15])
|
||||
location: Optional[str] = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarEventUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
start_at: Optional[str] = None
|
||||
end_at: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
reminder_minutes: Optional[list[int]] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
class InsightActionRequest(BaseModel):
|
||||
action: str = Field(..., pattern="^(accepted|dismissed|acted_upon)$")
|
||||
|
||||
|
||||
class SessionHeartbeat(BaseModel):
|
||||
surface_type: str
|
||||
app_version: str
|
||||
screen: Optional[str] = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Communication Events ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/events", summary="List communication events for a lead")
|
||||
async def list_events(
|
||||
request: Request,
|
||||
lead_id: str = Query(..., description="Lead ID to fetch events for"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Return paginated communication events for a given lead, newest first."""
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT event_id, lead_id, channel, direction, provider, capture_mode,
|
||||
consent_state, timestamp, duration_seconds, summary, raw_reference,
|
||||
recording_ref, provider_metadata, created_at
|
||||
FROM edge_communication_events
|
||||
WHERE tenant_id = $1 AND lead_id = $2
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
_tenant_scope(user),
|
||||
lead_id, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2",
|
||||
_tenant_scope(user), lead_id,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"events": [dict(r) for r in rows],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/events", status_code=status.HTTP_201_CREATED, summary="Log a communication event")
|
||||
async def create_event(
|
||||
request: Request,
|
||||
body: CommunicationEventCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new communication event record.
|
||||
Supports all three capture modes: direct_api, provider_routed, operator_import.
|
||||
"""
|
||||
if body.channel not in VALID_CHANNELS:
|
||||
raise HTTPException(400, f"Invalid channel. Valid: {sorted(VALID_CHANNELS)}")
|
||||
if body.capture_mode not in VALID_CAPTURE_MODES:
|
||||
raise HTTPException(400, f"Invalid capture_mode. Valid: {sorted(VALID_CAPTURE_MODES)}")
|
||||
if body.direction not in VALID_DIRECTIONS:
|
||||
raise HTTPException(400, "direction must be 'inbound' or 'outbound'")
|
||||
if body.consent_state not in VALID_CONSENT:
|
||||
raise HTTPException(400, f"Invalid consent_state. Valid: {sorted(VALID_CONSENT)}")
|
||||
|
||||
pool = _pool(request)
|
||||
import json
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO edge_communication_events (
|
||||
tenant_id, lead_id, channel, direction, provider, capture_mode,
|
||||
consent_state, duration_seconds, summary, raw_reference,
|
||||
recording_ref, provider_metadata
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)
|
||||
RETURNING event_id, created_at
|
||||
""",
|
||||
_tenant_scope(user), body.lead_id, body.channel, body.direction, body.provider,
|
||||
body.capture_mode, body.consent_state, body.duration_seconds,
|
||||
body.summary, body.raw_reference, body.recording_ref,
|
||||
json.dumps(body.provider_metadata),
|
||||
)
|
||||
logger.info("Created communication event %s for lead %s", row["event_id"], body.lead_id)
|
||||
return {"event_id": str(row["event_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
# ── Communication Memory Facts ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/memory", summary="List memory facts for a lead")
|
||||
async def list_memory_facts(
|
||||
request: Request,
|
||||
lead_id: str = Query(...),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT fact_id, lead_id, event_id, fact_type, fact_text,
|
||||
effective_date, confidence, extracted_from, is_confirmed,
|
||||
confirmed_by, confirmed_at, created_at
|
||||
FROM edge_communication_memory_facts
|
||||
WHERE tenant_id = $1 AND lead_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
_tenant_scope(user), lead_id, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_communication_memory_facts WHERE tenant_id=$1 AND lead_id=$2",
|
||||
_tenant_scope(user), lead_id,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "facts": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── Operator-Assisted Import ──────────────────────────────────────────────────
|
||||
|
||||
@router.post("/imports", status_code=status.HTTP_201_CREATED, summary="Operator-assisted import")
|
||||
async def create_import(
|
||||
request: Request,
|
||||
body: ImportCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Mode C import: user uploads recording ref or confirms a note manually.
|
||||
Creates an event with capture_mode = 'operator_import' and triggers a
|
||||
transcription job if a recording_ref is supplied.
|
||||
"""
|
||||
if body.channel not in VALID_CHANNELS:
|
||||
raise HTTPException(400, f"Invalid channel. Valid: {sorted(VALID_CHANNELS)}")
|
||||
|
||||
pool = _pool(request)
|
||||
import json
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
event_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO edge_communication_events (
|
||||
tenant_id, lead_id, channel, direction, capture_mode,
|
||||
consent_state, recording_ref, summary
|
||||
) VALUES ($1,$2,$3,'inbound',$4,$5,$6,$7)
|
||||
RETURNING event_id, created_at
|
||||
""",
|
||||
_tenant_scope(user), body.lead_id, body.channel, body.capture_mode,
|
||||
body.consent_state, body.recording_ref, body.summary,
|
||||
)
|
||||
event_id = event_row["event_id"]
|
||||
|
||||
job_id = None
|
||||
if body.recording_ref:
|
||||
job_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO edge_transcription_jobs (
|
||||
tenant_id, event_id, media_type, consent_state
|
||||
) VALUES ($1,$2,'audio',$3)
|
||||
RETURNING transcription_job_id
|
||||
""",
|
||||
_tenant_scope(user), event_id, body.consent_state,
|
||||
)
|
||||
job_id = str(job_row["transcription_job_id"])
|
||||
|
||||
return {
|
||||
"event_id": str(event_id),
|
||||
"transcription_job_id": job_id,
|
||||
"created_at": str(event_row["created_at"]),
|
||||
}
|
||||
|
||||
|
||||
# ── Quick Notes ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/notes", status_code=status.HTTP_201_CREATED, summary="Create a quick note for a lead")
|
||||
async def create_note(
|
||||
request: Request,
|
||||
body: NoteCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a manual memory fact from an operator note.
|
||||
No event is created — this is a direct fact insertion.
|
||||
"""
|
||||
pool = _pool(request)
|
||||
from datetime import date
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO edge_communication_memory_facts (
|
||||
tenant_id, lead_id, fact_type, fact_text, effective_date,
|
||||
extracted_from, confidence, is_confirmed
|
||||
) VALUES ($1,$2,$3,$4,$5,'operator_note',1.0, TRUE)
|
||||
RETURNING fact_id, created_at
|
||||
""",
|
||||
_tenant_scope(user), body.lead_id, body.fact_type, body.note_text,
|
||||
body.effective_date,
|
||||
)
|
||||
return {"fact_id": str(row["fact_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
# ── Calendar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/calendar", summary="Get calendar events for the authed user")
|
||||
async def list_calendar_events(
|
||||
request: Request,
|
||||
from_date: Optional[str] = Query(None),
|
||||
to_date: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
if from_date and to_date:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
AND start_at >= $3::timestamptz AND end_at <= $4::timestamptz
|
||||
ORDER BY start_at ASC LIMIT $5
|
||||
""",
|
||||
_tenant_scope(user), user.user_id, from_date, to_date, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
ORDER BY start_at ASC LIMIT $3
|
||||
""",
|
||||
_tenant_scope(user), user.user_id, limit,
|
||||
)
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/calendar", status_code=status.HTTP_201_CREATED, summary="Create a calendar event")
|
||||
async def create_calendar_event(
|
||||
request: Request,
|
||||
body: CalendarEventCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
import json
|
||||
if body.status not in VALID_CALENDAR_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO user_calendar_events (
|
||||
tenant_id, owner_user_id, lead_id, source_event_id, title, description,
|
||||
start_at, end_at, all_day, status, reminder_minutes, created_by, location, metadata
|
||||
) VALUES (
|
||||
$1::text,$2::text,$3::text,$4::uuid,$5::text,$6::text,
|
||||
$7::timestamptz,$8::timestamptz,$9::boolean,$10::text,
|
||||
$11::integer[],$12::text,$13::text,$14::jsonb
|
||||
)
|
||||
RETURNING calendar_event_id, lead_id, title, description, start_at, end_at,
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
""",
|
||||
_tenant_scope(user), user.user_id, body.lead_id, body.source_event_id,
|
||||
body.title, body.description, body.start_at, body.end_at,
|
||||
body.all_day, body.status, body.reminder_minutes, "user",
|
||||
body.location, json.dumps(body.metadata),
|
||||
)
|
||||
event = dict(row)
|
||||
event["calendar_event_id"] = str(event["calendar_event_id"])
|
||||
for key in ("start_at", "end_at", "created_at"):
|
||||
if event.get(key) is not None and hasattr(event[key], "isoformat"):
|
||||
event[key] = event[key].isoformat()
|
||||
return {"status": "ok", "event": event}
|
||||
|
||||
|
||||
@router.patch("/calendar/{calendar_event_id}", summary="Update a calendar event")
|
||||
async def update_calendar_event(
|
||||
calendar_event_id: str,
|
||||
request: Request,
|
||||
body: CalendarEventUpdate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
# Build partial update
|
||||
updates: list[str] = []
|
||||
values: list[Any] = []
|
||||
idx = 1
|
||||
|
||||
def _add(col: str, val: Any):
|
||||
nonlocal idx
|
||||
updates.append(f"{col} = ${idx}")
|
||||
values.append(val)
|
||||
idx += 1
|
||||
|
||||
if body.title is not None: _add("title", body.title)
|
||||
if body.description is not None: _add("description", body.description)
|
||||
if body.start_at is not None: _add("start_at", body.start_at)
|
||||
if body.end_at is not None: _add("end_at", body.end_at)
|
||||
if body.status is not None:
|
||||
if body.status not in VALID_CALENDAR_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
|
||||
_add("status", body.status)
|
||||
if body.reminder_minutes is not None: _add("reminder_minutes", body.reminder_minutes)
|
||||
if body.location is not None: _add("location", body.location)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
_add("updated_at", datetime.now(timezone.utc))
|
||||
_add("tenant_id", _tenant_scope(user))
|
||||
_add("owner_user_id", user.user_id)
|
||||
values.append(calendar_event_id)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
f"""
|
||||
UPDATE user_calendar_events
|
||||
SET {', '.join(updates)}
|
||||
WHERE tenant_id=${idx} AND owner_user_id=${idx+1} AND calendar_event_id=${idx+2}
|
||||
""",
|
||||
*values,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Calendar event not found or not owned by you")
|
||||
return {"status": "updated", "calendar_event_id": calendar_event_id}
|
||||
|
||||
|
||||
@router.delete("/calendar/{calendar_event_id}", summary="Cancel a calendar event")
|
||||
async def delete_calendar_event(
|
||||
calendar_event_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE user_calendar_events
|
||||
SET status='cancelled', updated_at=NOW()
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2 AND calendar_event_id=$3
|
||||
""",
|
||||
_tenant_scope(user), user.user_id, calendar_event_id,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Calendar event not found or not owned by you")
|
||||
return {"status": "cancelled"}
|
||||
|
||||
|
||||
# ── Transcripts ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/transcripts/{event_id}", summary="Get transcript segments for an event")
|
||||
async def get_transcript(
|
||||
event_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
job = await conn.fetchrow(
|
||||
"""
|
||||
SELECT j.transcription_job_id, j.status, j.provider, j.speaker_count,
|
||||
j.word_count, j.language, j.completed_at
|
||||
FROM edge_transcription_jobs j
|
||||
JOIN edge_communication_events e ON e.event_id = j.event_id
|
||||
WHERE j.event_id = $1 AND e.tenant_id = $2
|
||||
ORDER BY j.created_at DESC LIMIT 1
|
||||
""",
|
||||
event_id, _tenant_scope(user),
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(404, "No transcription job found for this event")
|
||||
|
||||
segments = await conn.fetch(
|
||||
"""
|
||||
SELECT segment_id, speaker_label, start_ms, end_ms, text, confidence, is_agent_turn
|
||||
FROM edge_transcript_segments
|
||||
WHERE transcription_job_id = $1
|
||||
ORDER BY start_ms ASC
|
||||
""",
|
||||
job["transcription_job_id"],
|
||||
)
|
||||
|
||||
return {
|
||||
"job": dict(job),
|
||||
"segments": [dict(s) for s in segments],
|
||||
}
|
||||
|
||||
|
||||
# ── Insights ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/insights/{lead_id}", summary="Get insight recommendations for a lead")
|
||||
async def get_insights(
|
||||
lead_id: str,
|
||||
request: Request,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
if status_filter:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT recommendation_id, lead_id, source_event_id, recommendation_type,
|
||||
summary, suggested_action, target_system, status, confidence, created_at
|
||||
FROM insight_recommendations
|
||||
WHERE tenant_id=$1 AND lead_id=$2 AND status=$3
|
||||
ORDER BY created_at DESC LIMIT $4
|
||||
""",
|
||||
_tenant_scope(user), lead_id, status_filter, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT recommendation_id, lead_id, source_event_id, recommendation_type,
|
||||
summary, suggested_action, target_system, status, confidence, created_at
|
||||
FROM insight_recommendations
|
||||
WHERE tenant_id=$1 AND lead_id=$2
|
||||
ORDER BY created_at DESC LIMIT $3
|
||||
""",
|
||||
_tenant_scope(user), lead_id, limit,
|
||||
)
|
||||
return {"insights": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/insights/{recommendation_id}/act", summary="Act on or dismiss an insight")
|
||||
async def act_on_insight(
|
||||
recommendation_id: str,
|
||||
request: Request,
|
||||
body: InsightActionRequest,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE insight_recommendations
|
||||
SET status=$1, acted_by=$2, acted_at=NOW(), updated_at=NOW()
|
||||
WHERE recommendation_id=$3 AND tenant_id=$4
|
||||
""",
|
||||
body.action, user.user_id, recommendation_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Insight not found")
|
||||
return {"status": body.action}
|
||||
|
||||
|
||||
# ── Alerts ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/alerts", summary="Get active alerts for the authed user")
|
||||
async def get_alerts(
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Returns a combined, prioritized view of:
|
||||
- Pending insights needing action
|
||||
- Calendar events due within 24 hours
|
||||
- Pending transcription jobs
|
||||
"""
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
pending_insights = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
|
||||
_tenant_scope(user),
|
||||
)
|
||||
upcoming_events = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*) FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status='confirmed'
|
||||
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
|
||||
""",
|
||||
_tenant_scope(user), user.user_id,
|
||||
)
|
||||
pending_transcriptions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
|
||||
_tenant_scope(user),
|
||||
)
|
||||
|
||||
return {
|
||||
"pending_insights": pending_insights,
|
||||
"upcoming_calendar_events_24h": upcoming_events,
|
||||
"pending_transcriptions": pending_transcriptions,
|
||||
"generated_at": _now(),
|
||||
}
|
||||
|
||||
|
||||
# ── Session Heartbeat ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/session", status_code=status.HTTP_200_OK, summary="Register surface session heartbeat")
|
||||
async def session_heartbeat(
|
||||
request: Request,
|
||||
body: SessionHeartbeat,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Upsert a surface session to track cross-surface activity."""
|
||||
valid_surfaces = {
|
||||
"webos", "ipad", "android_tablet", "iphone_edge", "android_phone_edge",
|
||||
}
|
||||
if body.surface_type not in valid_surfaces:
|
||||
raise HTTPException(400, f"Invalid surface_type. Valid: {sorted(valid_surfaces)}")
|
||||
|
||||
pool = _pool(request)
|
||||
import json
|
||||
async with pool.acquire() as conn:
|
||||
existing_session_id = await conn.fetchval(
|
||||
"""
|
||||
SELECT session_id
|
||||
FROM surface_sessions
|
||||
WHERE tenant_id=$1 AND user_id=$2 AND surface_type=$3
|
||||
AND ended_at IS NULL
|
||||
AND last_active_at > NOW() - INTERVAL '30 minutes'
|
||||
ORDER BY last_active_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
_tenant_scope(user), user.user_id, body.surface_type,
|
||||
)
|
||||
|
||||
if existing_session_id:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE surface_sessions
|
||||
SET last_active_at=NOW(),
|
||||
app_version=$1,
|
||||
metadata=$2::jsonb,
|
||||
screen_sequence = CASE
|
||||
WHEN $3::text IS NULL THEN screen_sequence
|
||||
ELSE array_append(screen_sequence, $3::text)
|
||||
END
|
||||
WHERE session_id=$4
|
||||
""",
|
||||
body.app_version, json.dumps(body.metadata), body.screen, existing_session_id,
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO surface_sessions (
|
||||
tenant_id, user_id, surface_type, app_version, metadata, screen_sequence
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5::jsonb,
|
||||
CASE
|
||||
WHEN $6::text IS NULL THEN '{}'::text[]
|
||||
ELSE ARRAY[$6::text]
|
||||
END
|
||||
)
|
||||
""",
|
||||
_tenant_scope(user), user.user_id, body.surface_type, body.app_version,
|
||||
json.dumps(body.metadata), body.screen,
|
||||
)
|
||||
return {"status": "ok", "timestamp": _now()}
|
||||
24
core/api/api/routes_observability.py
Normal file
24
core/api/api/routes_observability.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.observability import metrics_snapshot
|
||||
|
||||
router = APIRouter(prefix="/observability", tags=["Observability"])
|
||||
|
||||
|
||||
@router.get("/request-metrics")
|
||||
async def request_metrics(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"tenant_id": user.tenant_id,
|
||||
"metrics": metrics_snapshot(request.app, limit=limit),
|
||||
},
|
||||
}
|
||||
|
||||
146
core/api/api/routes_oracle.py
Normal file
146
core/api/api/routes_oracle.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.oracle.action_service import oracle_action_service
|
||||
from backend.oracle.natural_db_agent import natural_db_agent
|
||||
from backend.oracle.persona_service import persona_service
|
||||
from backend.services.mcp_registry import mcp_registry
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
from backend.services.runtime_llm_service import runtime_llm_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WorkflowPreviewRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
tenant_id: str = "tenant_velocity"
|
||||
actor_role: str = "sales_director"
|
||||
|
||||
|
||||
class MCPExecuteRequest(BaseModel):
|
||||
tool_name: str = Field(..., min_length=1, max_length=128)
|
||||
query: str = Field(..., min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class OracleWritebackRequest(BaseModel):
|
||||
action_id: str
|
||||
tenant_id: str = "tenant_velocity"
|
||||
actor_id: str = "oracle_operator"
|
||||
target_entity_type: str = Field(..., min_length=1, max_length=64)
|
||||
target_entity_id: str = Field(..., min_length=1, max_length=128)
|
||||
action_type: str = Field(default="lead_writeback", min_length=1, max_length=128)
|
||||
writeback_payload: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OracleQueryRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
row_limit: int = Field(default=100, ge=1, le=500)
|
||||
context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def oracle_health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"persona": await persona_service.health(),
|
||||
"mcp_tools": mcp_registry.list_tools(),
|
||||
"runtime_llm": await runtime_llm_service.list_providers(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/data-health")
|
||||
async def oracle_data_health(request: Request) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
data = await natural_db_agent.data_health(conn)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/schema-catalog")
|
||||
async def oracle_schema_catalog(request: Request) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
catalog = await natural_db_agent.schema_catalog(conn)
|
||||
return {"status": "ok", "data": catalog}
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
async def oracle_query(request: Request, payload: OracleQueryRequest) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
async with pool.acquire() as conn:
|
||||
result = await natural_db_agent.execute_prompt(payload.prompt, row_limit=payload.row_limit, conn=conn)
|
||||
return {"status": "ok", "data": result.as_dict()}
|
||||
|
||||
|
||||
@router.get("/mcp/tools")
|
||||
async def oracle_mcp_tools() -> dict:
|
||||
return {"status": "ok", "data": mcp_registry.list_tools()}
|
||||
|
||||
|
||||
@router.post("/mcp/execute")
|
||||
async def oracle_mcp_execute(request: Request, payload: MCPExecuteRequest) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
result = await mcp_registry.execute(payload.tool_name, payload.query, crm_pool=pool)
|
||||
return {"status": "ok", "data": result}
|
||||
|
||||
|
||||
@router.post("/workflow/preview")
|
||||
async def workflow_preview(payload: WorkflowPreviewRequest) -> dict:
|
||||
persona_plan = await persona_service.plan_for_prompt(
|
||||
prompt=payload.prompt,
|
||||
tenant_id=payload.tenant_id,
|
||||
actor_role=payload.actor_role,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"persona_plan": persona_plan,
|
||||
"workflow": nemoclaw_runtime.build_workflow_dispatch(
|
||||
prompt=payload.prompt,
|
||||
tenant_id=payload.tenant_id,
|
||||
actor_role=payload.actor_role,
|
||||
component_templates=persona_plan["recommendedTemplates"],
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
async def list_oracle_actions(status: str | None = None, limit: int = 50) -> dict:
|
||||
actions = await oracle_action_service.list_actions(status=status, limit=limit)
|
||||
return {"status": "ok", "data": actions, "meta": {"count": len(actions)}}
|
||||
|
||||
|
||||
@router.get("/actions/{action_id}")
|
||||
async def get_oracle_action(action_id: str) -> dict:
|
||||
action = await oracle_action_service.get_action(action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail=f"Oracle action '{action_id}' not found.")
|
||||
return {"status": "ok", "data": action}
|
||||
|
||||
|
||||
@router.post("/actions/writeback")
|
||||
async def apply_oracle_writeback(request: Request, payload: OracleWritebackRequest) -> dict:
|
||||
result = await oracle_action_service.apply_writeback(payload.model_dump())
|
||||
if hasattr(request.app.state, "broadcast_crm_event"):
|
||||
await request.app.state.broadcast_crm_event(
|
||||
{
|
||||
"type": "oracle_writeback",
|
||||
"entity": payload.target_entity_type,
|
||||
"entity_id": payload.target_entity_id,
|
||||
"action_id": payload.action_id,
|
||||
"payload": result["resultPayload"],
|
||||
}
|
||||
)
|
||||
return {"status": "ok", "data": result}
|
||||
404
core/api/api/routes_oracle_templates.py
Normal file
404
core/api/api/routes_oracle_templates.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
routes_oracle_templates.py
|
||||
──────────────────────────
|
||||
Oracle Template Catalog API
|
||||
|
||||
Extends the existing Oracle route surface with template taxonomy and seeding.
|
||||
|
||||
Endpoints:
|
||||
GET /oracle/template-chapters — list chapters
|
||||
POST /oracle/template-chapters — create a chapter
|
||||
GET /oracle/template-subchapters — list subchapters (optionally filtered)
|
||||
POST /oracle/template-subchapters — create a subchapter
|
||||
GET /oracle/component-templates — list templates (filterable)
|
||||
POST /oracle/component-templates — create a template
|
||||
GET /oracle/component-templates/{id} — get a template
|
||||
POST /oracle/component-templates/{id}/seed — add a seed example
|
||||
GET /oracle/component-templates/{id}/seed — list seed examples for a template
|
||||
POST /oracle/component-templates/synthetic-jobs — trigger a Kimi synthetic job
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger("velocity.oracle_templates")
|
||||
|
||||
router = APIRouter()
|
||||
_DEFAULT_TENANT_ID = os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pool(request: Request):
|
||||
pool = request.app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(503, "Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
def _tenant_id() -> str:
|
||||
return _DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ChapterCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class SubchapterCreate(BaseModel):
|
||||
chapter_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
chapter_id: Optional[str] = None
|
||||
subchapter_id: Optional[str] = None
|
||||
component_type: Optional[str] = None
|
||||
accepted_shapes: list[str] = Field(default_factory=list)
|
||||
json_template: Optional[dict] = None
|
||||
description: Optional[str] = None
|
||||
origin: str = "premade"
|
||||
version: str = "1.0.0"
|
||||
|
||||
|
||||
class SeedExampleCreate(BaseModel):
|
||||
title: str
|
||||
example_json: dict
|
||||
quality_notes: Optional[str] = None
|
||||
chapter_id: Optional[str] = None
|
||||
subchapter_id: Optional[str] = None
|
||||
is_canonical: bool = False
|
||||
|
||||
|
||||
class SyntheticJobCreate(BaseModel):
|
||||
template_id: str
|
||||
chapter_id: Optional[str] = None
|
||||
subchapter_id: Optional[str] = None
|
||||
model: str = "kimi"
|
||||
requested_count: int = Field(10, ge=1, le=500)
|
||||
|
||||
|
||||
# ── Template Chapters ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/template-chapters", summary="List Oracle template chapters")
|
||||
async def list_template_chapters(
|
||||
request: Request,
|
||||
include_inactive: bool = Query(False),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
where = "WHERE ch.tenant_id=$1" + ("" if include_inactive else " AND ch.is_active=TRUE")
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active,
|
||||
COUNT(sub.subchapter_id) FILTER (WHERE sub.is_active=TRUE) as subchapter_count,
|
||||
COUNT(t.template_id) as template_count
|
||||
FROM oracle_template_chapters ch
|
||||
LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id
|
||||
LEFT JOIN oracle_component_templates t ON t.chapter_id = ch.chapter_id
|
||||
AND t.status != 'archived'
|
||||
{where}
|
||||
GROUP BY ch.chapter_id
|
||||
ORDER BY ch.sort_order ASC
|
||||
""",
|
||||
_tenant_id(),
|
||||
)
|
||||
return {"chapters": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/template-chapters", status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a template chapter")
|
||||
async def create_template_chapter(
|
||||
request: Request,
|
||||
body: ChapterCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_template_chapters (tenant_id, name, description, sort_order)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
RETURNING chapter_id, created_at
|
||||
""",
|
||||
_tenant_id(), body.name, body.description, body.sort_order,
|
||||
)
|
||||
return {"chapter_id": str(row["chapter_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
# ── Template Subchapters ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/template-subchapters", summary="List Oracle template subchapters")
|
||||
async def list_template_subchapters(
|
||||
request: Request,
|
||||
chapter_id: Optional[str] = Query(None),
|
||||
include_inactive: bool = Query(False),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
where = "WHERE sub.tenant_id=$1"
|
||||
params: list[Any] = [_tenant_id()]
|
||||
idx = 2
|
||||
if not include_inactive:
|
||||
where += " AND sub.is_active=TRUE"
|
||||
if chapter_id:
|
||||
where += f" AND sub.chapter_id=${idx}"; params.append(chapter_id); idx += 1
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT sub.subchapter_id, sub.chapter_id, ch.name as chapter_name,
|
||||
sub.name, sub.description, sub.sort_order, sub.is_active,
|
||||
COUNT(t.template_id) as template_count
|
||||
FROM oracle_template_subchapters sub
|
||||
JOIN oracle_template_chapters ch ON ch.chapter_id = sub.chapter_id
|
||||
LEFT JOIN oracle_component_templates t ON t.subchapter_id = sub.subchapter_id
|
||||
AND t.status != 'archived'
|
||||
{where}
|
||||
GROUP BY sub.subchapter_id, ch.name
|
||||
ORDER BY sub.chapter_id, sub.sort_order ASC
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return {"subchapters": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/template-subchapters", status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a template subchapter")
|
||||
async def create_template_subchapter(
|
||||
request: Request,
|
||||
body: SubchapterCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
# Verify chapter exists and belongs to tenant
|
||||
ch_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM oracle_template_chapters WHERE chapter_id=$1 AND tenant_id=$2",
|
||||
body.chapter_id, _tenant_id(),
|
||||
)
|
||||
if not ch_exists:
|
||||
raise HTTPException(404, "Chapter not found")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_template_subchapters
|
||||
(chapter_id, tenant_id, name, description, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING subchapter_id, created_at
|
||||
""",
|
||||
body.chapter_id, _tenant_id(), body.name, body.description, body.sort_order,
|
||||
)
|
||||
return {"subchapter_id": str(row["subchapter_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
# ── Component Templates ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/component-templates", summary="List Oracle component templates")
|
||||
async def list_component_templates(
|
||||
request: Request,
|
||||
chapter_id: Optional[str] = Query(None),
|
||||
subchapter_id: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
where = "WHERE t.tenant_id=$1"
|
||||
params: list[Any] = [_tenant_id()]
|
||||
idx = 2
|
||||
|
||||
if chapter_id:
|
||||
where += f" AND t.chapter_id=${idx}"; params.append(chapter_id); idx += 1
|
||||
if subchapter_id:
|
||||
where += f" AND t.subchapter_id=${idx}"; params.append(subchapter_id); idx += 1
|
||||
if status_filter:
|
||||
where += f" AND t.status=${idx}"; params.append(status_filter); idx += 1
|
||||
if search:
|
||||
where += f" AND (t.name ILIKE ${idx} OR t.description ILIKE ${idx})"
|
||||
params.append(f"%{search}%"); idx += 1
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT t.template_id, t.name, t.category, t.status, t.origin, t.version,
|
||||
t.accepted_shapes, t.use_count, t.chapter_id, t.subchapter_id,
|
||||
t.description, ch.name as chapter_name, sub.name as subchapter_name,
|
||||
t.created_at, t.updated_at
|
||||
FROM oracle_component_templates t
|
||||
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
|
||||
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
|
||||
{where}
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT ${idx} OFFSET ${idx+1}
|
||||
""",
|
||||
*params, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM oracle_component_templates t {where}", *params,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/component-templates", status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a component template")
|
||||
async def create_component_template(
|
||||
request: Request,
|
||||
body: TemplateCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_component_templates (
|
||||
tenant_id, name, category, chapter_id, subchapter_id,
|
||||
accepted_shapes, json_template, description, origin, version, status
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,'draft')
|
||||
RETURNING template_id, created_at
|
||||
""",
|
||||
_tenant_id(), body.name, body.category, body.chapter_id, body.subchapter_id,
|
||||
body.accepted_shapes,
|
||||
json.dumps(body.json_template) if body.json_template else None,
|
||||
body.description, body.origin, body.version,
|
||||
)
|
||||
return {"template_id": str(row["template_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
@router.get("/component-templates/{template_id}", summary="Get a component template")
|
||||
async def get_component_template(
|
||||
template_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT t.*, ch.name as chapter_name, sub.name as subchapter_name
|
||||
FROM oracle_component_templates t
|
||||
LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id
|
||||
LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id
|
||||
WHERE t.template_id=$1 AND t.tenant_id=$2
|
||||
""",
|
||||
template_id, _tenant_id(),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ── Seed Examples ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/component-templates/{template_id}/seed", status_code=status.HTTP_201_CREATED,
|
||||
summary="Add a seed example to a template")
|
||||
async def add_seed_example(
|
||||
template_id: str,
|
||||
request: Request,
|
||||
body: SeedExampleCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM oracle_component_templates WHERE template_id=$1 AND tenant_id=$2",
|
||||
template_id, _tenant_id(),
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(404, "Template not found")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_template_seed_examples (
|
||||
template_id, chapter_id, subchapter_id, title, example_json,
|
||||
quality_notes, is_canonical
|
||||
) VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)
|
||||
RETURNING example_id, created_at
|
||||
""",
|
||||
template_id, body.chapter_id, body.subchapter_id, body.title,
|
||||
json.dumps(body.example_json), body.quality_notes, body.is_canonical,
|
||||
)
|
||||
return {"example_id": str(row["example_id"]), "created_at": str(row["created_at"])}
|
||||
|
||||
|
||||
@router.get("/component-templates/{template_id}/seed", summary="List seed examples for a template")
|
||||
async def list_seed_examples(
|
||||
template_id: str,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT example_id, title, example_json, quality_notes, is_canonical, created_at
|
||||
FROM oracle_template_seed_examples
|
||||
WHERE template_id=$1
|
||||
ORDER BY is_canonical DESC, created_at ASC
|
||||
""",
|
||||
template_id,
|
||||
)
|
||||
return {"examples": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── Synthetic Jobs ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/component-templates/synthetic-jobs", status_code=status.HTTP_201_CREATED,
|
||||
summary="Trigger a Kimi synthetic data generation job")
|
||||
async def trigger_synthetic_job(
|
||||
request: Request,
|
||||
body: SyntheticJobCreate,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Queues a Kimi synthetic data expansion job for a template.
|
||||
The job will be picked up by the background synthetic generation worker.
|
||||
"""
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM oracle_component_templates WHERE template_id=$1 AND tenant_id=$2",
|
||||
body.template_id, _tenant_id(),
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(404, "Template not found")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO oracle_synthetic_generation_jobs (
|
||||
tenant_id, template_id, chapter_id, subchapter_id,
|
||||
model, requested_count, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING job_id, status, created_at
|
||||
""",
|
||||
_tenant_id(), body.template_id, body.chapter_id, body.subchapter_id,
|
||||
body.model, body.requested_count, user.user_id,
|
||||
)
|
||||
logger.info(
|
||||
"Synthetic job queued: %s for template %s (%d examples)",
|
||||
row["job_id"], body.template_id, body.requested_count,
|
||||
)
|
||||
return {
|
||||
"job_id": str(row["job_id"]),
|
||||
"status": row["status"],
|
||||
"created_at": str(row["created_at"]),
|
||||
}
|
||||
140
core/api/api/routes_runtime_llm.py
Normal file
140
core/api/api/routes_runtime_llm.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.runtime_llm_service import runtime_llm_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str = Field(..., pattern="^(system|user|assistant)$")
|
||||
content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class RuntimeChatRequest(BaseModel):
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
system_prompt: str | None = None
|
||||
messages: list[ChatMessage]
|
||||
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
|
||||
response_format: str | None = Field(default=None, pattern="^(json|text)$")
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class BatchItemRequest(BaseModel):
|
||||
request_id: str
|
||||
messages: list[ChatMessage]
|
||||
system_prompt: str | None = None
|
||||
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
|
||||
response_format: str | None = Field(default=None, pattern="^(json|text)$")
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RuntimeBatchRequest(BaseModel):
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
job_type: str = Field(..., min_length=1, max_length=128)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
items: list[BatchItemRequest] = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
def _normalize_user(user: UserPrincipal) -> dict[str, str]:
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"role": user.role,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/providers", summary="List configured runtime LLM providers and models")
|
||||
async def list_runtime_providers(_: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||
return {"status": "ok", "data": await runtime_llm_service.list_providers()}
|
||||
|
||||
|
||||
@router.post("/chat", summary="Execute a single runtime LLM chat completion")
|
||||
async def runtime_chat(
|
||||
payload: RuntimeChatRequest,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
response = await runtime_llm_service.chat(
|
||||
provider_id=payload.provider,
|
||||
model=payload.model,
|
||||
system_prompt=payload.system_prompt,
|
||||
messages=[message.model_dump() for message in payload.messages],
|
||||
temperature=payload.temperature,
|
||||
response_format=payload.response_format,
|
||||
metadata={
|
||||
**payload.metadata,
|
||||
"requested_by": _normalize_user(user),
|
||||
},
|
||||
)
|
||||
return {"status": "ok", "data": response}
|
||||
|
||||
|
||||
@router.post("/batch", status_code=status.HTTP_202_ACCEPTED, summary="Submit a persisted runtime LLM batch job")
|
||||
async def runtime_batch(
|
||||
payload: RuntimeBatchRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
result = await runtime_llm_service.submit_batch(
|
||||
provider_id=payload.provider,
|
||||
model=payload.model,
|
||||
job_type=payload.job_type,
|
||||
items=[item.model_dump() for item in payload.items],
|
||||
metadata={
|
||||
**payload.metadata,
|
||||
"requested_by": _normalize_user(user),
|
||||
},
|
||||
pool=pool,
|
||||
actor_id=user.user_id,
|
||||
)
|
||||
return {"status": "ok", "data": result}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", summary="Get runtime LLM batch job status")
|
||||
async def get_runtime_job(
|
||||
job_id: str,
|
||||
request: Request,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
job = await runtime_llm_service.get_job(job_id, pool=pool)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail=f"Runtime LLM job '{job_id}' not found.")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"job_id": job["job_id"],
|
||||
"status": job["status"],
|
||||
"provider": job["provider"],
|
||||
"model": job["model"],
|
||||
"job_type": job["job_type"],
|
||||
"submitted_count": job["submitted_count"],
|
||||
"completed_count": job["completed_count"],
|
||||
"failed_count": job["failed_count"],
|
||||
"created_at": job["created_at"],
|
||||
"started_at": job["started_at"],
|
||||
"completed_at": job["completed_at"],
|
||||
"metadata": job.get("metadata") or {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/results", summary="Get runtime LLM batch job item results")
|
||||
async def get_runtime_job_results(
|
||||
job_id: str,
|
||||
request: Request,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
results = await runtime_llm_service.list_job_results(job_id, pool=pool)
|
||||
if results is None:
|
||||
raise HTTPException(status_code=404, detail=f"Runtime LLM job '{job_id}' not found.")
|
||||
return {"status": "ok", "data": results, "meta": {"count": len(results)}}
|
||||
0
core/api/api/routes_weaver.py
Normal file
0
core/api/api/routes_weaver.py
Normal file
1
core/auth/auth/__init__.py
Normal file
1
core/auth/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.auth package"""
|
||||
153
core/auth/auth/dependencies.py
Normal file
153
core/auth/auth/dependencies.py
Normal 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
105
core/auth/auth/routes.py
Normal 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
123
core/auth/auth/service.py
Normal 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
|
||||
]
|
||||
45
core/auth/auth/user_directory.py
Normal file
45
core/auth/auth/user_directory.py
Normal 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
1
core/db/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.db package"""
|
||||
58
core/db/db/pool.py
Normal file
58
core/db/db/pool.py
Normal 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
181
core/db/db/schema.sql
Normal 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);
|
||||
85
core/db/db/schema_addendum.sql
Normal file
85
core/db/db/schema_addendum.sql
Normal 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
100
core/db/db/schema_comms.sql
Normal 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;
|
||||
923
core/db/db/schema_crm_canonical.sql
Normal file
923
core/db/db/schema_crm_canonical.sql
Normal 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
141
core/db/seed_test_users.py
Normal 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
278
core/main.py
Normal 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(),
|
||||
}
|
||||
2
core/migrations/migrations/__init__.py
Normal file
2
core/migrations/migrations/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Velocity backend migration utilities."""
|
||||
|
||||
102
core/migrations/migrations/runner.py
Normal file
102
core/migrations/migrations/runner.py
Normal 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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'));
|
||||
34
core/nemoclaw_prompts/nemoclaw_prompts/cctv_profiler.md
Normal file
34
core/nemoclaw_prompts/nemoclaw_prompts/cctv_profiler.md
Normal 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.
|
||||
32
core/nemoclaw_prompts/nemoclaw_prompts/lead_tagger.md
Normal file
32
core/nemoclaw_prompts/nemoclaw_prompts/lead_tagger.md
Normal 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.
|
||||
54
core/nemoclaw_prompts/nemoclaw_prompts/qd_calculator.md
Normal file
54
core/nemoclaw_prompts/nemoclaw_prompts/qd_calculator.md
Normal 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
103
core/observability.py
Normal 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]
|
||||
|
||||
1
core/oracle/oracle/__init__.py
Normal file
1
core/oracle/oracle/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Oracle services package
|
||||
361
core/oracle/oracle/action_service.py
Normal file
361
core/oracle/oracle/action_service.py
Normal 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()
|
||||
780
core/oracle/oracle/canvas_service.py
Normal file
780
core/oracle/oracle/canvas_service.py
Normal 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()
|
||||
353
core/oracle/oracle/codebook_service.py
Normal file
353
core/oracle/oracle/codebook_service.py
Normal 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()
|
||||
430
core/oracle/oracle/collaboration_service.py
Normal file
430
core/oracle/oracle/collaboration_service.py
Normal 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()
|
||||
484
core/oracle/oracle/data_access_gateway.py
Normal file
484
core/oracle/oracle/data_access_gateway.py
Normal 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()
|
||||
202
core/oracle/oracle/execution_profiler.py
Normal file
202
core/oracle/oracle/execution_profiler.py
Normal 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()
|
||||
591
core/oracle/oracle/natural_db_agent.py
Normal file
591
core/oracle/oracle/natural_db_agent.py
Normal 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()
|
||||
153597
core/oracle/oracle/oracle_runtime_codebook_merged.json
Normal file
153597
core/oracle/oracle/oracle_runtime_codebook_merged.json
Normal file
File diff suppressed because it is too large
Load Diff
497
core/oracle/oracle/oracle_template_seed_db.json
Normal file
497
core/oracle/oracle/oracle_template_seed_db.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
97
core/oracle/oracle/persona_service.py
Normal file
97
core/oracle/oracle/persona_service.py
Normal 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()
|
||||
436
core/oracle/oracle/plan_verifier.py
Normal file
436
core/oracle/oracle/plan_verifier.py
Normal 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()
|
||||
225
core/oracle/oracle/policy_service.py
Normal file
225
core/oracle/oracle/policy_service.py
Normal 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,
|
||||
)
|
||||
1321
core/oracle/oracle/prompt_orchestrator.py
Normal file
1321
core/oracle/oracle/prompt_orchestrator.py
Normal file
File diff suppressed because it is too large
Load Diff
554
core/oracle/oracle/router_v1.py
Normal file
554
core/oracle/oracle/router_v1.py
Normal 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 ───────────────────────────────────────────────────
|
||||
|
||||
350
core/oracle/oracle/schema_extension_v2.sql
Normal file
350
core/oracle/oracle/schema_extension_v2.sql
Normal 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);
|
||||
236
core/oracle/oracle/schema_oracle.sql
Normal file
236
core/oracle/oracle/schema_oracle.sql
Normal 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);
|
||||
512
core/oracle/oracle/semantic_catalog.py
Normal file
512
core/oracle/oracle/semantic_catalog.py
Normal 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=(",", ":"),
|
||||
)
|
||||
382
core/oracle/oracle/visualization_planner.py
Normal file
382
core/oracle/oracle/visualization_planner.py
Normal 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
20
core/requirements.txt
Normal 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
|
||||
1
core/routers/routers/__init__.py
Normal file
1
core/routers/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.routers package"""
|
||||
142
core/routers/routers/cctv.py
Normal file
142
core/routers/routers/cctv.py
Normal 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,
|
||||
}
|
||||
102
core/routers/routers/scenes.py
Normal file
102
core/routers/routers/scenes.py
Normal 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],
|
||||
}
|
||||
805
core/routers/routers/sentinel.py
Normal file
805
core/routers/routers/sentinel.py
Normal 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")
|
||||
190
core/routers/routers/vault.py
Normal file
190
core/routers/routers/vault.py
Normal 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)
|
||||
109
core/routers/routers/videos.py
Normal file
109
core/routers/routers/videos.py
Normal 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}
|
||||
1
core/services/services/__init__.py
Normal file
1
core/services/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.services package"""
|
||||
520
core/services/services/ad_network_service.py
Normal file
520
core/services/services/ad_network_service.py
Normal 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()
|
||||
217
core/services/services/auto_mode_matcher.py
Normal file
217
core/services/services/auto_mode_matcher.py
Normal 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,
|
||||
)
|
||||
3
core/services/services/client_graph/__init__.py
Normal file
3
core/services/services/client_graph/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
backend/services/client_graph/__init__.py
|
||||
"""
|
||||
428
core/services/services/client_graph/aggregation_service.py
Normal file
428
core/services/services/client_graph/aggregation_service.py
Normal 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,
|
||||
}
|
||||
90
core/services/services/comms_evolution_provider.py
Normal file
90
core/services/services/comms_evolution_provider.py
Normal 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 []
|
||||
239
core/services/services/comms_ingest.py
Normal file
239
core/services/services/comms_ingest.py
Normal 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),
|
||||
}
|
||||
63
core/services/services/comms_provider.py
Normal file
63
core/services/services/comms_provider.py
Normal 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"},
|
||||
}
|
||||
95
core/services/services/comms_waha_provider.py
Normal file
95
core/services/services/comms_waha_provider.py
Normal 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
|
||||
3
core/services/services/imports/__init__.py
Normal file
3
core/services/services/imports/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
backend/services/imports/__init__.py
|
||||
"""
|
||||
286
core/services/services/imports/ingest_service.py
Normal file
286
core/services/services/imports/ingest_service.py
Normal 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
|
||||
136
core/services/services/mcp_registry.py
Normal file
136
core/services/services/mcp_registry.py
Normal 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()
|
||||
354
core/services/services/nemoclaw_client.py
Normal file
354
core/services/services/nemoclaw_client.py
Normal 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
|
||||
40
core/services/services/nemoclaw_runtime.py
Normal file
40
core/services/services/nemoclaw_runtime.py
Normal 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()
|
||||
443
core/services/services/runtime_llm_service.py
Normal file
443
core/services/services/runtime_llm_service.py
Normal 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()
|
||||
147
infrastructure/ci/.gitlab-ci.yml
Normal file
147
infrastructure/ci/.gitlab-ci.yml
Normal 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"'
|
||||
91
infrastructure/ecr/build_push_sign.sh
Normal file
91
infrastructure/ecr/build_push_sign.sh
Normal 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}"
|
||||
118
infrastructure/ingress-box/poll_and_transfer.sh
Normal file
118
infrastructure/ingress-box/poll_and_transfer.sh
Normal 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 ==="
|
||||
142
infrastructure/ingress-box/sync_comfy_route.py
Normal file
142
infrastructure/ingress-box/sync_comfy_route.py
Normal 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())
|
||||
152
infrastructure/ingress-box/sync_llm_route.py
Normal file
152
infrastructure/ingress-box/sync_llm_route.py
Normal 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())
|
||||
142
infrastructure/ingress-box/sync_velocity_route.py
Normal file
142
infrastructure/ingress-box/sync_velocity_route.py
Normal 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())
|
||||
342
infrastructure/k3s/deployments/deployments.yaml
Normal file
342
infrastructure/k3s/deployments/deployments.yaml
Normal 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
|
||||
107
infrastructure/k3s/deployments/gpu-mig-config.yaml
Normal file
107
infrastructure/k3s/deployments/gpu-mig-config.yaml
Normal 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)
|
||||
64
infrastructure/k3s/ingress/caddyfile-base.conf
Normal file
64
infrastructure/k3s/ingress/caddyfile-base.conf
Normal 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
|
||||
158
infrastructure/k3s/ingress/ingress.yaml
Normal file
158
infrastructure/k3s/ingress/ingress.yaml
Normal 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
|
||||
27
infrastructure/k3s/namespaces/namespaces.yaml
Normal file
27
infrastructure/k3s/namespaces/namespaces.yaml
Normal 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
|
||||
82
infrastructure/k3s/services/services.yaml
Normal file
82
infrastructure/k3s/services/services.yaml
Normal 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
|
||||
132
infrastructure/k3s/volumes/persistent-volumes.yaml
Normal file
132
infrastructure/k3s/volumes/persistent-volumes.yaml
Normal 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
|
||||
164
infrastructure/model-hydration/hydrate_gpu_comfy_models.py
Normal file
164
infrastructure/model-hydration/hydrate_gpu_comfy_models.py
Normal 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
Reference in New Issue
Block a user