forked from sagnik/Project_Velocity
feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
@@ -11,12 +11,35 @@ 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"]),
|
||||
@@ -24,7 +47,7 @@ def _serialize_person(row: Any) -> dict[str, Any]:
|
||||
"primary_email": row["primary_email"],
|
||||
"primary_phone": row["primary_phone"],
|
||||
"buyer_type": row["buyer_type"],
|
||||
"persona_labels": row["persona_labels"] or [],
|
||||
"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,
|
||||
}
|
||||
@@ -38,8 +61,8 @@ def _serialize_lead(row: Any) -> dict[str, Any]:
|
||||
"urgency": row["urgency"],
|
||||
"financing_posture": row["financing_posture"],
|
||||
"timeline_to_decision": row["timeline_to_decision"],
|
||||
"objections": row["objections"] or [],
|
||||
"motivations": row["motivations"] or [],
|
||||
"objections": _json_string_list(row["objections"]),
|
||||
"motivations": _json_string_list(row["motivations"]),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
@@ -99,7 +122,7 @@ def _serialize_property_interest(row: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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.
|
||||
@@ -111,8 +134,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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
|
||||
@@ -126,9 +151,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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 = [
|
||||
{
|
||||
@@ -147,10 +175,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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
|
||||
|
||||
@@ -162,10 +192,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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]
|
||||
|
||||
@@ -175,10 +208,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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]
|
||||
|
||||
@@ -189,10 +224,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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]
|
||||
|
||||
@@ -202,11 +239,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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]
|
||||
|
||||
@@ -216,8 +255,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
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}
|
||||
|
||||
@@ -262,6 +303,7 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
|
||||
async def get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
@@ -272,8 +314,8 @@ async def get_contact_list(
|
||||
Paginated contact list with lead status and QD summary.
|
||||
Implements the 'summary query' pattern from Doc 09.
|
||||
"""
|
||||
clauses: list[str] = ["1=1"]
|
||||
params: list[Any] = []
|
||||
clauses: list[str] = ["p.tenant_id = $1"]
|
||||
params: list[Any] = [tenant_id]
|
||||
|
||||
if search:
|
||||
params.append(f"%{search}%")
|
||||
@@ -310,14 +352,15 @@ async def get_contact_list(
|
||||
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) AS interaction_count,
|
||||
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS last_interaction_at,
|
||||
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.status = 'pending') AS pending_tasks
|
||||
(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
|
||||
@@ -325,6 +368,7 @@ async def get_contact_list(
|
||||
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
|
||||
@@ -335,6 +379,7 @@ async def get_contact_list(
|
||||
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
|
||||
@@ -344,7 +389,7 @@ async def get_contact_list(
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM crm_people p
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = p.tenant_id
|
||||
{where}
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user