Feat: CRM v2, Richer synthetic data, Canvas JSON Components
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
oracle/data_access_gateway.py
|
||||
Read-only, policy-aware PostgreSQL query executor for Oracle datasets.
|
||||
|
||||
Nemoclaw is treated strictly as a planner. The gateway executes only
|
||||
whitelisted dataset queries and always injects the actor's tenant scope.
|
||||
Nemoclaw/LLM is treated strictly as a planner. The gateway executes only
|
||||
whitelisted read models and always applies policy before touching data.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,9 +21,15 @@ from .policy_service import PolicyContext, PolicyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
_ALLOW_IN_MEMORY = os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
|
||||
|
||||
_DATASET_ALIASES = {
|
||||
"crm_last_interacted_clients": "oracle_last_contacted_clients",
|
||||
"crm_top_interested_clients": "oracle_top_interested_clients",
|
||||
"crm_interaction_timeline": "oracle_client_interaction_timeline",
|
||||
"crm_property_interest_rollup": "oracle_property_interest_rollup",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryExecutionResult:
|
||||
@@ -32,7 +38,29 @@ class QueryExecutionResult:
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
|
||||
if asyncpg is None:
|
||||
return False
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||
return True
|
||||
return all(
|
||||
os.getenv(name)
|
||||
for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
|
||||
)
|
||||
|
||||
|
||||
async def _connect_db() -> Any:
|
||||
assert asyncpg is not None
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||
return await asyncpg.connect(database_url)
|
||||
return await asyncpg.connect(
|
||||
host=os.getenv("VELOCITY_DB_HOST", "localhost"),
|
||||
port=int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
||||
database=os.environ["VELOCITY_DB_NAME"],
|
||||
user=os.environ["VELOCITY_DB_USER"],
|
||||
password=os.environ["VELOCITY_DB_PASSWORD"],
|
||||
)
|
||||
|
||||
|
||||
class DataAccessGateway:
|
||||
@@ -61,7 +89,7 @@ class DataAccessGateway:
|
||||
|
||||
try:
|
||||
rows = await self._query_dataset(
|
||||
dataset=dataset,
|
||||
dataset=_DATASET_ALIASES.get(dataset, dataset),
|
||||
row_limit=validation.effective_row_limit,
|
||||
ctx=ctx,
|
||||
prompt=prompt,
|
||||
@@ -82,8 +110,7 @@ class DataAccessGateway:
|
||||
prompt: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
sql, params = self._build_whitelisted_query(dataset, row_limit, ctx, prompt)
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
conn = await _connect_db()
|
||||
try:
|
||||
records = await conn.fetch(sql, *params)
|
||||
finally:
|
||||
@@ -101,23 +128,9 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "deals":
|
||||
sql = """
|
||||
SELECT
|
||||
stage,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(value), 0)::float AS value,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', lead_id,
|
||||
'name', lead_name,
|
||||
'company', company,
|
||||
'value', value_label,
|
||||
'avatar', avatar_url
|
||||
)
|
||||
ORDER BY value DESC NULLS LAST
|
||||
) FILTER (WHERE lead_id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
SELECT stage, COUNT(*)::int AS count, COALESCE(SUM(value), 0)::float AS value,
|
||||
COALESCE(json_agg(json_build_object('id', lead_id, 'name', lead_name, 'company', company, 'value', value_label, 'avatar', avatar_url)
|
||||
ORDER BY value DESC NULLS LAST) FILTER (WHERE lead_id IS NOT NULL), '[]'::json) AS leads
|
||||
FROM deals
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY stage
|
||||
@@ -128,9 +141,7 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "lead_daily_snapshot":
|
||||
sql = """
|
||||
SELECT
|
||||
source,
|
||||
COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
|
||||
SELECT source, COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
|
||||
FROM lead_daily_snapshot
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY source
|
||||
@@ -141,14 +152,9 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "lead_geo_interest_rollup":
|
||||
sql = """
|
||||
SELECT
|
||||
district,
|
||||
lat,
|
||||
lng,
|
||||
COALESCE(lead_count, 0)::int AS lead_count,
|
||||
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
|
||||
COALESCE(x, 0)::float AS x,
|
||||
COALESCE(y, 0)::float AS y
|
||||
SELECT district, lat, lng, COALESCE(lead_count, 0)::int AS lead_count,
|
||||
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
|
||||
COALESCE(x, 0)::float AS x, COALESCE(y, 0)::float AS y
|
||||
FROM lead_geo_interest_rollup
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY lead_count DESC, district ASC
|
||||
@@ -158,14 +164,11 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "broker_performance":
|
||||
sql = """
|
||||
SELECT
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY COUNT(DISTINCT l.person_id) DESC, COALESCE(u.full_name, u.email, u.id::text) ASC
|
||||
)::int AS rank,
|
||||
COALESCE(u.full_name, u.email, u.id::text) AS name,
|
||||
COUNT(DISTINCT l.person_id)::int AS deals_closed,
|
||||
COALESCE(SUM(o.value), 0)::float AS revenue_generated,
|
||||
u.avatar_url AS avatar
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY COUNT(DISTINCT l.person_id) DESC, COALESCE(u.full_name, u.email, u.id::text) ASC)::int AS rank,
|
||||
COALESCE(u.full_name, u.email, u.id::text) AS name,
|
||||
COUNT(DISTINCT l.person_id)::int AS deals_closed,
|
||||
COALESCE(SUM(o.value), 0)::float AS revenue_generated,
|
||||
u.avatar_url AS avatar
|
||||
FROM users_and_roles u
|
||||
LEFT JOIN crm_leads l ON l.assigned_user_id = u.id
|
||||
LEFT JOIN crm_opportunities o ON o.lead_id = l.lead_id
|
||||
@@ -173,16 +176,14 @@ class DataAccessGateway:
|
||||
GROUP BY u.id, u.full_name, u.email, u.avatar_url
|
||||
HAVING COUNT(DISTINCT l.person_id) > 0 OR COALESCE(SUM(o.value), 0) > 0
|
||||
ORDER BY revenue_generated DESC, name ASC
|
||||
LIMIT $2
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "inventory_absorption":
|
||||
sql = """
|
||||
SELECT
|
||||
period_label AS period,
|
||||
COALESCE(absorption_rate, 0)::float AS absorption_rate,
|
||||
COALESCE(target_rate, 0)::float AS target_rate
|
||||
SELECT period_label AS period, COALESCE(absorption_rate, 0)::float AS absorption_rate,
|
||||
COALESCE(target_rate, 0)::float AS target_rate
|
||||
FROM inventory_absorption
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY period_start ASC
|
||||
@@ -196,16 +197,10 @@ class DataAccessGateway:
|
||||
metric_name = "total_pipeline_value"
|
||||
elif "quota" in lower_prompt or "attainment" in lower_prompt:
|
||||
metric_name = "quota_attainment"
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
metric_value,
|
||||
metric_label,
|
||||
trend_value,
|
||||
comparison_label
|
||||
SELECT metric_value, metric_label, trend_value, comparison_label
|
||||
FROM oracle_aggregated_metric
|
||||
WHERE tenant_id = $1
|
||||
AND metric_name = $2
|
||||
WHERE tenant_id = $1 AND metric_name = $2
|
||||
ORDER BY observed_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
@@ -214,27 +209,19 @@ class DataAccessGateway:
|
||||
if dataset == "lead_activity_log":
|
||||
if "follow-up" in lower_prompt or "queue" in lower_prompt:
|
||||
sql = """
|
||||
SELECT
|
||||
lead_name AS name,
|
||||
assigned_broker,
|
||||
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
|
||||
COALESCE(qd_score, 0)::float AS qd_score,
|
||||
urgency,
|
||||
avatar_url AS avatar
|
||||
SELECT lead_name AS name, assigned_broker,
|
||||
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
|
||||
COALESCE(qd_score, 0)::float AS qd_score, urgency, avatar_url AS avatar
|
||||
FROM lead_activity_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY last_contact_hours_ago DESC, qd_score DESC
|
||||
LIMIT $2
|
||||
"""
|
||||
return sql, [ctx.tenant_id, row_limit]
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
activity_type AS type,
|
||||
COALESCE(activity_title, activity_summary, activity_type) AS title,
|
||||
activity_summary AS summary,
|
||||
actor_name AS actor,
|
||||
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
SELECT activity_type AS type, COALESCE(activity_title, activity_summary, activity_type) AS title,
|
||||
activity_summary AS summary, actor_name AS actor,
|
||||
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
FROM lead_activity_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY activity_at DESC
|
||||
@@ -244,27 +231,19 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "crm_contacts_overview":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(p.city, '') AS city,
|
||||
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(p.city, '') AS city,
|
||||
COALESCE(p.buyer_type, 'unclassified') AS buyer_type,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.score_type = 'engagement_score' THEN 0
|
||||
WHEN q.score_type = 'intent_score' THEN 1
|
||||
WHEN q.score_type = 'urgency_score' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.computed_at DESC
|
||||
ORDER BY CASE WHEN q.score_type = 'engagement_score' THEN 0 WHEN q.score_type = 'intent_score' THEN 1 WHEN q.score_type = 'urgency_score' THEN 2 ELSE 3 END,
|
||||
q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
ORDER BY qd_score DESC, p.full_name ASC
|
||||
@@ -274,23 +253,10 @@ class DataAccessGateway:
|
||||
|
||||
if dataset == "crm_opportunity_pipeline":
|
||||
sql = """
|
||||
SELECT
|
||||
o.stage::text AS stage,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(o.value), 0)::float AS value,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', o.opportunity_id,
|
||||
'name', p.full_name,
|
||||
'company', COALESCE(a.account_name, ''),
|
||||
'value', COALESCE(o.value, 0),
|
||||
'nextAction', COALESCE(o.next_action, '')
|
||||
)
|
||||
ORDER BY o.value DESC NULLS LAST
|
||||
) FILTER (WHERE o.opportunity_id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
SELECT o.stage::text AS stage, COUNT(*)::int AS count, COALESCE(SUM(o.value), 0)::float AS value,
|
||||
COALESCE(json_agg(json_build_object('id', o.opportunity_id, 'name', p.full_name, 'company', COALESCE(a.account_name, ''),
|
||||
'value', COALESCE(o.value, 0), 'nextAction', COALESCE(o.next_action, ''))
|
||||
ORDER BY o.value DESC NULLS LAST) FILTER (WHERE o.opportunity_id IS NOT NULL), '[]'::json) AS leads
|
||||
FROM crm_opportunities o
|
||||
JOIN crm_leads l ON l.lead_id = o.lead_id
|
||||
JOIN crm_people p ON p.person_id = l.person_id
|
||||
@@ -301,95 +267,213 @@ class DataAccessGateway:
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_property_interest_rollup":
|
||||
if dataset == "oracle_property_interest_rollup":
|
||||
sql = """
|
||||
SELECT
|
||||
project_name AS category,
|
||||
COUNT(*)::int AS value,
|
||||
ROUND(AVG(COALESCE((budget_min + budget_max) / 2.0, budget_max, budget_min, 0)), 2)::float AS average_budget
|
||||
FROM crm_property_interests
|
||||
GROUP BY project_name
|
||||
ORDER BY value DESC, project_name ASC
|
||||
SELECT COALESCE(pi.project_name, ip.project_name, 'Unknown Project') AS category,
|
||||
COUNT(*)::int AS value,
|
||||
ROUND(AVG(COALESCE((pi.budget_min + pi.budget_max) / 2.0, pi.budget_max, pi.budget_min, 0)), 2)::float AS average_budget,
|
||||
MAX(pi.created_at) AS latest_interest_at
|
||||
FROM crm_property_interests pi
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
|
||||
GROUP BY COALESCE(pi.project_name, ip.project_name, 'Unknown Project')
|
||||
ORDER BY value DESC, category ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_last_interacted_clients":
|
||||
if dataset == "oracle_last_contacted_clients":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(MAX(i.happened_at), p.updated_at, p.created_at) AS last_interaction_at,
|
||||
COUNT(i.interaction_id)::int AS interaction_count,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
WITH message_contacts AS (
|
||||
SELECT i.person_id, MAX(m.delivered_at) AS contacted_at
|
||||
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
||||
GROUP BY i.person_id
|
||||
), email_contacts AS (
|
||||
SELECT i.person_id, MAX(e.sent_at) AS contacted_at
|
||||
FROM intel_emails e JOIN intel_interactions i ON i.interaction_id = e.interaction_id
|
||||
GROUP BY i.person_id
|
||||
), call_contacts AS (
|
||||
SELECT i.person_id, MAX(i.happened_at) AS contacted_at
|
||||
FROM intel_calls c JOIN intel_interactions i ON i.interaction_id = c.interaction_id
|
||||
GROUP BY i.person_id
|
||||
), visit_contacts AS (
|
||||
SELECT person_id, MAX(visited_at) AS contacted_at FROM intel_visits GROUP BY person_id
|
||||
), thread_contacts AS (
|
||||
SELECT person_id, MAX(last_message_at) AS contacted_at FROM intel_whatsapp_threads GROUP BY person_id
|
||||
), interaction_contacts AS (
|
||||
SELECT person_id, MAX(happened_at) AS contacted_at FROM intel_interactions GROUP BY person_id
|
||||
), next_reminders AS (
|
||||
SELECT DISTINCT ON (person_id) person_id, title AS next_action, due_at AS next_action_at
|
||||
FROM intel_reminders
|
||||
WHERE status IN ('pending', 'open', 'scheduled')
|
||||
ORDER BY person_id, due_at ASC NULLS LAST
|
||||
), contact_rollup AS (
|
||||
SELECT p.person_id,
|
||||
GREATEST(
|
||||
COALESCE(mc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(ec.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(cc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(vc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(tc.contacted_at, '-infinity'::timestamptz),
|
||||
COALESCE(ic.contacted_at, '-infinity'::timestamptz)
|
||||
) AS last_contacted_at,
|
||||
mc.contacted_at AS last_message_at, ec.contacted_at AS last_email_at,
|
||||
cc.contacted_at AS last_call_at, vc.contacted_at AS last_visit_at,
|
||||
tc.contacted_at AS last_whatsapp_at, ic.contacted_at AS last_interaction_at
|
||||
FROM crm_people p
|
||||
LEFT JOIN message_contacts mc ON mc.person_id = p.person_id
|
||||
LEFT JOIN email_contacts ec ON ec.person_id = p.person_id
|
||||
LEFT JOIN call_contacts cc ON cc.person_id = p.person_id
|
||||
LEFT JOIN visit_contacts vc ON vc.person_id = p.person_id
|
||||
LEFT JOIN thread_contacts tc ON tc.person_id = p.person_id
|
||||
LEFT JOIN interaction_contacts ic ON ic.person_id = p.person_id
|
||||
)
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
|
||||
NULLIF(cr.last_contacted_at, '-infinity'::timestamptz) AS last_contacted_at,
|
||||
CASE
|
||||
WHEN cr.last_contacted_at = cr.last_call_at THEN 'phone'
|
||||
WHEN cr.last_contacted_at = cr.last_email_at THEN 'email'
|
||||
WHEN cr.last_contacted_at = cr.last_visit_at THEN 'site_visit'
|
||||
WHEN cr.last_contacted_at = cr.last_whatsapp_at THEN 'whatsapp'
|
||||
WHEN cr.last_contacted_at = cr.last_message_at THEN 'message'
|
||||
WHEN cr.last_contacted_at = cr.last_interaction_at THEN 'interaction'
|
||||
ELSE 'unknown'
|
||||
END AS last_contact_channel,
|
||||
COALESCE(li.summary, nr.next_action, '') AS last_contact_summary,
|
||||
COUNT(DISTINCT i.interaction_id)::int AS interaction_count,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score,
|
||||
COALESCE(nr.next_action, '') AS next_action,
|
||||
nr.next_action_at
|
||||
FROM crm_people p
|
||||
JOIN contact_rollup cr ON cr.person_id = p.person_id
|
||||
LEFT JOIN intel_interactions i ON i.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
SELECT summary
|
||||
FROM intel_interactions li
|
||||
WHERE li.person_id = p.person_id
|
||||
ORDER BY li.happened_at DESC
|
||||
LIMIT 1
|
||||
) li ON TRUE
|
||||
LEFT JOIN next_reminders nr ON nr.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.score_type = 'engagement_score' THEN 0
|
||||
WHEN q.score_type = 'intent_score' THEN 1
|
||||
WHEN q.score_type = 'urgency_score' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.computed_at DESC
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, p.updated_at, p.created_at, q.current_value
|
||||
ORDER BY last_interaction_at DESC NULLS LAST, interaction_count DESC, p.full_name ASC
|
||||
WHERE cr.last_contacted_at <> '-infinity'::timestamptz
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, cr.last_contacted_at,
|
||||
cr.last_message_at, cr.last_email_at, cr.last_call_at, cr.last_visit_at,
|
||||
cr.last_whatsapp_at, cr.last_interaction_at, li.summary, nr.next_action,
|
||||
nr.next_action_at, q.current_value
|
||||
ORDER BY last_contacted_at DESC NULLS LAST, interaction_count DESC, p.full_name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_top_interested_clients":
|
||||
if dataset == "oracle_top_interested_clients":
|
||||
sql = """
|
||||
SELECT
|
||||
p.person_id::text AS id,
|
||||
p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email,
|
||||
COALESCE(p.primary_phone, '') AS phone,
|
||||
COUNT(pi.interest_id)::int AS interest_count,
|
||||
STRING_AGG(DISTINCT pi.project_name, ', ' ORDER BY pi.project_name) AS projects,
|
||||
COALESCE(MAX(pi.created_at), p.updated_at, p.created_at) AS last_interest_at,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score
|
||||
WITH interest_mentions AS (
|
||||
SELECT i.person_id, COUNT(*)::int AS mention_count, MAX(COALESCE(m.delivered_at, i.happened_at)) AS last_mention_at
|
||||
FROM intel_interactions i
|
||||
LEFT JOIN intel_messages m ON m.interaction_id = i.interaction_id
|
||||
WHERE LOWER(COALESCE(i.summary, '') || ' ' || COALESCE(m.message_text, '')) ~
|
||||
'(interested|interest|shortlist|visit|book|budget|configuration|bhk|project|property)'
|
||||
GROUP BY i.person_id
|
||||
)
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
|
||||
COUNT(DISTINCT pi.interest_id)::int AS explicit_interest_count,
|
||||
COALESCE(MAX(im.mention_count), 0)::int AS inferred_interest_count,
|
||||
(COUNT(DISTINCT pi.interest_id) + COALESCE(MAX(im.mention_count), 0))::int AS interest_count,
|
||||
STRING_AGG(DISTINCT COALESCE(pi.project_name, ip.project_name), ', ' ORDER BY COALESCE(pi.project_name, ip.project_name)) AS projects,
|
||||
GREATEST(COALESCE(MAX(pi.created_at), '-infinity'::timestamptz),
|
||||
COALESCE(MAX(im.last_mention_at), '-infinity'::timestamptz),
|
||||
COALESCE(p.updated_at, p.created_at)) AS last_interest_at,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score,
|
||||
COALESCE(MAX(pi.notes), '') AS latest_interest_note
|
||||
FROM crm_people p
|
||||
INNER JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
|
||||
LEFT JOIN interest_mentions im ON im.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.score_type = 'engagement_score' THEN 0
|
||||
WHEN q.score_type = 'intent_score' THEN 1
|
||||
WHEN q.score_type = 'urgency_score' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.computed_at DESC
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, p.updated_at, p.created_at, q.current_value
|
||||
HAVING COUNT(DISTINCT pi.interest_id) > 0 OR COALESCE(MAX(im.mention_count), 0) > 0
|
||||
ORDER BY interest_count DESC, qd_score DESC, last_interest_at DESC NULLS LAST, p.full_name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "crm_interaction_timeline":
|
||||
if dataset == "oracle_client_interaction_timeline":
|
||||
sql = """
|
||||
SELECT
|
||||
i.interaction_type AS type,
|
||||
COALESCE(i.summary, i.interaction_type) AS title,
|
||||
CONCAT(p.full_name, ' · ', i.channel::text) AS summary,
|
||||
p.full_name AS actor,
|
||||
TO_CHAR(i.happened_at, 'YYYY-MM-DD HH24:MI') AS date
|
||||
FROM intel_interactions i
|
||||
JOIN crm_people p ON p.person_id = i.person_id
|
||||
ORDER BY i.happened_at DESC
|
||||
WITH timeline AS (
|
||||
SELECT i.person_id, i.channel::text AS type, COALESCE(i.interaction_type, i.channel::text) AS title,
|
||||
COALESCE(i.summary, '') AS detail, i.happened_at AS event_at, 'interaction' AS source_type
|
||||
FROM intel_interactions i
|
||||
UNION ALL
|
||||
SELECT i.person_id, 'message', COALESCE(m.sender_role, 'message'), m.message_text, m.delivered_at, 'message'
|
||||
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
||||
UNION ALL
|
||||
SELECT i.person_id, 'call', c.call_direction::text, COALESCE(t.full_text, c.call_outcome, 'Call record'), i.happened_at, 'call'
|
||||
FROM intel_calls c
|
||||
JOIN intel_interactions i ON i.interaction_id = c.interaction_id
|
||||
LEFT JOIN intel_transcripts t ON t.call_id = c.call_id OR t.interaction_id = i.interaction_id
|
||||
UNION ALL
|
||||
SELECT i.person_id, 'email', COALESCE(e.subject, 'Email'), COALESCE(e.body_text, ''), e.sent_at, 'email'
|
||||
FROM intel_emails e JOIN intel_interactions i ON i.interaction_id = e.interaction_id
|
||||
UNION ALL
|
||||
SELECT v.person_id, 'site_visit', COALESCE(v.project_name, 'Site visit'), COALESCE(v.visit_notes, ''), v.visited_at, 'visit'
|
||||
FROM intel_visits v
|
||||
UNION ALL
|
||||
SELECT r.person_id, 'reminder', r.title, COALESCE(r.notes, r.status), COALESCE(r.due_at, r.created_at), 'reminder'
|
||||
FROM intel_reminders r
|
||||
UNION ALL
|
||||
SELECT q.person_id, 'qd_score', q.score_type, COALESCE(q.reasoning, q.current_value::text), q.computed_at, 'qd_score'
|
||||
FROM intel_qd_scores q
|
||||
UNION ALL
|
||||
SELECT qt.person_id, 'qd_timeseries', COALESCE(qt.signal_source, qt.score_type), qt.value::text, qt.timestamp, 'qd_timeseries'
|
||||
FROM intel_qd_timeseries qt
|
||||
)
|
||||
SELECT t.type, t.title, CONCAT(p.full_name, ' - ', t.detail) AS summary,
|
||||
p.full_name AS actor, TO_CHAR(t.event_at, 'YYYY-MM-DD HH24:MI') AS date,
|
||||
t.source_type, t.event_at
|
||||
FROM timeline t
|
||||
JOIN crm_people p ON p.person_id = t.person_id
|
||||
ORDER BY t.event_at DESC NULLS LAST
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
if dataset == "oracle_client_360_summary":
|
||||
sql = """
|
||||
SELECT p.person_id::text AS id, p.full_name AS name,
|
||||
COALESCE(p.primary_email, '') AS email, COALESCE(p.primary_phone, '') AS phone,
|
||||
COALESCE(l.status::text, 'unknown') AS lead_status,
|
||||
COALESCE(l.budget_band, '') AS budget_band,
|
||||
COALESCE(l.urgency, '') AS urgency,
|
||||
COALESCE(q.current_value, 0)::float AS qd_score,
|
||||
COUNT(DISTINCT pi.interest_id)::int AS interest_count,
|
||||
COUNT(DISTINCT i.interaction_id)::int AS interaction_count,
|
||||
MAX(i.happened_at) AS last_interaction_at,
|
||||
STRING_AGG(DISTINCT COALESCE(pi.project_name, ip.project_name), ', ' ORDER BY COALESCE(pi.project_name, ip.project_name)) AS projects
|
||||
FROM crm_people p
|
||||
LEFT JOIN crm_leads l ON l.person_id = p.person_id
|
||||
LEFT JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
||||
LEFT JOIN inventory_projects ip ON ip.project_id = pi.project_id
|
||||
LEFT JOIN intel_interactions i ON i.person_id = p.person_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
GROUP BY p.person_id, p.full_name, p.primary_email, p.primary_phone, l.status, l.budget_band, l.urgency, q.current_value
|
||||
ORDER BY qd_score DESC, interaction_count DESC, interest_count DESC, name ASC
|
||||
LIMIT $1
|
||||
"""
|
||||
return sql, [row_limit]
|
||||
|
||||
Reference in New Issue
Block a user