forked from sagnik/Project_Velocity
Built the Sentinel Tab
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user