Files
Velocity-OS/core/services/services/auto_mode_matcher.py

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,
)