429 lines
15 KiB
Python
429 lines
15 KiB
Python
"""
|
|
backend/services/client_graph/aggregation_service.py
|
|
Client 360 Aggregation Service
|
|
|
|
Produces Client360Snapshot read models by joining across
|
|
crm_people, crm_leads, crm_opportunities, intel_interactions,
|
|
intel_reminders, intel_qd_scores, crm_property_interests.
|
|
|
|
This is a derived read model — never the sole source of truth.
|
|
As specified in Doc 07 (Client360Snapshot contract) and Doc 08 (Adapter Spec).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("velocity.client_graph.aggregation")
|
|
|
|
|
|
def _json_string_list(value: Any) -> list[str]:
|
|
"""Normalize canonical array fields that may arrive as jsonb, text[], or JSON text."""
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list | tuple):
|
|
return [str(item) for item in value if item is not None]
|
|
if isinstance(value, str):
|
|
normalized = value.strip()
|
|
if not normalized:
|
|
return []
|
|
try:
|
|
parsed = json.loads(normalized)
|
|
except json.JSONDecodeError:
|
|
return [normalized]
|
|
if isinstance(parsed, list):
|
|
return [str(item) for item in parsed if item is not None]
|
|
if parsed is None:
|
|
return []
|
|
return [str(parsed)]
|
|
return [str(value)]
|
|
|
|
|
|
def _serialize_person(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"person_id": str(row["person_id"]),
|
|
"full_name": row["full_name"],
|
|
"primary_email": row["primary_email"],
|
|
"primary_phone": row["primary_phone"],
|
|
"buyer_type": row["buyer_type"],
|
|
"persona_labels": _json_string_list(row["persona_labels"]),
|
|
"source_confidence": float(row["source_confidence"] or 0.0),
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
}
|
|
|
|
|
|
def _serialize_lead(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"lead_id": str(row["lead_id"]),
|
|
"status": row["status"],
|
|
"budget_band": row["budget_band"],
|
|
"urgency": row["urgency"],
|
|
"financing_posture": row["financing_posture"],
|
|
"timeline_to_decision": row["timeline_to_decision"],
|
|
"objections": _json_string_list(row["objections"]),
|
|
"motivations": _json_string_list(row["motivations"]),
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
}
|
|
|
|
|
|
def _serialize_opportunity(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"opportunity_id": str(row["opportunity_id"]),
|
|
"stage": row["stage"],
|
|
"value": float(row["value"]) if row["value"] else None,
|
|
"probability": row["probability"],
|
|
"expected_close_date": row["expected_close_date"].isoformat() if row["expected_close_date"] else None,
|
|
"next_action": row["next_action"],
|
|
"project_id": str(row["project_id"]) if row["project_id"] else None,
|
|
"unit_id": str(row["unit_id"]) if row["unit_id"] else None,
|
|
}
|
|
|
|
|
|
def _serialize_interaction(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"interaction_id": str(row["interaction_id"]),
|
|
"channel": row["channel"],
|
|
"interaction_type": row["interaction_type"],
|
|
"happened_at": row["happened_at"].isoformat() if row["happened_at"] else None,
|
|
"summary": row["summary"],
|
|
}
|
|
|
|
|
|
def _serialize_reminder(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"reminder_id": str(row["reminder_id"]),
|
|
"reminder_type": row["reminder_type"],
|
|
"title": row["title"],
|
|
"due_at": row["due_at"].isoformat() if row["due_at"] else None,
|
|
"status": row["status"],
|
|
"priority": row["priority"],
|
|
}
|
|
|
|
|
|
def _serialize_qd_score(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"score_type": row["score_type"],
|
|
"current_value": float(row["current_value"]),
|
|
"computed_at": row["computed_at"].isoformat() if row["computed_at"] else None,
|
|
"reasoning": row["reasoning"],
|
|
}
|
|
|
|
|
|
def _serialize_property_interest(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"interest_id": str(row["interest_id"]),
|
|
"project_name": row["project_name"],
|
|
"unit_preference": row["unit_preference"],
|
|
"configuration": row["configuration"],
|
|
"budget_min": float(row["budget_min"]) if row["budget_min"] else None,
|
|
"budget_max": float(row["budget_max"]) if row["budget_max"] else None,
|
|
"priority": row["priority"],
|
|
}
|
|
|
|
|
|
async def get_client_360(conn: Any, tenant_id: str, person_id: str) -> dict[str, Any] | None:
|
|
"""
|
|
Aggregate a full Client360Snapshot for a given person_id.
|
|
This is a read model — derived from canonical tables, never primary truth.
|
|
"""
|
|
# 1. Core identity
|
|
person_row = await conn.fetchrow(
|
|
"""
|
|
SELECT person_id, full_name, primary_email, primary_phone,
|
|
buyer_type, persona_labels, source_confidence, created_at
|
|
FROM crm_people
|
|
WHERE person_id = $1::uuid
|
|
AND tenant_id = $2
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
if not person_row:
|
|
return None
|
|
|
|
identity = _serialize_person(person_row)
|
|
|
|
# 2. Account links
|
|
account_rows = await conn.fetch(
|
|
"""
|
|
SELECT ca.account_id, ca.account_name, ca.account_type, ca.industry
|
|
FROM crm_accounts ca
|
|
INNER JOIN crm_leads cl ON cl.account_id = ca.account_id
|
|
WHERE cl.person_id = $1::uuid
|
|
AND cl.tenant_id = $2
|
|
AND ca.tenant_id = $2
|
|
LIMIT 5
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
account_links = [
|
|
{
|
|
"account_id": str(r["account_id"]),
|
|
"account_name": r["account_name"],
|
|
"account_type": r["account_type"],
|
|
"industry": r["industry"],
|
|
}
|
|
for r in account_rows
|
|
]
|
|
|
|
# 3. Active lead
|
|
lead_row = await conn.fetchrow(
|
|
"""
|
|
SELECT lead_id, status, budget_band, urgency, financing_posture,
|
|
timeline_to_decision, objections, motivations, created_at
|
|
FROM crm_leads
|
|
WHERE person_id = $1::uuid
|
|
AND tenant_id = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
lead = _serialize_lead(lead_row) if lead_row else None
|
|
|
|
# 4. Active opportunities (top 5)
|
|
opp_rows = await conn.fetch(
|
|
"""
|
|
SELECT co.opportunity_id, co.stage, co.value, co.probability,
|
|
co.expected_close_date, co.next_action, co.project_id, co.unit_id
|
|
FROM crm_opportunities co
|
|
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
|
|
WHERE cl.person_id = $1::uuid
|
|
AND cl.tenant_id = $2
|
|
AND co.tenant_id = $2
|
|
ORDER BY co.updated_at DESC
|
|
LIMIT 5
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
active_opportunities = [_serialize_opportunity(r) for r in opp_rows]
|
|
|
|
# 5. Recent interactions (last 10)
|
|
interaction_rows = await conn.fetch(
|
|
"""
|
|
SELECT interaction_id, channel, interaction_type, happened_at, summary
|
|
FROM intel_interactions
|
|
WHERE person_id = $1::uuid
|
|
AND tenant_id = $2
|
|
ORDER BY happened_at DESC
|
|
LIMIT 10
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
recent_interactions = [_serialize_interaction(r) for r in interaction_rows]
|
|
|
|
# 6. Property interests
|
|
interest_rows = await conn.fetch(
|
|
"""
|
|
SELECT interest_id, project_name, unit_preference, configuration,
|
|
budget_min, budget_max, priority
|
|
FROM crm_property_interests
|
|
WHERE person_id = $1::uuid
|
|
AND tenant_id = $2
|
|
ORDER BY priority ASC, interest_id ASC
|
|
LIMIT 10
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
property_interests = [_serialize_property_interest(r) for r in interest_rows]
|
|
|
|
# 7. Pending tasks / reminders
|
|
task_rows = await conn.fetch(
|
|
"""
|
|
SELECT reminder_id, reminder_type, title, due_at, status, priority
|
|
FROM intel_reminders
|
|
WHERE person_id = $1::uuid
|
|
AND tenant_id = $2
|
|
AND status IN ('pending', 'snoozed')
|
|
ORDER BY due_at ASC NULLS LAST
|
|
LIMIT 10
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
tasks = [_serialize_reminder(r) for r in task_rows]
|
|
|
|
# 8. QD overview (all score types)
|
|
qd_rows = await conn.fetch(
|
|
"""
|
|
SELECT score_type, current_value, computed_at, reasoning
|
|
FROM intel_qd_scores
|
|
WHERE person_id = $1::uuid
|
|
AND tenant_id = $2
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
)
|
|
qd_overview = {r["score_type"]: _serialize_qd_score(r) for r in qd_rows}
|
|
|
|
# 9. Risk flags — heuristic derivation
|
|
risk_flags: list[str] = []
|
|
if lead and lead.get("urgency") in ("high", "critical") and not active_opportunities:
|
|
risk_flags.append("high_urgency_without_active_opportunity")
|
|
if not recent_interactions:
|
|
risk_flags.append("no_recent_interactions")
|
|
if qd_overview.get("intent_score", {}).get("current_value", 1.0) < 0.3:
|
|
risk_flags.append("low_intent_score")
|
|
if not property_interests:
|
|
risk_flags.append("no_property_interests_recorded")
|
|
|
|
# 10. Recommended next actions — simple heuristic
|
|
recommended_next_actions: list[str] = []
|
|
if tasks:
|
|
overdue = [t for t in tasks if t.get("status") == "pending"]
|
|
if overdue:
|
|
recommended_next_actions.append(f"Complete pending task: {overdue[0]['title']}")
|
|
if lead and lead.get("urgency") in ("high", "critical"):
|
|
recommended_next_actions.append("High-urgency client — prioritize callback within 24h")
|
|
if not recent_interactions and lead:
|
|
recommended_next_actions.append("No recent interactions — schedule follow-up")
|
|
|
|
return {
|
|
"client_ref": person_id,
|
|
"snapshot_type": "client_360",
|
|
"identity": identity,
|
|
"account_links": account_links,
|
|
"current_lead": lead,
|
|
"active_opportunities": active_opportunities,
|
|
"recent_interactions": recent_interactions,
|
|
"property_interests": property_interests,
|
|
"tasks": tasks,
|
|
"qd_overview": qd_overview,
|
|
"risk_flags": risk_flags,
|
|
"recommended_next_actions": recommended_next_actions,
|
|
"note": "Derived read model. Not primary truth. Refresh from canonical tables.",
|
|
}
|
|
|
|
|
|
async def get_contact_list(
|
|
conn: Any,
|
|
tenant_id: str,
|
|
search: str | None = None,
|
|
buyer_type: str | None = None,
|
|
status: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Paginated contact list with lead status and QD summary.
|
|
Implements the 'summary query' pattern from Doc 09.
|
|
"""
|
|
clauses: list[str] = ["p.tenant_id = $1"]
|
|
params: list[Any] = [tenant_id]
|
|
|
|
if search:
|
|
params.append(f"%{search}%")
|
|
clauses.append(
|
|
f"(p.full_name ILIKE ${len(params)} OR p.primary_email ILIKE ${len(params)} OR p.primary_phone ILIKE ${len(params)})"
|
|
)
|
|
if buyer_type:
|
|
params.append(buyer_type)
|
|
clauses.append(f"p.buyer_type = ${len(params)}")
|
|
if status:
|
|
params.append(status)
|
|
clauses.append(f"cl.status = ${len(params)}::crm_lead_status")
|
|
|
|
where = "WHERE " + " AND ".join(clauses)
|
|
params_for_count = params.copy()
|
|
|
|
params.append(limit)
|
|
params.append(offset)
|
|
|
|
query = f"""
|
|
SELECT
|
|
p.person_id,
|
|
p.full_name,
|
|
p.primary_email,
|
|
p.primary_phone,
|
|
p.buyer_type,
|
|
p.legacy_li_id,
|
|
p.created_at,
|
|
cl.lead_id,
|
|
cl.status AS lead_status,
|
|
cl.budget_band,
|
|
cl.urgency,
|
|
pi.project_name AS primary_interest,
|
|
COALESCE(qs.intent_value, 0.0) AS intent_score,
|
|
COALESCE(qs.engagement_value, qs.intent_value, 0.0) AS engagement_score,
|
|
COALESCE(qs.urgency_value, 0.0) AS urgency_score,
|
|
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS interaction_count,
|
|
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS last_interaction_at,
|
|
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.tenant_id = p.tenant_id AND ir.status = 'pending') AS pending_tasks
|
|
FROM crm_people p
|
|
LEFT JOIN LATERAL (
|
|
SELECT lead_id, status, budget_band, urgency
|
|
FROM crm_leads
|
|
WHERE person_id = p.person_id
|
|
AND tenant_id = p.tenant_id
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
) cl ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT project_name
|
|
FROM crm_property_interests
|
|
WHERE person_id = p.person_id
|
|
AND tenant_id = p.tenant_id
|
|
ORDER BY priority ASC, created_at DESC
|
|
LIMIT 1
|
|
) pi ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
MAX(CASE WHEN score_type = 'intent_score' THEN current_value END) AS intent_value,
|
|
MAX(CASE WHEN score_type = 'engagement_score' THEN current_value END) AS engagement_value,
|
|
MAX(CASE WHEN score_type = 'urgency_score' THEN current_value END) AS urgency_value
|
|
FROM intel_qd_scores
|
|
WHERE person_id = p.person_id
|
|
AND tenant_id = p.tenant_id
|
|
) qs ON TRUE
|
|
{where}
|
|
ORDER BY last_interaction_at DESC NULLS LAST, p.created_at DESC
|
|
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
|
"""
|
|
|
|
count_query = f"""
|
|
SELECT COUNT(*)
|
|
FROM crm_people p
|
|
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = p.tenant_id
|
|
{where}
|
|
"""
|
|
|
|
rows = await conn.fetch(query, *params)
|
|
total_row = await conn.fetchrow(count_query, *params_for_count)
|
|
total = int(total_row[0]) if total_row else 0
|
|
|
|
contacts = []
|
|
for r in rows:
|
|
contacts.append({
|
|
"person_id": str(r["person_id"]),
|
|
"full_name": r["full_name"],
|
|
"primary_email": r["primary_email"],
|
|
"primary_phone": r["primary_phone"],
|
|
"buyer_type": r["buyer_type"],
|
|
"lead_id": str(r["lead_id"]) if r["lead_id"] else None,
|
|
"legacy_li_id": str(r["legacy_li_id"]) if r["legacy_li_id"] else None,
|
|
"lead_status": r["lead_status"],
|
|
"budget_band": r["budget_band"],
|
|
"urgency": r["urgency"],
|
|
"primary_interest": r["primary_interest"],
|
|
"intent_score": float(r["intent_score"]),
|
|
"engagement_score": float(r["engagement_score"]),
|
|
"urgency_score": float(r["urgency_score"]),
|
|
"interaction_count": int(r["interaction_count"]),
|
|
"last_interaction_at": r["last_interaction_at"].isoformat() if r["last_interaction_at"] else None,
|
|
"pending_tasks": int(r["pending_tasks"]),
|
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
|
})
|
|
|
|
return {
|
|
"contacts": contacts,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|