Built the Sentinel Tab

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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

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

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

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

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

190
backend/routers/vault.py Normal file
View File

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

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

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