forked from sagnik/Velocity-OS
218 lines
6.1 KiB
Python
218 lines
6.1 KiB
Python
"""
|
|
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,
|
|
)
|