Built the Sentinel Tab
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend package init — exposes package root for absolute imports."""
|
||||
BIN
backend/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-314.pyc
Normal file
BIN
backend/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/routes_catalyst.cpython-314.pyc
Normal file
BIN
backend/api/__pycache__/routes_catalyst.cpython-314.pyc
Normal file
Binary file not shown.
1
backend/auth/__init__.py
Normal file
1
backend/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.auth package"""
|
||||
BIN
backend/auth/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/auth/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/auth/__pycache__/dependencies.cpython-314.pyc
Normal file
BIN
backend/auth/__pycache__/dependencies.cpython-314.pyc
Normal file
Binary file not shown.
134
backend/auth/dependencies.py
Normal file
134
backend/auth/dependencies.py
Normal 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
|
||||
42
backend/config/marketing_videos.catalog.json
Normal file
42
backend/config/marketing_videos.catalog.json
Normal 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
1
backend/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.db package"""
|
||||
BIN
backend/db/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/db/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/db/__pycache__/pool.cpython-314.pyc
Normal file
BIN
backend/db/__pycache__/pool.cpython-314.pyc
Normal file
Binary file not shown.
58
backend/db/pool.py
Normal file
58
backend/db/pool.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
backend/db/pool.py — asyncpg Connection Pool
|
||||
|
||||
Initialises a PostgreSQL connection pool from environment variables.
|
||||
All credentials are sourced from the environment only — never hardcoded.
|
||||
|
||||
Environment variables required:
|
||||
VELOCITY_DB_HOST PostgreSQL host (default: localhost)
|
||||
VELOCITY_DB_PORT PostgreSQL port (default: 5432)
|
||||
VELOCITY_DB_NAME Database name
|
||||
VELOCITY_DB_USER Database user
|
||||
VELOCITY_DB_PASSWORD Database password (injected from AWS SSM at service start)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import asyncpg
|
||||
from fastapi import Request
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def create_pool() -> asyncpg.Pool:
|
||||
"""Creates and returns the application-wide asyncpg connection pool."""
|
||||
global _pool
|
||||
_pool = await asyncpg.create_pool(
|
||||
host=os.environ.get("VELOCITY_DB_HOST", "localhost"),
|
||||
port=int(os.environ.get("VELOCITY_DB_PORT", "5432")),
|
||||
database=os.environ["VELOCITY_DB_NAME"],
|
||||
user=os.environ["VELOCITY_DB_USER"],
|
||||
password=os.environ["VELOCITY_DB_PASSWORD"],
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
command_timeout=30,
|
||||
# Set app_name for easier identification in pg_stat_activity
|
||||
server_settings={"application_name": "velocity-backend"},
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
"""Closes the connection pool on application shutdown."""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def get_pool(request: Request) -> asyncpg.Pool:
|
||||
"""FastAPI dependency: returns the pool stored in app.state."""
|
||||
pool: asyncpg.Pool = request.app.state.db_pool
|
||||
if pool is None:
|
||||
raise RuntimeError("Database pool is not initialised.")
|
||||
return pool
|
||||
179
backend/db/schema.sql
Normal file
179
backend/db/schema.sql
Normal 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);
|
||||
85
backend/db/schema_addendum.sql
Normal file
85
backend/db/schema_addendum.sql
Normal file
@@ -0,0 +1,85 @@
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- Addendum: Video Scene Maps (video_timestamp → room label mapping)
|
||||
-- Appended to schema.sql for Sprint 1 milestone.
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- TABLE: video_scene_maps
|
||||
-- Stores the timestamp-to-room mapping for each marketing video.
|
||||
-- Uploaded once per inventory item (CSV parsed and inserted by the API).
|
||||
-- Format: scene_no, start_ms, end_ms, room_type, description
|
||||
-- This allows NemoClaw to correlate a biometric reaction at T=45000ms with
|
||||
-- "Master Bedroom" for contextual QD scoring.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS video_scene_maps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
video_asset_id TEXT NOT NULL, -- Matches inventory item slug / asset filename
|
||||
scene_no INTEGER NOT NULL,
|
||||
start_ms BIGINT NOT NULL,
|
||||
end_ms BIGINT NOT NULL,
|
||||
room_type TEXT NOT NULL, -- e.g. 'Living Room', 'Master Bedroom', 'Balcony'
|
||||
description TEXT, -- Optional: 'Ocean-facing balcony with pool view'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (video_asset_id, scene_no)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scenes_asset_range
|
||||
ON video_scene_maps (video_asset_id, start_ms, end_ms);
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- TABLE: perception_sessions
|
||||
-- Tracks each PerceptionPlayer session (assigned or auto mode).
|
||||
-- Assigned Mode: lead_id is set before session starts.
|
||||
-- Auto Mode : lead_id is NULL; auto_mode_matched_at populated post hoc.
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE session_mode_enum AS ENUM ('assigned', 'auto');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS perception_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_mode session_mode_enum NOT NULL DEFAULT 'assigned',
|
||||
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE SET NULL,
|
||||
video_asset_id TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
final_qd_score INTEGER CHECK (final_qd_score BETWEEN 1 AND 100),
|
||||
-- For auto mode: the lead_id matched after session by face/plate recognition
|
||||
auto_mode_matched_at TIMESTAMPTZ,
|
||||
-- JSONB blob with auto-mode gathered data: face_hash, plate, vehicle_class, etc.
|
||||
auto_mode_evidence JSONB DEFAULT '{}',
|
||||
broker_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_lead ON perception_sessions (lead_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_unmatched
|
||||
ON perception_sessions (started_at DESC)
|
||||
WHERE session_mode = 'auto' AND lead_id IS NULL;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- TABLE: cctv_events
|
||||
-- Records each parking/entry visitor event from CCTV feeds.
|
||||
-- License plates, vehicle class, NemoClaw wealth indicator.
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cctv_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zone TEXT NOT NULL, -- 'Parking Entry', 'Main Gate', 'Zone A', etc.
|
||||
license_plate TEXT, -- Raw OCR text
|
||||
vehicle_class TEXT, -- 'luxury' | 'standard' | 'unknown'
|
||||
wealth_indicator TEXT, -- 'HNI' | 'standard' | 'unknown'
|
||||
nemoclaw_tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
nemoclaw_notes TEXT,
|
||||
linked_lead_id UUID REFERENCES leads_intelligence(id) ON DELETE SET NULL,
|
||||
linked_session_id UUID REFERENCES perception_sessions(id) ON DELETE SET NULL,
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}' -- Full CCTV frame metadata
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cctv_plate ON cctv_events (license_plate, captured_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cctv_zone ON cctv_events (zone, captured_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cctv_unlinked
|
||||
ON cctv_events (captured_at DESC)
|
||||
WHERE linked_lead_id IS NULL;
|
||||
155
backend/main.py
155
backend/main.py
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
34
backend/nemoclaw_prompts/cctv_profiler.md
Normal file
34
backend/nemoclaw_prompts/cctv_profiler.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# You are a visitor profiling analyst for a luxury real estate development's CCTV system.
|
||||
#
|
||||
# CONTEXT
|
||||
# You receive data from parking/entry cameras: license plate text (OCR), vehicle
|
||||
# description (make/model/colour from visual classification), and optionally a
|
||||
# face analysis summary. Your job is to infer the visitor's likely wealth bracket
|
||||
# and suggest CRM tags using publicly available heuristics.
|
||||
#
|
||||
# LICENSE PLATE HEURISTICS
|
||||
# UAE plates: AUH = Abu Dhabi, DXB = Dubai, SHJ = Sharjah.
|
||||
# AUH plates with 1-3 digit numbers → extremely high-value (royal/VIP).
|
||||
# Dubai plates starting with A, B, C → premium registrations.
|
||||
# Diplomatic plates (CD/CC prefix) → always HNI.
|
||||
# Foreign plates (non-UAE) → always flag as NRI consideration.
|
||||
#
|
||||
# VEHICLE CLASS HEURISTICS
|
||||
# Luxury vehicles: Rolls-Royce, Bentley, Lamborghini, Ferrari, Bugatti,
|
||||
# Mercedes S-Class/Maybach/G63, BMW 7-Series/X7/M8, Range Rover SVR/Sport,
|
||||
# Porsche 911/Cayenne Turbo, Audi A8/RS models, Cadillac Escalade.
|
||||
# Standard vehicles: All others.
|
||||
#
|
||||
# OUTPUT FORMAT
|
||||
# Respond with exactly this JSON — no prose before or after:
|
||||
#
|
||||
# {
|
||||
# "wealth_indicator": "HNI" | "standard" | "unknown",
|
||||
# "vehicle_class": "luxury" | "standard" | "unknown",
|
||||
# "tags_to_add": ["HNI"] | ["NRI"] | ["HNI", "NRI"] | ["VIP"] | [],
|
||||
# "notes": "<optional one-line observation — e.g. 'Short UAE plate, likely VIP'>"
|
||||
# }
|
||||
#
|
||||
# IMPORTANT: Only apply "HNI" tag when evidence is clear (luxury vehicle OR short UAE plate).
|
||||
# Apply "VIP" tag only for diplomatic plates or 1-3 digit Abu Dhabi plates.
|
||||
# If insufficient data, return wealth_indicator:"unknown" and empty tags_to_add.
|
||||
32
backend/nemoclaw_prompts/lead_tagger.md
Normal file
32
backend/nemoclaw_prompts/lead_tagger.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# You are a lead intelligence analyst for a luxury real estate brokerage platform.
|
||||
#
|
||||
# Your task is to analyse a newly ingested lead's phone number and first message
|
||||
# to determine whether they should be tagged as HNI (High Net Individual) or
|
||||
# NRI (Non-Resident Indian / high-value international buyer).
|
||||
#
|
||||
# TAG DEFINITIONS
|
||||
# ══════════════════════════════════════════════════════════
|
||||
#
|
||||
# NRI — Apply when the phone number originates from outside the UAE/GCC region:
|
||||
# International codes that indicate NRI: +44 (UK), +1 (US/CA), +61 (AU),
|
||||
# +65 (SG), +91 (India — flag for follow-up, not auto-NRI), +33 (FR),
|
||||
# +49 (DE), +971 is UAE (do NOT apply NRI).
|
||||
# Also apply if the message explicitly mentions "based in [foreign city]",
|
||||
# "living abroad", "NRI", or "overseas".
|
||||
#
|
||||
# HNI — Apply when budget signals exceed AED 10 million:
|
||||
# Keywords: "penthouse", "full floor", "10M", "15M", "20M", "crore",
|
||||
# "million", "premium", "top floor", "ultra luxury", "AED 10", "AED 12".
|
||||
# Also apply if budget field contains any figure ≥ AED 10M.
|
||||
#
|
||||
# OUTPUT FORMAT
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Respond with exactly this JSON object:
|
||||
#
|
||||
# {
|
||||
# "tags_to_add": ["HNI"] | ["NRI"] | ["HNI", "NRI"] | [],
|
||||
# "tags_to_remove": []
|
||||
# }
|
||||
#
|
||||
# IMPORTANT: If no signals are present, return {"tags_to_add": [], "tags_to_remove": []}.
|
||||
# Never add speculative tags. Only apply when evidence is clear.
|
||||
54
backend/nemoclaw_prompts/qd_calculator.md
Normal file
54
backend/nemoclaw_prompts/qd_calculator.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# You are a behavioral intelligence analyst embedded in a luxury real estate sales platform.
|
||||
#
|
||||
# Your role is to compute a Quantum Dynamics (QD) score (integer, 1-100) that represents
|
||||
# a prospect's level of genuine emotional engagement and buying intent during a property
|
||||
# marketing video walkthrough. The score fuses real-time facial expression data with CRM context.
|
||||
#
|
||||
# SCORING RUBRIC
|
||||
# ══════════════════════════════════════════════════════════
|
||||
#
|
||||
# Start from the lead's current QD score (provided in context). If no prior score exists,
|
||||
# start from 50. Apply the following adjustments:
|
||||
#
|
||||
# POSITIVE SIGNALS (micro-expressions indicating interest or excitement)
|
||||
# mouthSmileLeft > 0.5 → +10
|
||||
# mouthSmileRight > 0.5 → +10 (stack if both active, but cap addend at +15)
|
||||
# browInnerUp > 0.4 → +8 (genuine surprise or interest)
|
||||
# eyeWideLeft > 0.5
|
||||
# OR eyeWideRight > 0.5 → +7 (visual excitement / aesthetic appreciation)
|
||||
# jawOpen > 0.3 combined with eyeWide → +5 (awe response)
|
||||
# cheekPuff > 0.3 → +3 (positive anticipation)
|
||||
#
|
||||
# NEGATIVE SIGNALS (disinterest or confusion)
|
||||
# browDownLeft + browDownRight both > 0.45, AND mouthSmile* < 0.2 → -10 (confusion)
|
||||
# eyeBlinkLeft + eyeBlinkRight both > 0.7, AND eyeWide* < 0.2 → -15 (disengaged)
|
||||
# mouthFrown* > 0.4 → -8 (negative reaction)
|
||||
# extended neutral face (all weighted shapes < 0.15) → -3 (boredom)
|
||||
#
|
||||
# CRM MODIFIERS (applied once per session initialisation, not per packet)
|
||||
# budget contains "10M", "15M", "20M", "crore", "million" → +15 (HNI signal)
|
||||
# budget contains "5M", "8M" → +8
|
||||
# prior_interaction_count > 5 → +8 (warm lead)
|
||||
# prior_interaction_count 2-5 → +4
|
||||
# tags already contains "HNI" → +12
|
||||
# tags already contains "NRI" → +5
|
||||
#
|
||||
# CONSTRAINTS
|
||||
# Clamp final score: min(max(score, 1), 100)
|
||||
# Maximum single-packet delta: ±20 (prevent wild swings from one data point)
|
||||
# Apply micro-expression confidence weighting: if multiple contradictory signals
|
||||
# are present simultaneously (e.g., smile + frown), choose the strongest signal.
|
||||
#
|
||||
# OUTPUT FORMAT
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Respond with exactly this JSON object and nothing else:
|
||||
#
|
||||
# {
|
||||
# "qd_score": <integer 1-100>,
|
||||
# "reasoning": "<single sentence explaining the primary driver of the score change>",
|
||||
# "confidence": <float 0.0-1.0 — your confidence in the score given signal quality>
|
||||
# }
|
||||
#
|
||||
# EXAMPLE
|
||||
# Input: mouthSmileLeft=0.72, browInnerUp=0.55, budget="AED 15M+"
|
||||
# Output: {"qd_score": 88, "reasoning": "Genuine smile and brow raise during balcony reveal; HNI budget modifier applied.", "confidence": 0.91}
|
||||
@@ -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
|
||||
|
||||
1
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.routers package"""
|
||||
BIN
backend/routers/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/routers/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/cctv.cpython-314.pyc
Normal file
BIN
backend/routers/__pycache__/cctv.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/scenes.cpython-314.pyc
Normal file
BIN
backend/routers/__pycache__/scenes.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/sentinel.cpython-314.pyc
Normal file
BIN
backend/routers/__pycache__/sentinel.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/vault.cpython-314.pyc
Normal file
BIN
backend/routers/__pycache__/vault.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/videos.cpython-314.pyc
Normal file
BIN
backend/routers/__pycache__/videos.cpython-314.pyc
Normal file
Binary file not shown.
142
backend/routers/cctv.py
Normal file
142
backend/routers/cctv.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
backend/routers/cctv.py - CCTV ingestion and auto-mode session linkage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, require_role
|
||||
from backend.db.pool import get_pool
|
||||
from backend.services.auto_mode_matcher import auto_mode_match_session
|
||||
from backend.services.nemoclaw_client import profile_cctv_visitor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CCTVEventRequest(BaseModel):
|
||||
zone: str
|
||||
session_id: str | None = None
|
||||
license_plate: str | None = None
|
||||
face_description: str | None = None
|
||||
vehicle_description: str | None = None
|
||||
raw_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
captured_at: datetime | None = None
|
||||
|
||||
|
||||
class FinalizeAutoModeRequest(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
async def _ensure_session(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
session_id: str | None,
|
||||
) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO perception_sessions (id, session_mode, video_asset_id, auto_mode_evidence)
|
||||
VALUES ($1::uuid, 'auto', 'unknown', '{}'::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/event", summary="Receive a CCTV frame event from the ONVIF/RTSP bridge")
|
||||
async def ingest_cctv_event(
|
||||
body: CCTVEventRequest,
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
|
||||
) -> dict[str, Any]:
|
||||
del user
|
||||
profile = await profile_cctv_visitor(
|
||||
license_plate=body.license_plate,
|
||||
zone=body.zone,
|
||||
face_description=body.face_description,
|
||||
vehicle_description=body.vehicle_description,
|
||||
)
|
||||
captured_at = body.captured_at or datetime.now(timezone.utc)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await _ensure_session(conn, session_id=body.session_id)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO cctv_events
|
||||
(zone, license_plate, vehicle_class, wealth_indicator, nemoclaw_tags,
|
||||
nemoclaw_notes, linked_session_id, captured_at, raw_payload)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5::text[], $6, $7::uuid, $8, $9::jsonb)
|
||||
RETURNING id::text
|
||||
""",
|
||||
body.zone,
|
||||
body.license_plate,
|
||||
profile.vehicle_class,
|
||||
profile.wealth_indicator,
|
||||
profile.tags_to_add,
|
||||
profile.notes,
|
||||
body.session_id,
|
||||
captured_at,
|
||||
body.raw_payload,
|
||||
)
|
||||
|
||||
if body.session_id:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE perception_sessions
|
||||
SET auto_mode_evidence = auto_mode_evidence || $1::jsonb
|
||||
WHERE id = $2::uuid
|
||||
""",
|
||||
{
|
||||
"license_plate": body.license_plate,
|
||||
"vehicle_description": body.vehicle_description,
|
||||
"face_description": body.face_description,
|
||||
"vehicle_class": profile.vehicle_class,
|
||||
"wealth_indicator": profile.wealth_indicator,
|
||||
"nemoclaw_tags": profile.tags_to_add,
|
||||
"latest_cctv_event_id": row["id"],
|
||||
},
|
||||
body.session_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ingested",
|
||||
"event_id": row["id"],
|
||||
"session_id": body.session_id,
|
||||
"wealth_indicator": profile.wealth_indicator,
|
||||
"vehicle_class": profile.vehicle_class,
|
||||
"tags_to_add": profile.tags_to_add,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/finalize-auto-mode", summary="Match or create a lead after an auto mode session")
|
||||
async def finalize_auto_mode(
|
||||
body: FinalizeAutoModeRequest,
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
|
||||
) -> dict[str, Any]:
|
||||
del user
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
try:
|
||||
result = await auto_mode_match_session(conn, session_id=body.session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"status": "matched",
|
||||
"session_id": body.session_id,
|
||||
"lead_id": result.lead_id,
|
||||
"action": result.action,
|
||||
"confidence": result.confidence,
|
||||
"rationale": result.rationale,
|
||||
"tags_applied": result.tags_applied,
|
||||
}
|
||||
102
backend/routers/scenes.py
Normal file
102
backend/routers/scenes.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
backend/routers/scenes.py - Video scene map ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, require_role
|
||||
from backend.db.pool import get_pool
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/upload", summary="Upload a scene CSV for a marketing video")
|
||||
async def upload_scene_map(
|
||||
video_asset_id: str,
|
||||
file: UploadFile = File(...),
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
|
||||
) -> dict[str, int | str]:
|
||||
del user
|
||||
if not file.filename or not file.filename.lower().endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="Scene upload must be a CSV file.")
|
||||
|
||||
raw_bytes = await file.read()
|
||||
try:
|
||||
text = raw_bytes.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="CSV must be UTF-8 encoded.") from exc
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
required = {"scene_no", "start_ms", "end_ms", "room_type"}
|
||||
if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="CSV must contain scene_no,start_ms,end_ms,room_type columns.",
|
||||
)
|
||||
|
||||
rows: list[tuple[str, int, int, int, str, str | None]] = []
|
||||
for row in reader:
|
||||
try:
|
||||
rows.append(
|
||||
(
|
||||
video_asset_id,
|
||||
int(row["scene_no"]),
|
||||
int(row["start_ms"]),
|
||||
int(row["end_ms"]),
|
||||
row["room_type"].strip(),
|
||||
(row.get("description") or "").strip() or None,
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError, KeyError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scene row: {row}") from exc
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="CSV contains no scene rows.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"DELETE FROM video_scene_maps WHERE video_asset_id = $1",
|
||||
video_asset_id,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO video_scene_maps
|
||||
(video_asset_id, scene_no, start_ms, end_ms, room_type, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
|
||||
return {"status": "uploaded", "video_asset_id": video_asset_id, "row_count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/{video_asset_id}", summary="List the uploaded scene map for a marketing video")
|
||||
async def get_scene_map(
|
||||
video_asset_id: str,
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
|
||||
) -> dict[str, object]:
|
||||
del user
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT scene_no, start_ms, end_ms, room_type, description
|
||||
FROM video_scene_maps
|
||||
WHERE video_asset_id = $1
|
||||
ORDER BY scene_no ASC
|
||||
""",
|
||||
video_asset_id,
|
||||
)
|
||||
return {
|
||||
"video_asset_id": video_asset_id,
|
||||
"row_count": len(rows),
|
||||
"scenes": [dict(row) for row in rows],
|
||||
}
|
||||
479
backend/routers/sentinel.py
Normal file
479
backend/routers/sentinel.py
Normal 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
190
backend/routers/vault.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
backend/routers/vault.py — Velocity Vault (Trackable Link) Router
|
||||
|
||||
Endpoints:
|
||||
POST /api/vault/generate-link → Generate a trackable URL for a shared asset
|
||||
GET /vault/{tracking_hash} → Public link accessed by the prospect;
|
||||
logs the open, fires WS_ASSET_OPENED
|
||||
|
||||
SRS Reference: Section 3C — Velocity Link Generation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from pydantic import BaseModel, UUID4
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, require_role
|
||||
from backend.db.pool import get_pool
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Pydantic models ───────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateLinkRequest(BaseModel):
|
||||
lead_id: str
|
||||
asset_name: str
|
||||
asset_type: str # 'pdf' | 'image' | 'video'
|
||||
storage_path: str # relative to /opt/dlami/nvme/assets/
|
||||
|
||||
|
||||
class GenerateLinkResponse(BaseModel):
|
||||
tracking_hash: str
|
||||
vault_url: str
|
||||
asset_id: str
|
||||
|
||||
|
||||
# ── Helper: WebSocket broadcast ───────────────────────────────────────────────
|
||||
|
||||
async def _broadcast_vault_opened(
|
||||
request: Request,
|
||||
lead_id: str,
|
||||
lead_name: str,
|
||||
asset_name: str,
|
||||
tracking_hash: str,
|
||||
ip: Optional[str],
|
||||
) -> None:
|
||||
"""Fires WS_ASSET_OPENED to all broker WebSocket clients watching this lead."""
|
||||
broadcast = getattr(request.app.state, "broadcast_sentinel_event", None)
|
||||
if broadcast:
|
||||
await broadcast({
|
||||
"type": "WS_ASSET_OPENED",
|
||||
"data": {
|
||||
"lead_id": lead_id,
|
||||
"lead_name": lead_name,
|
||||
"asset_name": asset_name,
|
||||
"tracking_hash": tracking_hash,
|
||||
"opened_at": datetime.now(timezone.utc).isoformat(),
|
||||
"ip": ip,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# ── POST /api/vault/generate-link ─────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/generate-link",
|
||||
response_model=GenerateLinkResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate a trackable Velocity Link for a document share",
|
||||
)
|
||||
async def generate_link(
|
||||
body: GenerateLinkRequest,
|
||||
request: Request,
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
|
||||
) -> GenerateLinkResponse:
|
||||
"""
|
||||
Creates a cryptographically unique URL for every document share instance.
|
||||
When the prospect opens the URL, FastAPI logs the event and fires a
|
||||
real-time WebSocket notification to the broker's Active Notification Center.
|
||||
"""
|
||||
tracking_hash = secrets.token_hex(32) # 64 character hex string
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO velocity_vault_assets
|
||||
(asset_name, asset_type, storage_path, tracking_hash, lead_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5::uuid, $6::uuid)
|
||||
RETURNING id::text
|
||||
""",
|
||||
body.asset_name,
|
||||
body.asset_type,
|
||||
body.storage_path,
|
||||
tracking_hash,
|
||||
body.lead_id,
|
||||
user.user_id,
|
||||
)
|
||||
|
||||
base_url = os.getenv("VELOCITY_API_BASE_URL", "http://localhost:8000")
|
||||
vault_url = f"{base_url}/vault/{tracking_hash}"
|
||||
|
||||
return GenerateLinkResponse(
|
||||
tracking_hash=tracking_hash,
|
||||
vault_url=vault_url,
|
||||
asset_id=row["id"],
|
||||
)
|
||||
|
||||
|
||||
# ── GET /vault/{tracking_hash} ────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/{tracking_hash}",
|
||||
summary="Public Velocity Link endpoint — accessed by the prospect",
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def open_vault_link(
|
||||
tracking_hash: str,
|
||||
request: Request,
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
) -> RedirectResponse:
|
||||
"""
|
||||
No auth required — this URL is shared with the prospect externally.
|
||||
|
||||
On access:
|
||||
1. Appends NOW() to velocity_vault_assets.opened_at
|
||||
2. Writes a WS_ASSET_OPENED entry to omnichannel_logs
|
||||
3. Broadcasts the event to all connected broker WebSocket clients
|
||||
4. Redirects the prospect to the actual asset file
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE velocity_vault_assets
|
||||
SET opened_at = array_append(opened_at, NOW())
|
||||
WHERE tracking_hash = $1
|
||||
RETURNING id::text, lead_id::text, asset_name, storage_path
|
||||
""",
|
||||
tracking_hash,
|
||||
)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Link not found or expired.")
|
||||
|
||||
lead_id = row["lead_id"]
|
||||
asset_name = row["asset_name"]
|
||||
|
||||
# Fetch lead name for the notification body
|
||||
lead_row = await conn.fetchrow(
|
||||
"SELECT name FROM leads_intelligence WHERE id = $1::uuid",
|
||||
lead_id,
|
||||
)
|
||||
lead_name = lead_row["name"] if lead_row else "Unknown Lead"
|
||||
|
||||
# Write to omnichannel_logs
|
||||
ip = request.client.host if request.client else None
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO omnichannel_logs (event_type, lead_id, payload)
|
||||
VALUES ('WS_ASSET_OPENED', $1::uuid, $2::jsonb)
|
||||
""",
|
||||
lead_id,
|
||||
{
|
||||
"tracking_hash": tracking_hash,
|
||||
"asset_name": asset_name,
|
||||
"ip": ip,
|
||||
"user_agent": request.headers.get("user-agent", ""),
|
||||
},
|
||||
)
|
||||
|
||||
# Fire real-time WebSocket broadcast to all brokers
|
||||
await _broadcast_vault_opened(
|
||||
request=request,
|
||||
lead_id=lead_id,
|
||||
lead_name=lead_name,
|
||||
asset_name=asset_name,
|
||||
tracking_hash=tracking_hash,
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
# Redirect to the static asset file served by FastAPI
|
||||
asset_url = f"/assets/{row['storage_path']}"
|
||||
return RedirectResponse(url=asset_url, status_code=302)
|
||||
109
backend/routers/videos.py
Normal file
109
backend/routers/videos.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
backend/routers/videos.py - Marketing video catalog for Sentinel live sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||
DEFAULT_COLORS = ["#3b82f6", "#06b6d4", "#8b5cf6", "#10b981", "#f59e0b", "#ef4444"]
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
|
||||
|
||||
def _humanize(value: str) -> str:
|
||||
base = re.sub(r"[-_]+", " ", value).strip()
|
||||
return re.sub(r"\s+", " ", base).title()
|
||||
|
||||
|
||||
def _derive_unit(name: str) -> str:
|
||||
parts = re.findall(r"[A-Za-z0-9]+", name)
|
||||
if not parts:
|
||||
return "N/A"
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}-{parts[1]}"
|
||||
return parts[0]
|
||||
|
||||
|
||||
def _build_record(
|
||||
*,
|
||||
file_path: Path,
|
||||
public_root: str,
|
||||
metadata: dict[str, Any] | None,
|
||||
color_index: int,
|
||||
) -> dict[str, Any]:
|
||||
rel_path = file_path.relative_to(public_root).as_posix()
|
||||
name = metadata.get("title") if metadata else None
|
||||
title = name or _humanize(file_path.stem.replace("video", "").replace("Video", ""))
|
||||
property_name = metadata.get("property_name") if metadata else None
|
||||
property_name = property_name or _humanize(file_path.parent.name if file_path.parent.name != "videos" else file_path.stem)
|
||||
slug = metadata.get("id") if metadata else None
|
||||
slug = slug or _slugify(file_path.stem)
|
||||
|
||||
return {
|
||||
"id": slug,
|
||||
"title": title,
|
||||
"property_name": property_name,
|
||||
"unit_number": (metadata or {}).get("unit_number") or _derive_unit(file_path.stem),
|
||||
"type": (metadata or {}).get("type") or "Property Walkthrough",
|
||||
"duration_seconds": int((metadata or {}).get("duration_seconds") or 0),
|
||||
"video_url": f"/assets/{rel_path}",
|
||||
"thumbnail_color": (metadata or {}).get("thumbnail_color") or DEFAULT_COLORS[color_index % len(DEFAULT_COLORS)],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketing", summary="List marketing videos available for Sentinel live sessions")
|
||||
async def list_marketing_videos() -> dict[str, Any]:
|
||||
asset_root = Path(os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets"))
|
||||
video_root = Path(os.getenv("VELOCITY_VIDEO_DIR", str(asset_root / "videos")))
|
||||
catalog_path = video_root / "catalog.json"
|
||||
|
||||
catalog_entries: list[dict[str, Any]] = []
|
||||
if catalog_path.exists():
|
||||
catalog_entries = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
|
||||
records: list[dict[str, Any]] = []
|
||||
indexed: set[Path] = set()
|
||||
for idx, entry in enumerate(catalog_entries):
|
||||
file_path = video_root / entry["storage_path"]
|
||||
if not file_path.exists():
|
||||
continue
|
||||
indexed.add(file_path.resolve())
|
||||
records.append(
|
||||
_build_record(
|
||||
file_path=file_path,
|
||||
public_root=str(asset_root),
|
||||
metadata=entry,
|
||||
color_index=idx,
|
||||
)
|
||||
)
|
||||
|
||||
unindexed_files = sorted(
|
||||
[
|
||||
path
|
||||
for path in video_root.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS and path.resolve() not in indexed
|
||||
]
|
||||
)
|
||||
for idx, file_path in enumerate(unindexed_files, start=len(records)):
|
||||
records.append(
|
||||
_build_record(
|
||||
file_path=file_path,
|
||||
public_root=str(asset_root),
|
||||
metadata=None,
|
||||
color_index=idx,
|
||||
)
|
||||
)
|
||||
|
||||
return {"count": len(records), "videos": records}
|
||||
BIN
backend/scripts/__pycache__/cctv_ocr_bridge.cpython-314.pyc
Normal file
BIN
backend/scripts/__pycache__/cctv_ocr_bridge.cpython-314.pyc
Normal file
Binary file not shown.
40
backend/scripts/cctv_ocr_bridge.py
Normal file
40
backend/scripts/cctv_ocr_bridge.py
Normal 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())
|
||||
394
backend/scripts/nemoclaw_deploy.sh
Normal file
394
backend/scripts/nemoclaw_deploy.sh
Normal 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 "================================================================"
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.services package"""
|
||||
BIN
backend/services/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/services/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/auto_mode_matcher.cpython-314.pyc
Normal file
BIN
backend/services/__pycache__/auto_mode_matcher.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/nemoclaw_client.cpython-314.pyc
Normal file
BIN
backend/services/__pycache__/nemoclaw_client.cpython-314.pyc
Normal file
Binary file not shown.
217
backend/services/auto_mode_matcher.py
Normal file
217
backend/services/auto_mode_matcher.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
backend/services/auto_mode_matcher.py - Post-session lead matching for Sentinel auto mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoModeMatchResult:
|
||||
action: str
|
||||
lead_id: str
|
||||
confidence: float
|
||||
rationale: str
|
||||
tags_applied: list[str]
|
||||
|
||||
|
||||
def _normalise_plate(plate: str | None) -> str | None:
|
||||
if not plate:
|
||||
return None
|
||||
cleaned = "".join(ch for ch in plate.upper() if ch.isalnum())
|
||||
return cleaned or None
|
||||
|
||||
|
||||
async def _find_match_by_plate(
|
||||
conn: asyncpg.Connection,
|
||||
normalized_plate: str,
|
||||
) -> tuple[str, float, str] | None:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT linked_lead_id::text AS lead_id
|
||||
FROM cctv_events
|
||||
WHERE regexp_replace(COALESCE(license_plate, ''), '[^A-Za-z0-9]', '', 'g') = $1
|
||||
AND linked_lead_id IS NOT NULL
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
normalized_plate,
|
||||
)
|
||||
if row:
|
||||
return row["lead_id"], 0.96, "matched_existing_plate"
|
||||
return None
|
||||
|
||||
|
||||
async def _find_match_by_tags(
|
||||
conn: asyncpg.Connection,
|
||||
tags: list[str],
|
||||
) -> tuple[str, float, str] | None:
|
||||
if not tags:
|
||||
return None
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS lead_id,
|
||||
COALESCE(cardinality(tags & $1::text[]), 0) AS overlap,
|
||||
last_active
|
||||
FROM leads_intelligence
|
||||
WHERE tags && $1::text[]
|
||||
AND status IN ('engaged', 'qualified', 'hot')
|
||||
ORDER BY overlap DESC, last_active DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
tags,
|
||||
)
|
||||
if row and row["overlap"] > 0:
|
||||
confidence = min(0.65 + (0.1 * int(row["overlap"])), 0.85)
|
||||
return row["lead_id"], confidence, "matched_tag_overlap"
|
||||
return None
|
||||
|
||||
|
||||
async def _create_auto_lead(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
wealth_indicator: str,
|
||||
tags: list[str],
|
||||
session_id: str,
|
||||
) -> str:
|
||||
name = f"Auto Visitor {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
|
||||
qualification = "whale" if wealth_indicator == "HNI" else "potential"
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO leads_intelligence
|
||||
(name, source, status, qualification, quantum_dynamics_score, tags, last_message)
|
||||
VALUES
|
||||
($1, 'walkin', 'new', $2::qualification_enum, 50, $3::text[], $4)
|
||||
RETURNING id::text
|
||||
""",
|
||||
name,
|
||||
qualification,
|
||||
tags,
|
||||
f"Auto-created from Sentinel auto mode session {session_id}",
|
||||
)
|
||||
return row["id"]
|
||||
|
||||
|
||||
async def auto_mode_match_session(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> AutoModeMatchResult:
|
||||
session = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text, lead_id::text, session_mode, auto_mode_evidence, final_qd_score
|
||||
FROM perception_sessions
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
if not session:
|
||||
raise ValueError(f"Session {session_id} not found.")
|
||||
if session["session_mode"] != "auto":
|
||||
raise ValueError("auto_mode_match_session can only be used for auto sessions.")
|
||||
if session["lead_id"]:
|
||||
return AutoModeMatchResult(
|
||||
action="already_linked",
|
||||
lead_id=session["lead_id"],
|
||||
confidence=1.0,
|
||||
rationale="session_already_has_lead",
|
||||
tags_applied=[],
|
||||
)
|
||||
|
||||
evidence: dict[str, Any] = dict(session["auto_mode_evidence"] or {})
|
||||
normalized_plate = _normalise_plate(evidence.get("license_plate"))
|
||||
inferred_tags = list(dict.fromkeys((evidence.get("tags") or []) + (evidence.get("nemoclaw_tags") or [])))
|
||||
wealth_indicator = str(evidence.get("wealth_indicator") or "unknown")
|
||||
|
||||
match: tuple[str, float, str] | None = None
|
||||
if normalized_plate:
|
||||
match = await _find_match_by_plate(conn, normalized_plate)
|
||||
if not match:
|
||||
match = await _find_match_by_tags(conn, inferred_tags)
|
||||
|
||||
action = "linked_existing" if match else "created_new"
|
||||
if match:
|
||||
lead_id, confidence, rationale = match
|
||||
else:
|
||||
lead_id = await _create_auto_lead(
|
||||
conn,
|
||||
wealth_indicator=wealth_indicator,
|
||||
tags=inferred_tags,
|
||||
session_id=session_id,
|
||||
)
|
||||
confidence = 0.55
|
||||
rationale = "created_new_from_auto_mode"
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE perception_sessions
|
||||
SET lead_id = $1::uuid,
|
||||
auto_mode_matched_at = NOW(),
|
||||
auto_mode_evidence = auto_mode_evidence || $2::jsonb
|
||||
WHERE id = $3::uuid
|
||||
""",
|
||||
lead_id,
|
||||
{
|
||||
"match_action": action,
|
||||
"match_confidence": confidence,
|
||||
"match_rationale": rationale,
|
||||
},
|
||||
session_id,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE cctv_events
|
||||
SET linked_lead_id = $1::uuid
|
||||
WHERE linked_session_id = $2::uuid
|
||||
AND linked_lead_id IS NULL
|
||||
""",
|
||||
lead_id,
|
||||
session_id,
|
||||
)
|
||||
|
||||
if inferred_tags:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE leads_intelligence
|
||||
SET tags = ARRAY(
|
||||
SELECT DISTINCT unnest(COALESCE(tags, ARRAY[]::text[]) || $1::text[])
|
||||
),
|
||||
quantum_dynamics_score = COALESCE($2, quantum_dynamics_score),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3::uuid
|
||||
""",
|
||||
inferred_tags,
|
||||
session["final_qd_score"],
|
||||
lead_id,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO omnichannel_logs (event_type, lead_id, payload)
|
||||
VALUES ('LEAD_TAGGED', $1::uuid, $2::jsonb)
|
||||
""",
|
||||
lead_id,
|
||||
{
|
||||
"source": "auto_mode_matcher",
|
||||
"session_id": session_id,
|
||||
"tags_added": inferred_tags,
|
||||
"match_action": action,
|
||||
"match_confidence": confidence,
|
||||
"match_rationale": rationale,
|
||||
},
|
||||
)
|
||||
|
||||
return AutoModeMatchResult(
|
||||
action=action,
|
||||
lead_id=lead_id,
|
||||
confidence=confidence,
|
||||
rationale=rationale,
|
||||
tags_applied=inferred_tags,
|
||||
)
|
||||
413
backend/services/nemoclaw_client.py
Normal file
413
backend/services/nemoclaw_client.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
35
backend/tests/test_nemoclaw_score_qd.py
Normal file
35
backend/tests/test_nemoclaw_score_qd.py
Normal 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
|
||||
90
backend/tests/test_vault_notification_flow.py
Normal file
90
backend/tests/test_vault_notification_flow.py
Normal 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'
|
||||
Reference in New Issue
Block a user