""" 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, }