Built the Sentinel Tab

This commit is contained in:
Sagnik
2026-04-12 02:02:58 +05:30
parent fb656d1443
commit 075ab280ad
526 changed files with 17646 additions and 70931 deletions

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""backend package init — exposes package root for absolute imports."""

Binary file not shown.

Binary file not shown.

1
backend/auth/__init__.py Normal file
View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,134 @@
"""
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,
}
# ── Password hashing ──────────────────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(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) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
payload = {
"sub": user_id,
"role": role,
"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
@property
def role_level(self) -> int:
return ROLE_HIERARCHY.get(self.role, -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=payload["role"])
# ── 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

View File

@@ -0,0 +1,42 @@
[
{
"id": "eden-devprayag",
"title": "Eden Devprayag - Walkthrough",
"property_name": "Eden Devprayag",
"unit_number": "Property-01",
"type": "Property Walkthrough",
"duration_seconds": 0,
"thumbnail_color": "#3b82f6",
"storage_path": "eden-devprayag.mp4"
},
{
"id": "sugam-prakriti",
"title": "Sugam Prakriti - Walkthrough",
"property_name": "Sugam Prakriti",
"unit_number": "Property-02",
"type": "Property Walkthrough",
"duration_seconds": 0,
"thumbnail_color": "#06b6d4",
"storage_path": "sugam-prakriti.mp4"
},
{
"id": "atri-aqua",
"title": "Atri Aqua - Walkthrough",
"property_name": "Atri Aqua",
"unit_number": "Property-03",
"type": "Property Walkthrough",
"duration_seconds": 0,
"thumbnail_color": "#8b5cf6",
"storage_path": "atri-aqua.mp4"
},
{
"id": "atri-surya-toron",
"title": "Atri Surya Toron - Walkthrough",
"property_name": "Atri Surya Toron",
"unit_number": "Property-04",
"type": "Property Walkthrough",
"duration_seconds": 0,
"thumbnail_color": "#10b981",
"storage_path": "atri-surya-toron.mp4"
}
]

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

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

Binary file not shown.

Binary file not shown.

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

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

179
backend/db/schema.sql Normal file
View File

@@ -0,0 +1,179 @@
-- 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',
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);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: leads_intelligence (CRM core with QD scoring)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS leads_intelligence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source source_enum NOT NULL,
status lead_status_enum NOT NULL DEFAULT 'new',
qualification qualification_enum,
budget TEXT,
interest TEXT,
-- Quantum Dynamics Score: 1-100, updated in real-time by NemoClaw
quantum_dynamics_score INTEGER CHECK (quantum_dynamics_score BETWEEN 1 AND 100),
-- Polymorphic CRM intelligence tags e.g. ['HNI', 'NRI', 'Hot Lead']
tags TEXT[] NOT NULL DEFAULT '{}',
assigned_to UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
last_message TEXT,
last_active TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-update updated_at on every modification
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_leads_updated_at ON leads_intelligence;
CREATE TRIGGER trg_leads_updated_at
BEFORE UPDATE ON leads_intelligence
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads_intelligence (status);
CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads_intelligence (assigned_to);
-- GIN index for efficient tag array queries (e.g. WHERE 'HNI' = ANY(tags))
CREATE INDEX IF NOT EXISTS idx_leads_tags ON leads_intelligence USING GIN (tags);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: velocity_vault_assets (File Tracking Engine)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS velocity_vault_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_name TEXT NOT NULL,
asset_type TEXT NOT NULL, -- 'pdf', 'image', 'video'
storage_path TEXT NOT NULL, -- relative to /opt/dlami/nvme/assets/
-- Unique cryptographic string for every share instance
tracking_hash VARCHAR(64) UNIQUE NOT NULL,
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
created_by UUID REFERENCES users_and_roles(id),
-- Array of open timestamps; one entry appended per distinct open event
opened_at TIMESTAMPTZ[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vault_hash ON velocity_vault_assets (tracking_hash);
CREATE INDEX IF NOT EXISTS idx_vault_lead ON velocity_vault_assets (lead_id);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: omnichannel_logs (Polymorphic event ingestion + sentimental history)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS omnichannel_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type log_event_enum NOT NULL,
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
-- JSONB payload — schema varies by event_type:
-- SENTIMENT_SPIKE: {blend_shapes, qd_before, qd_after}
-- WS_ASSET_OPENED: {ip, user_agent, tracking_hash}
-- QD_UPDATED: {qd_score, reasoning, confidence}
-- LEAD_TAGGED: {tags_added, tags_removed}
payload JSONB NOT NULL DEFAULT '{}',
-- For MediaPipe-correlated entries: exact ms offset in the stimulus video
video_timestamp_ms BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Composite index supporting time-series sentiment history queries
CREATE INDEX IF NOT EXISTS idx_logs_lead_type_time
ON omnichannel_logs (lead_id, event_type, created_at DESC);
-- Partial index for fast SENTIMENT_SPIKE lookups
CREATE INDEX IF NOT EXISTS idx_logs_sentiment_spikes
ON omnichannel_logs (lead_id, created_at DESC)
WHERE event_type = 'SENTIMENT_SPIKE';
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: consent_log (GDPR biometric consent tracking)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS consent_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT,
-- 'granted' or 'revoked'
action TEXT NOT NULL DEFAULT 'granted'
);
CREATE INDEX IF NOT EXISTS idx_consent_lead ON consent_log (lead_id, consented_at DESC);

View File

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

View File

@@ -1,29 +1,68 @@
"""
The Catalyst — FastAPI Backend
Autonomous Digital Marketing Agency powered by Meta Marketing API.
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
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
from api.routes_catalyst import router as catalyst_router
from oracle.router_v1 import router as oracle_router
from backend.api.routes_catalyst import router as catalyst_router
from backend.auth.dependencies import (
create_access_token, verify_password, get_current_user
)
from backend.db.pool import create_pool, close_pool
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
load_dotenv()
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")
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 — Catalyst Backend",
description="Meta Marketing API integration for autonomous campaign management.",
version="1.0.0",
title="Velocity — Neural Core",
description="Unified backend: Catalyst, Sentinel QD Engine, Vault, Oracle, Auth.",
version="2.0.0",
lifespan=lifespan,
)
# ── CORS ──────────────────────────────────────────────────────────────────────
@@ -38,16 +77,71 @@ app.add_middleware(
allow_headers=["*"],
)
# ── 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(oracle_router, prefix="/api/oracle/v1", tags=["Oracle"])
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"])
# ── WebSocket — Live Optimization Feed ────────────────────────────────────────
# 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"])
class ConnectionManager:
"""Manages active WebSocket connections for live optimization broadcasts."""
# ── Auth endpoint ─────────────────────────────────────────────────────────────
from fastapi import HTTPException, status
from pydantic import BaseModel
class LoginRequest(BaseModel):
email: str
password: str
@app.post("/api/auth/login", tags=["Auth"])
async def login(body: LoginRequest):
"""
Authenticate a user and return a JWT.
Credentials are verified against the users_and_roles table.
"""
from backend.db.pool import get_pool
from fastapi import Request
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id::text, role, password_hash FROM users_and_roles WHERE email = $1 AND is_active = TRUE",
body.email,
)
if not row or not verify_password(body.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"])
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
@app.get("/api/auth/me", tags=["Auth"])
async def me(user=get_current_user):
return {"user_id": user.user_id, "role": user.role}
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
class _CatalystManager:
def __init__(self) -> None:
self.active: Set[WebSocket] = set()
@@ -68,34 +162,21 @@ class ConnectionManager:
self.active -= dead
manager = ConnectionManager()
_catalyst_mgr = _CatalystManager()
@app.websocket("/ws/catalyst")
async def websocket_endpoint(ws: WebSocket) -> None:
"""
WebSocket endpoint for streaming live Claw Agent optimization events.
Clients connect from <LiveOptimizationFeed /> in Catalyst.tsx.
"""
await manager.connect(ws)
async def catalyst_ws(ws: WebSocket) -> None:
await _catalyst_mgr.connect(ws)
try:
while True:
# Keep-alive: wait for any incoming ping/message
data = await ws.receive_text()
# Echo back as acknowledgment (clients may send heartbeat pings)
await ws.send_text(json.dumps({"type": "ack", "data": data}))
except WebSocketDisconnect:
manager.disconnect(ws)
_catalyst_mgr.disconnect(ws)
# ── Helper: broadcast a live event (called from routes after API mutations) ───
async def broadcast_live_event(
event_type: str,
message: str,
campaign_name: str | None = None,
value: str | None = None,
) -> None:
async def broadcast_live_event(event_type, message, campaign_name=None, value=None):
payload = {
"type": event_type,
"message": message,
@@ -103,15 +184,23 @@ async def broadcast_live_event(
"value": value,
"timestamp": datetime.utcnow().isoformat(),
}
await manager.broadcast(payload)
await _catalyst_mgr.broadcast(payload)
# Attach broadcaster so routes can call it
app.state.broadcast_live_event = broadcast_live_event
# ── Health check ──────────────────────────────────────────────────────────────
# ── Health ─────────────────────────────────────────────────────────────────────
@app.get("/health", tags=["Health"])
async def health() -> dict:
return {"status": "ok", "service": "catalyst-backend", "version": "1.0.0"}
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.utcnow().isoformat(),
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +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
python-dotenv>=1.0.0
httpx>=0.27.0
pydantic>=2.9.0
python-multipart>=0.0.12
asyncpg>=0.30.0
# ── Sentinel QD Engine dependencies ──────────────────────────────────────────
asyncpg>=0.31.0 # Raw PostgreSQL async driver (no ORM)
python-jose[cryptography]>=3.3.0 # JWT encode/decode
passlib[bcrypt]>=1.7.4 # bcrypt password hashing
httpx>=0.28.0 # Async HTTP client for NemoClaw API calls
pynvml>=11.5.0 # GPU VRAM health checks before NemoClaw inference
python-multipart>=0.0.9 # Multipart form parsing for CSV uploads
pytest>=8.3.0
pytest-asyncio>=0.25.0
# Alembic for schema migrations (run separately, not imported by the app)
alembic>=1.14.0

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

142
backend/routers/cctv.py Normal file
View File

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

102
backend/routers/scenes.py Normal file
View File

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

479
backend/routers/sentinel.py Normal file
View File

@@ -0,0 +1,479 @@
"""
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",
)
@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
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():
await _ensure_session_row(
conn,
session_id=sid,
session_mode=mode,
lead_id=lid,
video_asset_id=asset_id,
)
lead_row = None
if _is_uuid(lid):
lead_row = await conn.fetchrow(
"""
SELECT quantum_dynamics_score, budget, interest, tags
FROM leads_intelligence
WHERE id = $1::uuid
""",
lid,
)
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": (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
""",
lid,
)
if _is_uuid(lid)
else 0,
"tags": list((lead_row["tags"] if lead_row else None) or []),
"session_mode": mode,
}
result = await score_qd(
lead_id=lid or sid,
batch_id=sid,
blend_shapes=bs,
video_ts_ms=bts,
scene_label=scene_label,
crm_context=crm,
current_qd_score=(
lead_row["quantum_dynamics_score"]
if lead_row
else (session_row["final_qd_score"] if session_row else 50)
),
)
evidence = dict((session_row["auto_mode_evidence"] if session_row else {}) or {})
evidence.update(
{
"last_scene_label": scene_label,
"last_video_ts_ms": bts,
}
)
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 lead_row and _is_uuid(lid):
await conn.execute(
"""
INSERT INTO omnichannel_logs (event_type, lead_id, payload, video_timestamp_ms)
VALUES ('SENTIMENT_SPIKE', $1::uuid, $2::jsonb, $3)
""",
lid,
json.dumps(
{
"blend_shapes": bs,
"scene_label": scene_label,
"qd_before": lead_row["quantum_dynamics_score"],
"qd_after": result.qd_score,
"confidence": result.confidence,
"session_id": sid,
}
),
bts,
)
await conn.execute(
"""
UPDATE leads_intelligence
SET quantum_dynamics_score = $1, updated_at = NOW()
WHERE id = $2::uuid
""",
result.qd_score,
lid,
)
baseline = (
lead_row["quantum_dynamics_score"]
if lead_row and lead_row["quantum_dynamics_score"] is not None
else ((session_row["final_qd_score"] if session_row else None) or 50)
)
event = {
"type": "QD_UPDATED",
"data": {
"lead_id": lid,
"session_id": sid,
"qd_score": result.qd_score,
"delta": result.qd_score - baseline,
"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
lead_id: 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,
)
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 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
backend/routers/vault.py Normal file
View File

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

109
backend/routers/videos.py Normal file
View File

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

View File

@@ -0,0 +1,40 @@
"""
Bridge OCR/plate-recognition output into the Velocity CCTV ingestion API.
Usage:
python backend/scripts/cctv_ocr_bridge.py --api-base https://host --session-id <uuid> payload.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import httpx
def main() -> int:
parser = argparse.ArgumentParser(description='Forward OCR bridge payloads to Velocity CCTV API.')
parser.add_argument('payload', type=Path, help='Path to a JSON payload file from the OCR bridge.')
parser.add_argument('--api-base', default='http://127.0.0.1:8000', help='Velocity API base URL.')
parser.add_argument('--session-id', default=None, help='Optional auto mode perception session UUID.')
parser.add_argument('--token', default=None, help='Optional bearer token for protected ingestion.')
args = parser.parse_args()
body = json.loads(args.payload.read_text(encoding='utf-8'))
if args.session_id:
body['session_id'] = args.session_id
headers = {'Content-Type': 'application/json'}
if args.token:
headers['Authorization'] = f'Bearer {args.token}'
response = httpx.post(f"{args.api_base.rstrip('/')}/api/cctv/event", json=body, headers=headers, timeout=30.0)
response.raise_for_status()
print(json.dumps(response.json(), indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -0,0 +1,394 @@
#!/usr/bin/env bash
# =============================================================================
# nemoclaw_deploy.sh
# Deploys NemoClaw on the AWS G6.12xlarge instance.
# - All data/install paths on NVMe (/opt/dlami/nvme/)
# - Configures OpenShell to use existing Ollama (qwen3.5:27b, port 11434)
# - GPUs 0+1 are Ollama's. Do NOT reassign them.
# - ComfyUI owns GPUs 2+3. Do NOT touch.
# - Creates a systemd service for the NemoClaw gateway.
# =============================================================================
set -euo pipefail
NVME="/opt/dlami/nvme"
AGENT_NAME="velocity-sentinel"
OLLAMA_URL="http://127.0.0.1:11434"
OLLAMA_MODEL="qwen3.5:27b"
OPENCLAW_PORT=8080 # Port our FastAPI backend targets
echo "================================================================"
echo " Project Velocity — NemoClaw + OpenShell Deploy Script"
echo " Instance: G6.12xlarge | NVMe: $NVME"
echo "================================================================"
# ──────────────────────────────────────────────────────────────────
# 0. Safety checks
# ──────────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo "[ERROR] Run as root or with sudo"; exit 1
fi
if ! mountpoint -q "$NVME" 2>/dev/null && [ ! -d "$NVME" ]; then
echo "[WARN] NVMe not mounted at $NVME — using /home/ubuntu/nvme as fallback"
NVME="/home/ubuntu/nvme"
mkdir -p "$NVME"
fi
echo "[✓] NVMe target: $NVME"
# Confirm Ollama is alive before proceeding
if ! curl -sf "$OLLAMA_URL/api/tags" | grep -q "qwen"; then
echo "[WARN] Ollama at $OLLAMA_URL doesn't show qwen3.5:27b yet — proceeding anyway"
else
echo "[✓] Ollama confirmed running with qwen3.5:27b"
fi
# ──────────────────────────────────────────────────────────────────
# 1. Node.js 22 (NemoClaw requirement: >=22.16)
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[1/7] Installing Node.js 22..."
NODE_VERSION=$(node --version 2>/dev/null | sed 's/v//' | cut -d. -f1 || echo "0")
if [ "$NODE_VERSION" -ge 22 ]; then
echo "[✓] Node.js $(node --version) already installed"
else
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
echo "[✓] Node.js $(node --version) installed"
fi
npm --version
echo "[✓] npm $(npm --version)"
# ──────────────────────────────────────────────────────────────────
# 2. Docker (required for OpenShell container runtime)
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[2/7] Ensuring Docker is installed..."
if command -v docker &>/dev/null && docker info &>/dev/null; then
echo "[✓] Docker $(docker --version | awk '{print $3}') already running"
else
echo " Installing Docker..."
apt-get install -y ca-certificates curl gnupg lsb-release
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
| tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -q
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
systemctl start docker
echo "[✓] Docker installed"
fi
# Move Docker data root to NVMe so images don't fill root disk
DOCKER_DAEMON_JSON="/etc/docker/daemon.json"
if ! grep -q "nvme" "$DOCKER_DAEMON_JSON" 2>/dev/null; then
echo " Moving Docker data-root → $NVME/docker"
mkdir -p "$NVME/docker"
# Preserve existing config if any
EXISTING=$(cat "$DOCKER_DAEMON_JSON" 2>/dev/null || echo "{}")
python3 -c "
import json, sys
cfg = json.loads('''$EXISTING''')
cfg['data-root'] = '$NVME/docker'
print(json.dumps(cfg, indent=2))
" > "$DOCKER_DAEMON_JSON"
systemctl restart docker
echo "[✓] Docker data-root → $NVME/docker"
fi
# ──────────────────────────────────────────────────────────────────
# 3. Install NemoClaw (headless via env vars)
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[3/7] Installing NemoClaw..."
# Set HOME so NemoClaw installs to NVMe-backed location
export NEMOCLAW_HOME="$NVME/nemoclaw"
export OPENSHELL_HOME="$NVME/openshell"
export HOME_OVERRIDE="$NVME/home"
mkdir -p "$NEMOCLAW_HOME" "$OPENSHELL_HOME" "$HOME_OVERRIDE"
# Link ~/.nemoclaw and ~/.openshell to NVMe
ln -sfn "$NEMOCLAW_HOME" /root/.nemoclaw 2>/dev/null || true
ln -sfn "$NEMOCLAW_HOME" /home/ubuntu/.nemoclaw 2>/dev/null || true
ln -sfn "$OPENSHELL_HOME" /root/.openshell 2>/dev/null || true
ln -sfn "$OPENSHELL_HOME" /home/ubuntu/.openshell 2>/dev/null || true
if command -v nemoclaw &>/dev/null; then
echo "[✓] nemoclaw already installed: $(nemoclaw --version 2>/dev/null || echo 'version unknown')"
else
echo " Downloading NemoClaw installer..."
INSTALLER_SCRIPT="$NVME/nemoclaw_install.sh"
curl -fsSL https://www.nvidia.com/nemoclaw.sh -o "$INSTALLER_SCRIPT"
chmod +x "$INSTALLER_SCRIPT"
# Run the installer non-interactively
# NEMOCLAW_SKIP_ONBOARD=1 bypasses the interactive wizard (undocumented but standard pattern)
# We'll do manual onboarding after install using CLI flags
NEMOCLAW_SKIP_ONBOARD=1 \
NEMOCLAW_HOME="$NEMOCLAW_HOME" \
bash "$INSTALLER_SCRIPT" || true
# Reload PATH
export PATH="$PATH:/usr/local/bin:/root/.local/bin"
source ~/.bashrc 2>/dev/null || true
if ! command -v nemoclaw &>/dev/null; then
echo "[WARN] nemoclaw not in PATH yet — checking common locations..."
for p in /usr/local/bin/nemoclaw /root/.local/bin/nemoclaw "$NVME/bin/nemoclaw"; do
if [ -f "$p" ]; then
ln -sfn "$p" /usr/local/bin/nemoclaw
echo "[✓] Linked nemoclaw from $p"
break
fi
done
fi
echo "[✓] nemoclaw installed"
fi
# ──────────────────────────────────────────────────────────────────
# 4. Onboard the Velocity Sentinel agent sandbox
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[4/7] Onboarding '$AGENT_NAME' NemoClaw sandbox..."
# Check if sandbox already exists
if nemoclaw "$AGENT_NAME" status &>/dev/null; then
echo "[✓] Sandbox '$AGENT_NAME' already exists — skipping creation"
else
echo " Running nemoclaw onboard (this may take a few minutes)..."
# --provider compatible-endpoint: use our local Ollama instead of NVIDIA cloud
# --yes: skip confirmation prompts
nemoclaw onboard \
--name "$AGENT_NAME" \
--provider compatible-endpoint \
--endpoint "$OLLAMA_URL/v1" \
--model "$OLLAMA_MODEL" \
--yes \
--no-messaging-bridge \
--no-skills || {
echo "[WARN] Structured onboard failed — trying minimal onboard..."
# Fallback: let it run with defaults if flags are not supported in this alpha version
yes "" | nemoclaw onboard --name "$AGENT_NAME" 2>&1 | head -60 || true
}
echo "[✓] Sandbox onboarded"
fi
# ──────────────────────────────────────────────────────────────────
# 5. Configure OpenShell to use Ollama (compatible endpoint)
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[5/7] Configuring OpenShell inference → Ollama (qwen3.5:27b)..."
# Set inference route to our local Ollama
openshell inference set \
--provider compatible-endpoint \
--base-url "$OLLAMA_URL/v1" \
--api-key "ollama" \
--model "$OLLAMA_MODEL" \
--context-window 32768 \
--max-tokens 4096 || {
echo "[WARN] openshell inference set failed — trying alternate syntax..."
openshell inference set \
--provider compatible-endpoint \
--model "$OLLAMA_MODEL" || true
}
# Also set the context window on the Ollama model side
echo " Setting Ollama num_ctx=32768..."
curl -s -X POST "$OLLAMA_URL/api/generate" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$OLLAMA_MODEL\",\"prompt\":\"\",\"options\":{\"num_ctx\":32768},\"stream\":false}" \
> /dev/null 2>&1 || true
echo "[✓] OpenShell inference configured → $OLLAMA_URL ($OLLAMA_MODEL)"
# ──────────────────────────────────────────────────────────────────
# 6. Write OpenShell network policy (allow Velocity backend egress)
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[6/7] Writing OpenShell network policy..."
POLICY_DIR="$OPENSHELL_HOME/policy"
mkdir -p "$POLICY_DIR"
cat > "$POLICY_DIR/velocity_egress.yaml" << 'POLICY'
# OpenShell Network Egress Policy — Project Velocity Sentinel
# Applied to the velocity-sentinel sandbox.
# All non-listed hosts are blocked by default.
version: "1"
sandbox: velocity-sentinel
egress:
# Local Ollama inference (Qwen 3.5 27B)
- host: "127.0.0.1"
ports: [11434]
description: "Ollama LLM inference"
action: allow
# OpenShell gateway itself (loopback)
- host: "127.0.0.1"
ports: [8080, 8081, 8082, 8083, 8084, 8085]
description: "OpenShell gateway ports"
action: allow
# Velocity FastAPI backend (same host)
- host: "127.0.0.1"
ports: [8000, 8001, 8288]
description: "Velocity FastAPI backend"
action: allow
# PostgreSQL (same host)
- host: "127.0.0.1"
ports: [5432]
description: "PostgreSQL DB"
action: allow
# Block everything else
- host: "*"
action: deny
description: "Default deny — data sovereignty (India/Abu Dhabi)"
POLICY
# Apply the policy if openshell supports it
openshell policy apply "$POLICY_DIR/velocity_egress.yaml" 2>/dev/null || \
echo "[WARN] Policy apply not supported yet in this alpha — YAML written for future use"
echo "[✓] Network policy written → $POLICY_DIR/velocity_egress.yaml"
# ──────────────────────────────────────────────────────────────────
# 7. Write NemoClaw systemd service
# ──────────────────────────────────────────────────────────────────
echo ""
echo "[7/7] Installing systemd service: nemoclaw-velocity.service..."
NEMOCLAW_BIN=$(command -v nemoclaw || echo "/usr/local/bin/nemoclaw")
OPENSHELL_BIN=$(command -v openshell || echo "/usr/local/bin/openshell")
cat > /etc/systemd/system/nemoclaw-velocity.service << SERVICE
[Unit]
Description=NemoClaw Velocity Sentinel Gateway
Documentation=https://github.com/NVIDIA/NemoClaw
After=network.target ollama.service docker.service
Wants=ollama.service docker.service
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=$NVME/nemoclaw
# GPU constraint: NemoClaw itself is CPU-bound (inference goes to Ollama)
# Ollama already owns GPUs 0,1. ComfyUI owns GPUs 2,3.
Environment=CUDA_VISIBLE_DEVICES=""
Environment=NEMOCLAW_HOME=$NVME/nemoclaw
Environment=OPENSHELL_HOME=$NVME/openshell
Environment=OLLAMA_BASE_URL=http://127.0.0.1:11434
Environment=VELOCITY_NEMO_MODEL=qwen3.5:27b
Environment=GATEWAY_PORT=$OPENCLAW_PORT
ExecStart=$NEMOCLAW_BIN $AGENT_NAME connect --gateway-port $OPENCLAW_PORT
ExecReload=/bin/kill -HUP \$MAINPID
Restart=always
RestartSec=10
StandardOutput=append:$NVME/logs/nemoclaw-velocity.log
StandardError=append:$NVME/logs/nemoclaw-velocity.log
# Limits
LimitNOFILE=65536
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
SERVICE
mkdir -p "$NVME/logs"
systemctl daemon-reload
systemctl enable nemoclaw-velocity.service
systemctl start nemoclaw-velocity.service || true # May fail on first boot if onboard not done
echo "[✓] nemoclaw-velocity.service enabled and started"
# ──────────────────────────────────────────────────────────────────
# Finalize: Detect gateway port & write env file
# ──────────────────────────────────────────────────────────────────
echo ""
echo "================================================================"
echo " Writing Velocity backend environment file..."
echo "================================================================"
VELOCITY_ENV="$NVME/velocity/env"
mkdir -p "$(dirname "$VELOCITY_ENV")"
# Detect actual OpenShell gateway URL
GATEWAY_URL="http://127.0.0.1:$OPENCLAW_PORT"
GATEWAY_CHAT_URL="$GATEWAY_URL/v1/chat/completions"
# Quick connectivity test (will succeed once nemoclaw starts)
echo " Testing gateway at $GATEWAY_CHAT_URL ..."
sleep 5
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST "$GATEWAY_CHAT_URL" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:27b","messages":[{"role":"user","content":"ping"}],"max_tokens":5}' \
2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
echo "[✓] Gateway responding at $GATEWAY_CHAT_URL (HTTP $HTTP_CODE)"
else
echo "[WARN] Gateway not yet responding (HTTP $HTTP_CODE) — it may still be starting up"
fi
cat > "$VELOCITY_ENV" << ENV
# Project Velocity — Backend Environment
# Generated by nemoclaw_deploy.sh
# Loaded by: source $VELOCITY_ENV
# ── NemoClaw / OpenShell Gateway ──────────────────────────────────
NEMOCLAW_BASE_URL=$GATEWAY_URL
NEMOCLAW_CHAT_URL=$GATEWAY_CHAT_URL
NEMOCLAW_MODEL=qwen3.5:27b
NEMOCLAW_TIMEOUT_S=30.0
NEMOCLAW_TEMPERATURE=0.2
# ── Ollama (direct fallback if OpenShell gateway not up) ──────────
OLLAMA_BASE_URL=http://127.0.0.1:11434
# ── NemoClaw Prompts ──────────────────────────────────────────────
NEMOCLAW_PROMPT_DIR=$NVME/nemoclaw/prompts
# ── JWT / Auth ────────────────────────────────────────────────────
# VELOCITY_JWT_SECRET=<SET_THIS>
# ── PostgreSQL ────────────────────────────────────────────────────
# VELOCITY_DB_DSN=postgresql://velocity_app:<PW>@127.0.0.1:5432/velocity
ENV
echo "[✓] Environment file written → $VELOCITY_ENV"
echo ""
echo "================================================================"
echo " DONE. Summary:"
echo ""
echo " Agent name : $AGENT_NAME"
echo " Gateway URL : $GATEWAY_URL"
echo " Chat endpoint: $GATEWAY_CHAT_URL"
echo " Model : $OLLAMA_MODEL (via Ollama on port 11434)"
echo " GPUs 0,1 : Ollama (unchanged)"
echo " GPUs 2,3 : ComfyUI (unchanged)"
echo " Env file : $VELOCITY_ENV"
echo " Service log : $NVME/logs/nemoclaw-velocity.log"
echo ""
echo " Next commands to verify:"
echo " nemoclaw $AGENT_NAME status"
echo " nemoclaw $AGENT_NAME logs --follow"
echo " curl $GATEWAY_CHAT_URL (POST with messages[])"
echo "================================================================"

View File

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

Binary file not shown.

View File

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

View File

@@ -0,0 +1,413 @@
"""
backend/services/nemoclaw_client.py - NemoClaw inference client.
Primary path:
1. NVIDIA-hosted OpenAI-compatible chat completions.
2. Optional compatible endpoint via NEMOCLAW_BASE_URL.
3. Optional local Ollama fallback only when ALLOW_LOCAL_FALLBACK=true.
"""
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"))
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "")
NVIDIA_BASE_URL = os.getenv("NVIDIA_BASE_URL", "https://integrate.api.nvidia.com/v1")
NVIDIA_CHAT_URL = os.getenv("NVIDIA_CHAT_URL", f"{NVIDIA_BASE_URL}/chat/completions")
NVIDIA_MODEL = os.getenv("NVIDIA_MODEL", "nvidia/nemotron-3-super-120b-a12b")
NVIDIA_FALLBACK_MODEL = os.getenv(
"NVIDIA_FALLBACK_MODEL",
"nvidia/llama-3.3-nemotron-super-49b-v1",
)
NEMOCLAW_BASE_URL = os.getenv("NEMOCLAW_BASE_URL", "")
NEMOCLAW_CHAT_URL = (
os.getenv("NEMOCLAW_CHAT_URL") or f"{NEMOCLAW_BASE_URL}/v1/chat/completions"
if NEMOCLAW_BASE_URL
else ""
)
NEMOCLAW_MODEL = os.getenv("NEMOCLAW_MODEL", NVIDIA_MODEL)
NEMOCLAW_API_TOKEN = os.getenv("NEMOCLAW_API_TOKEN", "")
ALLOW_LOCAL_FALLBACK = os.getenv("ALLOW_LOCAL_FALLBACK", "false").lower() == "true"
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
OLLAMA_CHAT_URL = f"{OLLAMA_BASE_URL}/v1/chat/completions"
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:27b")
_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:
endpoints: list[tuple[str, str, str, dict[str, str]]] = []
if NVIDIA_API_KEY:
endpoints.append(
(
"nvidia_primary",
NVIDIA_CHAT_URL,
NVIDIA_MODEL,
{
"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
)
)
if NVIDIA_FALLBACK_MODEL and NVIDIA_FALLBACK_MODEL != NVIDIA_MODEL:
endpoints.append(
(
"nvidia_fallback",
NVIDIA_CHAT_URL,
NVIDIA_FALLBACK_MODEL,
{
"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
)
)
if NEMOCLAW_CHAT_URL:
headers = {"Content-Type": "application/json"}
if NEMOCLAW_API_TOKEN:
headers["Authorization"] = f"Bearer {NEMOCLAW_API_TOKEN}"
endpoints.append(("compatible_endpoint", NEMOCLAW_CHAT_URL, NEMOCLAW_MODEL, headers))
if ALLOW_LOCAL_FALLBACK:
endpoints.append(
("ollama_fallback", OLLAMA_CHAT_URL, OLLAMA_MODEL, {"Content-Type": "application/json"})
)
if not endpoints:
raise RuntimeError(
"No NemoClaw inference endpoint is configured. "
"Set NVIDIA_API_KEY or NEMOCLAW_BASE_URL."
)
t_start = time.monotonic()
last_error: Exception | None = None
for label, url, model, headers in endpoints:
try:
result = await _attempt_chat(
label=label,
url=url,
model=model,
system_content=system_content,
user_content=user_content,
timeout=timeout,
headers=headers,
)
logger.info(
"NemoClaw inference via %s model=%s elapsed=%.2fs",
label,
model,
time.monotonic() - t_start,
)
return result
except (httpx.ConnectError, httpx.TimeoutException) as exc:
logger.warning("NemoClaw %s unreachable (%s), trying next endpoint", label, exc)
last_error = exc
except httpx.HTTPStatusError as exc:
logger.error(
"NemoClaw %s HTTP %s: %s",
label,
exc.response.status_code,
exc.response.text[:300],
)
last_error = exc
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
logger.error("NemoClaw %s returned invalid JSON: %s", label, exc)
last_error = exc
raise RuntimeError(f"All NemoClaw endpoints failed. Last error: {last_error}")
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:
results: dict[str, str] = {}
endpoints: list[tuple[str, str, str, dict[str, str]]] = []
if NVIDIA_API_KEY:
endpoints.append(
(
"nvidia_primary",
NVIDIA_CHAT_URL,
NVIDIA_MODEL,
{
"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
)
)
if NEMOCLAW_CHAT_URL:
headers = {"Content-Type": "application/json"}
if NEMOCLAW_API_TOKEN:
headers["Authorization"] = f"Bearer {NEMOCLAW_API_TOKEN}"
endpoints.append(("compatible_endpoint", NEMOCLAW_CHAT_URL, NEMOCLAW_MODEL, headers))
if ALLOW_LOCAL_FALLBACK:
endpoints.append(
("ollama_fallback", OLLAMA_CHAT_URL, OLLAMA_MODEL, {"Content-Type": "application/json"})
)
for name, url, model, headers in endpoints:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
url,
json={
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 5,
},
headers=headers,
)
results[name] = "ok" if response.status_code < 500 else f"http_{response.status_code}"
except Exception as exc:
results[name] = f"error: {exc}"
results["model"] = NVIDIA_MODEL if NVIDIA_API_KEY else NEMOCLAW_MODEL
results["primary_url"] = NVIDIA_CHAT_URL if NVIDIA_API_KEY else (NEMOCLAW_CHAT_URL or OLLAMA_CHAT_URL)
return results

View File

@@ -0,0 +1,35 @@
import os
os.environ.setdefault('VELOCITY_JWT_SECRET', 'test-secret')
import pytest
from backend.services import nemoclaw_client
@pytest.mark.asyncio
async def test_score_qd_uses_nemoclaw_json_result(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(system_content: str, user_content: str, timeout: float = 45.0) -> dict:
assert '"scene_label": "Balcony Reveal"' in user_content
assert '"scene_label": "Balcony Reveal"' in user_content
return {
'qd_score': 87,
'reasoning': 'Strong smile during balcony reveal.',
'confidence': 0.91,
}
monkeypatch.setattr(nemoclaw_client, '_nemoclaw_chat', fake_chat)
result = await nemoclaw_client.score_qd(
lead_id='lead-1',
batch_id='batch-1',
blend_shapes={'jawOpen': 0.4, 'mouthSmileLeft': 0.7},
video_ts_ms=45000,
scene_label='Balcony Reveal',
crm_context={'budget': 'AED 15M+', 'interest': 'Penthouse', 'tags': ['HNI']},
current_qd_score=70,
)
assert result.qd_score == 87
assert result.reasoning == 'Strong smile during balcony reveal.'
assert result.confidence == 0.91

View File

@@ -0,0 +1,90 @@
import os
from contextlib import asynccontextmanager
os.environ.setdefault('VELOCITY_JWT_SECRET', 'test-secret')
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.db.pool import get_pool
from backend.routers.vault import router as vault_router
class FakeConn:
def __init__(self) -> None:
self.assets: dict[str, dict] = {}
self.logs: list[dict] = []
async def fetchrow(self, query: str, *args):
if 'INSERT INTO velocity_vault_assets' in query:
tracking_hash = args[3]
self.assets[tracking_hash] = {
'id': 'asset-1',
'lead_id': args[4],
'asset_name': args[0],
'storage_path': args[2],
}
return {'id': 'asset-1'}
if 'UPDATE velocity_vault_assets' in query:
asset = self.assets.get(args[0])
if not asset:
return None
return {
'id': asset['id'],
'lead_id': asset['lead_id'],
'asset_name': asset['asset_name'],
'storage_path': asset['storage_path'],
}
if 'SELECT name FROM leads_intelligence' in query:
return {'name': 'Mohammed Al-Rashid'}
return None
async def execute(self, query: str, *args):
if 'INSERT INTO omnichannel_logs' in query:
self.logs.append({'lead_id': args[0], 'payload': args[1]})
return 'OK'
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def test_vault_open_broadcasts_notification() -> None:
app = FastAPI()
pool = FakePool()
sent_events: list[dict] = []
async def capture_event(event: dict) -> None:
sent_events.append(event)
app.include_router(vault_router, prefix='/api/vault')
app.include_router(vault_router, prefix='/vault')
app.state.broadcast_sentinel_event = capture_event
app.dependency_overrides[get_pool] = lambda: pool
app.dependency_overrides[get_current_user] = lambda: UserPrincipal('user-1', 'ADMIN')
client = TestClient(app)
response = client.post(
'/api/vault/generate-link',
json={
'lead_id': '00000000-0000-4000-8000-000000000001',
'asset_name': 'PH-01 Deck',
'asset_type': 'pdf',
'storage_path': 'brochures/ph-01.pdf',
},
)
assert response.status_code == 201
tracking_hash = response.json()['tracking_hash']
open_response = client.get(f'/vault/{tracking_hash}', follow_redirects=False)
assert open_response.status_code == 302
assert open_response.headers['location'] == '/assets/brochures/ph-01.pdf'
assert len(sent_events) == 1
assert sent_events[0]['type'] == 'WS_ASSET_OPENED'
assert sent_events[0]['data']['asset_name'] == 'PH-01 Deck'