Prima Get an API key

Module kit / Analytics and tracking

Lead Behavioral Insights and Predictions

analytics-tracking/lead-behavioral-insights-and-predictions

Kind backend
Domain analytics-tracking
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping
Frontends analytics-tracking/ui/tracking-dashboard
Reference implementation
  • alchemy_labs/backend/services/tracking_insights_service.py
  • alchemy_labs/backend/api/routers/tracking_router.py
  • alchemy_labs/backend/models/tracking_model.py
  • alchemy_labs/backend/models/crm_model.py
  • alchemy_labs/backend/tests/test_tracking_scope.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

When a salesperson or admin opens a single identified Lead in FormLeads, get_lead_insights turns that Lead's tracking history (its linked TrackingContact's sessions and events, plus every ClientActivityLog row already on the Lead) into six read-only views: an engagement summary, a behavior-pattern breakdown, a set of intent signals, a journey summary, a short list of heuristic predictions, and a short list of heuristic recommendations, plus the complete raw timeline underneath them and a same-person "linked contacts" view (tracking_insights_service.py 509 to 543). The invariant that keeps this trustworthy is narrower than it sounds: every number this recipe computes is derived fresh from TrackingContact/TrackingEvent/ClientActivityLog at request time, with one deliberate exception - engagement.score is lead.lead_score or 0 (639), a straight read of the column lead_scoring_service.py owns and writes (its own SCORING_RULES table, that file lines 34 onward); this recipe never assigns to lead_score and never re-derives it (grep -c "lead_score =" services/tracking_insights_service.py prints 0), so a rebuild that quietly starts computing its own score has broken the one boundary this recipe exists to respect.

When to use

Read it before touching get_lead_insights, _get_linked_contacts, _analyze_lead_engagement, _analyze_behavior_patterns, _detect_intent_signals, _build_journey_summary, _generate_predictions, _generate_recommendations, _build_complete_timeline, _calculate_engagement_level, _format_duration, _format_event_title, or _safe_parse_metadata; and when someone asks "why does this lead show high intent", "what's their best contact time", "are they ready to buy", "why is the conversion probability different from the intent score", or "why did the linked-identities banner show up on this lead". Trigger phrases: "lead insights", "behavioral insights", "intent signals", "journey summary", "complete timeline", "ready to buy", "engagement level", "best contact time", "linked_contacts".

Not this recipe: the dashboard-level AGGREGATE insights (get_dashboard_insights and its private helpers _get_summary_stats, _classify_channel, _get_traffic_sources, _get_top_pages, _get_engagement_distribution, _get_conversion_funnel, _get_activity_heatmap, _get_device_breakdown, _get_trends, _generate_dashboard_insights_from_data, same file, lines 57 to 503) and its route GET /insights/dashboard (2890 to 2919) - a separate recipe (analytics-tracking/tracking-insights-dashboard), across all leads/sessions on a site, not one identified contact; lead_scoring_service.py's SCORING_RULES and the dynamic point-based lead_score itself - the forms-leads/CRM owner of the one number this recipe only reads (639), out of scope here entirely; GET /lead/{email}/timeline (2076) and GET /lead/{email}/sessions (2974) - sibling per-lead routes FormLeads.tsx calls in the same Promise.all as this one (285 to 292), each a separate recipe; identify_visitor / fingerprint session-merging (analytics-tracking/visitor-identification-and-session-merging) - this recipe consumes what that one produces (TrackingContact, primary_contact_id links) but never writes any of it; ad-platform event forwarding (Meta/Google/LinkedIn CAPI, GA4) elsewhere in the same router file - unrelated.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), Postgres 15 in prod (docker-compose.prod.yml 9) / 14 in the test harness (docker-compose.test.yml 6), psycopg2-binary==2.9.10. Unlike the neighboring POST /identify route in the same file, this one route has no Pydantic model on either side: email is a plain str path parameter (2924), normalized with .lower().strip() in the handler (2946), and the response is a raw dict built with **insights spread (2966 to 2971), not a response_model. Foundations assumed: S4 (fastapi-project-layout) for the app and the router mount; S5 (auth-dependency-pattern) for get_current_user, required on this route (unlike the public /identify beacon in the same file); S6 (workspace-scoping) for the workspace_id-derived tenancy the Lead lookup and the TrackingContact lookup both rely on.

Env/config: none specific to this recipe - no env var gates get_lead_insights or any of its private helpers (contrast /identify's site registry, or the ad-platform forwarding elsewhere in this router). Fail behaviour: neither get_lead_insights nor any of its twelve private helpers catches its own exceptions, and the route itself has no try/except either (contrast the neighboring /identify route's blanket try/except turning everything into a 500 with a message) - an exception anywhere in the six-view assembly propagates as FastAPI's default unhandled-exception 500, not a documented shape (grep -c "except" services/tracking_insights_service.py finds exactly 2 matches in the whole file, both inside the per-lead half, both in metadata parsing, see Gotchas).

Data model

This recipe reads three tables and writes none. tracking_contacts and tracking_events are fully specified in the sibling recipe analytics-tracking/visitor-identification-and-session-merging (Data model section); only what is new here:

leads (                                    -- backend/models/crm_model.py, Lead class at line 70
  id                uuid PRIMARY KEY,
  workspace_id      uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  email             varchar,
  lead_score        integer DEFAULT 0,     -- OWNED by lead_scoring_service.py; this recipe reads it at exactly one line (639), never writes it
  created_at        timestamptz
  -- full column list is lead_scoring_service.py's and the CRM recipes' concern, not repeated here
);

client_activity_logs (                     -- crm_model.py 183 to 198
  id                uuid PRIMARY KEY,
  lead_id           uuid REFERENCES leads(id),
  project_id        uuid REFERENCES client_projects(id),
  activity_type     enum(...),             -- ClientActivityType, crm_model.py 52 to 66
  activity_title    varchar NOT NULL,
  activity_description text,
  meta_data         text,                  -- DB column named "metadata"; JSON stored AS A STRING, not JSONB
  created_by_id     uuid REFERENCES users(id),
  created_at        timestamptz
);

What is NOT stored anywhere in this recipe: nothing here writes a row - every field in the response (engagement, behavior, intent_signals, journey, predictions, recommendations, complete_timeline, linked_contacts) is computed in memory per request and returned, never persisted back onto tracking_contacts.engagement_score or tracking_contacts.engagement_level (both columns exist, tracking_model.py 169 to 170, but get_lead_insights never assigns to either); intent_signals.intent_score (691 to 773) and predictions.conversion_probability (861 to 864) are the same heuristic surfaced under two keys (see Invariants), and neither ever touches leads.lead_score.

API surface

RouteAuthTenancyParamsCodesNotes
GET /api/scribe/insights/lead/{email} (tracking_router.py 2922 to 2971; router = APIRouter(prefix="/api/scribe", tags=["scribe"]) at 260)get_current_user (JWT bearer / httpOnly cookie / X-API-Key, in that priority, api/dependencies.py 187 to 202)Lead lookup itself is workspace-scoped for non-admins BEFORE the service ever runs (2951 to 2958): get_user_site_ids(db, current_user) or [NO_TRACKING] → _workspace_ids_for_site → Lead.workspace_id.in_(allowed_workspace_ids or []), the same NO_TRACKING sentinel pattern ("__no_tracking_configured__", 110) the rest of this router usespath: email: str, lower-stripped at 2946200 {success, email, lead_id, engagement, behavior, intent_signals, journey, predictions, recommendations, complete_timeline, linked_contacts}; 404 "Lead not found: {email}" when the scoped query misses; 401 from get_current_user on a missing/invalid token (dependencies.py 195 to 229); 403 if the account is disabled (236 to 239)Real caller: frontend/src/pages/FormLeads.tsx fetchSubmissions (270 to 308), called in one Promise.all alongside .../lead/{email}/timeline and .../lead/{email}/sessions (two neighbouring, separate-recipe routes), each individually .catch()-swallowed to {data: null} (285 to 292) so one failing call never blocks the other two

One Python entrypoint this route calls (not a route, but the actual surface a rebuild reimplements):

FunctionOwns its own transactionsNever raisesReturns
get_lead_insights(lead: Lead) -> Dict[str, Any] (509 to 543)no (read-only; runs its own three queries plus the linked-contacts fan-out, 514 to 532, all inside the caller's session)no (no try/except anywhere in this function or the twelve helpers it calls){engagement, behavior, intent_signals, journey, predictions, recommendations, complete_timeline, linked_contacts}

Application logic

get_lead_insights (509 to 543), in call order:

  1. Looks up the one TrackingContact matching (email, workspace_id) off the Lead itself (514 to 517) - this second lookup trusts lead.workspace_id, already proven to belong to the caller by the route's own scoped query one step earlier.
  2. Loads every ClientActivityLog for lead.id, newest first (520 to 522).
  3. If a contact was found, loads every TrackingEvent for contact.id, newest first (526 to 529); a Lead with no matching TrackingContact at this commit gets events=[] and every downstream helper runs on activities alone, never raising for the missing contact.
  4. _get_linked_contacts(contact) if contact else [] (532) - a same-person-different-email view, walking primary_contact_id in either direction (545 to 589); this reimplements, rather than calls, the similarly-named helper in tracking_service.py (802 to 846, the session-merging recipe's scope) - see Gotchas.
  5. Assembles the six views plus complete_timeline and linked_contacts into one dict literal (534 to 543); there is no ordering dependency between the six view-builders - each is called independently with the same (lead, contact, activities, events) tuple.

Per view, in the order the return dict lists them:

_analyze_lead_engagement (591 to 651): session_count is contact.total_sessions if contact else 1 (600); total_events = len(events) + len(activities) (603); pages_viewed is a set unioning event.page_path across TrackingEvent rows (606 to 609) with, from ClientActivityLog rows of type page_viewed, a page_path parsed out of meta_data via a LOCAL json.loads wrapped in a bare except Exception: pass (610 to 618) - a second, less defensive parser than _safe_parse_metadata, see Gotchas; total_time_seconds sums event_data['time_on_page_seconds'] off every heartbeat event (621 to 624); days_known = (utcnow() - first_seen.replace(tzinfo=None)).days if first_seen else 0 (633); score is the one field this recipe does not compute, lead.lead_score or 0 (639); level comes from _calculate_engagement_level(contact) (636, 964 to 981) - a three-tier sessions/events/pages threshold table (ENGAGEMENT_THRESHOLDS, 30 to 34), 'low' whenever there is no contact at all (966 to 967).

_analyze_behavior_patterns (653 to 689): tallies event_type across TrackingEvent and activity_type.value across ClientActivityLog into one defaultdict (659 to 663); most_common_action is the single highest-count key (683); most_active_day/most_active_hour come ONLY from TrackingEvent.created_at (672 to 675) - ClientActivityLog rows never contribute a day/hour, only events, not activities, is walked there; best_contact_time is a plain string, "{day} around {hour}:00", or None if either component is missing (688).

_detect_intent_signals (691 to 773): a hand-scored heuristic, not a model. +10 per event whose page_path contains any of HIGH_INTENT_PAGES (36 to 39, 700 to 707); +15 per event whose event_type is in HIGH_INTENT_ACTIONS (41 to 44, 716 to 721); +20 once, if more than 2 distinct session_ids appear across events (730 to 738); +25 once, if any event_type contains the substring 'form' (740 to 748); +10 once, if any event_type contains the substring 'video' (750 to 758); the returned intent_score is capped at 100 (min(intent_score, 100), 770), but intent_level (thresholds at 761 to 766) and ready_to_buy (intent_score >= 60, 772) are both evaluated against the UNCAPPED running total from earlier in the same function - the cap applies only to the field returned under that name, not to any threshold decision.

_build_journey_summary (775 to 821): up to three fixed stages, in fixed code order (never re-sorted by date once assembled) - "First Visit" if contact.first_seen is set, sourced from contact.first_touch_attribution.get('utm_source', 'direct') or 'direct' if that JSONB is unset (786 to 791); "Form Submitted", the EARLIEST ClientActivityLog of type form_submitted by created_at (793 to 801); "Lead Created", always present, lead.created_at (803 to 807). time_to_conversion_seconds is (lead.created_at - contact.first_seen), both tz-stripped (811 to 813), None if there is no contact.

_generate_predictions (823 to 864): re-runs _detect_intent_signals internally (834) - a SECOND, independent call from the one already made for the intent_signals view at 537 - and appends 'High likelihood to convert' only if ready_to_buy; appends 'At risk of going cold' if contact.last_seen is more than 14 days old (843 to 850); appends 'Price-sensitive buyer' if any event page_path contains the substring 'pricing' (852 to 859, a plain substring match, not HIGH_INTENT_PAGES); conversion_probability is that second call's already-capped intent_score, wrapped in a redundant second min(..., 100) (863) - not an independently computed prediction, the same number the intent_signals view already returned under a different key.

_generate_recommendations (866 to 913): re-runs BOTH _analyze_lead_engagement (877) and _detect_intent_signals (895) a second time each, purely to read one field off each (.level, .ready_to_buy) - across one get_lead_insights call this makes three total calls to _detect_intent_signals (537, 834, 895) and two to _analyze_lead_engagement (535, 877); recommends 'Schedule a call' if engagement level is 'high' (878 to 883); 'Contact on {best_contact_time}' if _analyze_behavior_patterns (886) found one (887 to 892); 'Send personalized offer' if ready_to_buy (896 to 901); 'Send re-engagement email' if last_seen is more than 7 days old (904 to 911) - a lead can be flagged both "at risk of going cold" (the 14-day threshold in _generate_predictions) and already due for a re-engagement email (the independent 7-day literal here); the two thresholds are separate hard-coded numbers, not one shared constant.

_build_complete_timeline (915 to 958): merges every ClientActivityLog and every TrackingEvent into one list tagged source: 'crm' or source: 'tracking', sorted by the timestamp STRING descending with '' as the fallback for a null timestamp (956) - a string sort, not a parsed-datetime comparison; this holds correctly only because ISO 8601 timestamps happen to sort the same way as strings and as datetimes. Activity rows go through _safe_parse_metadata (935, 1018 to 1028: returns {} on no data, the value as-is if already a dict, json.loads() otherwise, {'raw': meta_data} if that json.loads() raises) - the ONE place in this file that uses the defensive parser, unlike _analyze_lead_engagement's own inline parse of the same column (610 to 618).

Invariants

  • engagement.score is always lead.lead_score verbatim, never recomputed: read at exactly one line (639), assigned nowhere in this file. The actual scoring rules live in lead_scoring_service.py (SCORING_RULES, that file's lines 34 onward), a separate recipe/owner. Not exercised by test_tracking_scope.py (this recipe's given test file tests site-scoping only); a code-reading fact, not a proven one.
  • A Lead with no TrackingContact still returns a full six-view response, never a 4xx: every helper (591 to 981) takes contact: Optional[TrackingContact] and has an explicit contact-is-None branch (e.g. 600, 636, 786, 811, 820) rather than a null-pointer path. Not exercised by the given test file.
  • intent_signals.intent_score and predictions.conversion_probability are the same underlying heuristic surfaced under two keys, computed by two independent calls to _detect_intent_signals over the identical (activities, events) input (537 and 834) - a rebuild that memoizes one call and reuses it for the other changes nothing observable in one request.
  • The Lead lookup's workspace scoping happens exactly once, in the router, before TrackingInsightsService ever runs (2951 to 2958); get_lead_insights itself performs no workspace check of its own and trusts lead.workspace_id completely (515 to 516, the route's own comment at 2948 to 2950 names the prior hole this closed: "a Lead with no TrackingContact was allowed through"). A rebuild that calls get_lead_insights on an unscoped Lead object inherits no tenancy protection from this function.
  • No caching layer around this route: unlike GET /insights/dashboard, which wraps its computation in _scribe_cached (2916 to 2919), GET /insights/lead/{email} calls TrackingInsightsService(db).get_lead_insights(lead) directly (2963 to 2964) with no cache read or write - every request recomputes all six views from four fresh queries.

Frontend

Real caller, by path: frontend/src/pages/FormLeads.tsx - fetchSubmissions (270 to 308) calls GET /api/scribe/insights/lead/{email} in a Promise.all alongside the two sibling per-lead routes (285 to 292), stores the result in insights: Record<string, LeadInsights> keyed by leadId (225, 299 to 301), and renderInsights (494 onward) draws it as a card grid (Engagement/Behavior/Intent cards, plus a "linked identities" banner when linked_contacts is non-empty, 501 to 536). LeadInsights (82 to 150) is FormLeads.tsx's own hand-typed mirror of this route's response shape - not imported from a shared schema, so a field renamed in tracking_insights_service.py silently stops matching this interface instead of failing a type check.

By this recipe's required frontend_modules id, analytics-tracking/ui/tracking-dashboard: does not exist yet as a written recipe in this repo at this commit (the same gap the sibling visitor-identification-and-session-merging recipe records; the folder modules/analytics-tracking/ui/tracking-dashboard/ exists, in progress, but carries no .md recipe as of this writing). The real page rendering THIS route's data is NOT the tracking dashboard at all - it is FormLeads.tsx, which lives in the forms-leads domain, not analytics-tracking; grep -rn "insights/lead" frontend/src matches only FormLeads.tsx, confirming TrackingDashboard.tsx (the page the tracking-dashboard module id would document) has no caller for this route. A rebuild's tracking-dashboard module, if and when written, is the wrong place to look for this route's real UI.

Minimum admin/debug surface (no dedicated route exists for these at this commit, they are queries a human runs by hand): SELECT id, email, lead_score, workspace_id, created_at FROM leads WHERE email = :email AND workspace_id = ANY(:allowed_workspace_ids) (what the route's own scoped lookup runs, 2951 to 2959); SELECT id, total_sessions, total_events, engagement_score, engagement_level, primary_contact_id FROM tracking_contacts WHERE email = :email AND workspace_id = :ws (514 to 517, the contact half of the join); SELECT activity_type, created_at, meta_data FROM client_activity_logs WHERE lead_id = :id ORDER BY created_at DESC (520 to 522, what feeds both behavior and complete_timeline's 'crm' half).

Security & tenancy

Auth dependency: get_current_user (JWT bearer / httpOnly cookie / X-API-Key, in that priority, api/dependencies.py 187 to 202) - unlike the neighbouring public /identify beacon in the same router file (mounted surface_exempt("public", ...), app_fastapi.py 956), this route requires a signed-in caller; there is no anonymous path to it.

Workspace derivation: two scoped lookups, not one. First, the route itself (2951 to 2958): non-admins get lead_query filtered to Lead.workspace_id.in_(allowed_workspace_ids or []), where allowed_workspace_ids comes from get_user_site_ids(db, current_user) or [NO_TRACKING] resolved through _workspace_ids_for_site - the same NO_TRACKING-sentinel pattern the rest of this router uses; an unresolvable caller matches no row rather than being read as unrestricted. Second, inside the service, the TrackingContact lookup trusts lead.workspace_id already established by that first query (515 to 516) rather than re-deriving it - correct only because the Lead was already proven to belong to the caller's workspace set one line earlier; a rebuild that calls get_lead_insights on an unscoped Lead object reopens the hole the route's own comment (2948 to 2950) names as previously fixed.

Fail direction: closed on the Lead lookup (an empty allowed_workspace_ids produces .in_([]), which matches nothing, never the whole table, → 404); NOT closed on exceptions - unlike /identify's blanket try/except in the same file, this route has none of its own, so any unhandled exception in the six-view assembly surfaces as a plain, undocumented 500 (see Stack assumptions).

Secrets and PII at rest: nothing new written by this recipe - it is read-only. It surfaces an already-stored email (lead.email, echoed verbatim in the 200 response, 2968) and page paths/URLs off TrackingEvent, both already covered by the session-merging recipe's own PII note; nothing in this recipe's own code adds a new place PII is stored.

Rate limits: none observed on this route specifically (grep -n "insights/lead" on tracking_router.py finds only the one route decorator, with no rate-limit dependency attached).

Attacker-controlled input: email, the one path parameter, lower-stripped (2946) and used in two filtered queries (Lead.email ==, TrackingContact.email ==) - both always combined with a workspace-derived filter, never used alone to select a row.

Tests

Given test file, run 2026-09-12 from alchemy_labs/backend with the repo's own .venv:

.venv/bin/python -m pytest tests/test_tracking_scope.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 14 passed (162 warnings, Pydantic-config and FastAPI regex-param deprecation noise unrelated to this recipe, 2.15s this run). grep -c "^def test_" tests/test_tracking_scope.py prints 12; grep -c "@pytest.mark.parametrize" tests/test_tracking_scope.py prints 1 (test_non_admin_is_never_unrestricted, three site-count cases, 67 to 72), which is what takes 12 defs to 14 collected/passed.

Stated plainly, because this is a genuine gap and not worth papering over: test_tracking_scope.py (the one test file this recipe was given) tests require_site_id_or_admin, _session_visible_to, and _k_sites (imported at 36 to 41) - the SHARED site-scoping primitives every /api/scribe/* route draws on - and nothing else. It contains zero tests of get_lead_insights or any of its twelve private helpers (_get_linked_contacts, _analyze_lead_engagement, _analyze_behavior_patterns, _detect_intent_signals, _build_journey_summary, _generate_predictions, _generate_recommendations, _build_complete_timeline, _calculate_engagement_level, _format_duration, _format_event_title, _safe_parse_metadata) - not the heuristic scoring, not the None-contact branches, not the workspace-derivation coupling described under Invariants. Every claim in Application logic and Invariants beyond "the shared site-scoping primitives are tested" is a reading of the source, not a passing assertion, and a rebuild should treat it that way.

Hermetic vs live: all twelve tests in test_tracking_scope.py run with no database at all - db is a MagicMock throughout (44 to 47); they are pure-function tests of the scoping helpers, not integration tests of this route or this service.

One further file in this repo references TrackingInsightsService/get_lead_insights by name, backend/tests/v2/test_insights_security_cleanup.py, but it is OUTSIDE this recipe's given test scope and, on inspection, does not test this recipe's logic either - it is an import/filesystem regression gate (test_retired_modules_unimportable and similar, 62 onward) proving a retired, unauthenticated "analytics twin" (services/analytics/ml_pipeline.py and neighbours) stays deleted, with /api/scribe named in its own module docstring as "the single, workspace-scoped analytics path" that replaced it (1 to 13). Noted here for completeness and as corroborating evidence for this recipe's maturity: prod, not counted toward this recipe's own test evidence.

What a rebuild adds that this branch lacks: any test of get_lead_insights itself, at any level; no Playwright/E2E spec for GET /insights/lead/{email} or for FormLeads.tsx's rendering of it.

Deploy & ops

Config keys per app: none specific to this recipe (Stack assumptions). Migrations: none - this recipe owns no table and adds no column; every table it reads (leads, tracking_contacts, tracking_events, client_activity_logs) is created and migrated by other recipes (the session-merging recipe's Deploy & ops section has the tracking_* migration chain; leads and client_activity_logs predate that chain and carry no migration-chain comments of their own at the cited lines).

Scheduler: none - everything in this recipe runs synchronously inside the one HTTP request that calls it; no background job to register or monitor.

Argus-style check: GET /api/scribe/insights/lead/{a-known-test-lead-email} with a valid session, against a workspace with at least one Lead, returns 200 with all eight top-level keys present (engagement, behavior, intent_signals, journey, predictions, recommendations, complete_timeline, linked_contacts) - absence of any one key is a code change, since get_lead_insights's return dict (534 to 543) is a fixed literal, never built conditionally.

Rollback: nothing deployable beyond the code itself - no migration to unwind, no scheduled job to deregister; rolling back the route/service to a prior commit changes only what shape the endpoint returns, never any stored data (the endpoint reads, it never writes).

Gotchas

  • 2026-09-12 (measured at 62ba4fc): four extra, avoidable recomputations happen inside one get_lead_insights request - _analyze_lead_engagement runs at 535 (for the engagement view) and again at 877 (inside _generate_recommendations, only to read .level); _detect_intent_signals runs at 537 (for the intent_signals view), again at 834 (inside _generate_predictions, to read .ready_to_buy and .intent_score), and again at 895 (inside _generate_recommendations, to read .ready_to_buy) - three calls total to one function, two to the other, all over the identical (activities, events) input in one request. Not a correctness bug, each call is deterministic and side-effect-free, but a rebuild that memoizes per request changes nothing observable while roughly halving the redundant work.
  • 2026-09-12 (measured at 62ba4fc, 610 to 618 vs 1018 to 1028): two differently-defensive JSON parsers exist over the SAME column in this one file. _safe_parse_metadata (used only by _build_complete_timeline, at 935) treats a dict-already, a json.loads() success, and a json.loads() failure as three distinct cases, falling back to {'raw': meta_data} on the last. _analyze_lead_engagement's own inline parse of meta_data (for page_viewed activities, 610 to 618) is a bare try: ... except Exception: pass - a malformed page_viewed metadata string is silently dropped from pages_viewed there, but would surface as {'raw': ...} if the same row reached _build_complete_timeline instead. A rebuild that unifies these into one helper changes observable behavior on any already-malformed row.
  • 2026-09-12 (measured at 62ba4fc, 770 to 772): intent_score is capped at 100 only in the field returned under that name (min(intent_score, 100), 770); intent_level (thresholds at 761 to 766) and ready_to_buy (>= 60, 772) are both evaluated against the UNCAPPED running total computed earlier in the same function. Reading top to bottom, the cap looks like it applies everywhere; it applies to exactly one output field.
  • 2026-09-12 (measured at 62ba4fc): this recipe's _get_linked_contacts (545 to 589) reimplements, rather than imports, the similarly-named helper in tracking_service.py (802 to 846, the session-merging recipe's scope) - both walk primary_contact_id in either direction over TrackingContact, but they are two separate function bodies in two separate files with no shared import between them (grep -n "_get_linked_contacts" services/tracking_service.py services/tracking_insights_service.py finds one definition per file, no cross-import). At this commit their returned shapes happen to agree (email, is_primary, first_seen, total_events in both), but a rebuild that "deduplicates" by having one call the other needs to confirm that itself rather than assume it holds going forward.
  • 2026-09-12 (measured at 62ba4fc): no try/except anywhere in get_lead_insights or its twelve helpers, and none on the route itself (2922 to 2971) - unlike the neighbouring POST /identify route in the same file, which wraps its whole handler in one try/except. An exception anywhere in this six-view assembly is an un-shaped 500 (grep -c "except" services/tracking_insights_service.py finds exactly 2 matches in the whole file, both inside metadata parsing at 617 and 1027, none in the per-lead call path otherwise).

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/tracking_insights_service.py (1029 lines; only the per-lead half is this recipe's scope, line 505 onward - the dashboard-aggregate half, 26 to 503, is analytics-tracking/tracking-insights-dashboard, a separate recipe): module docstring and constants (1 to 44); get_lead_insights (509 to 543); _get_linked_contacts (545 to 589); _analyze_lead_engagement (591 to 651); _analyze_behavior_patterns (653 to 689); _detect_intent_signals (691 to 773); _build_journey_summary (775 to 821); _generate_predictions (823 to 864); _generate_recommendations (866 to 913); _build_complete_timeline (915 to 958); _calculate_engagement_level (964 to 981); _format_duration (983 to 994); _format_event_title (996 to 1016); _safe_parse_metadata (1018 to 1028).
  • alchemy_labs/backend/api/routers/tracking_router.py (3576 lines, whole file in scope for its imports/helpers, one route in scope for this recipe): router = APIRouter(prefix="/api/scribe", tags=["scribe"]) (260); NO_TRACKING (110); get_user_site_ids (131 to 174); _workspace_ids_for_site (239 to 257); GET /insights/lead/{email} → get_lead_insights (2922 to 2971, the route function shares its name with the service method it calls, 509, which it shadows via a local import inside the handler, 2942 to 2944).
  • alchemy_labs/backend/models/tracking_model.py: TrackingContact (117 to 211); TrackingEvent (214 to 260).
  • alchemy_labs/backend/models/crm_model.py: ClientActivityType (52 to 66); Lead (70 onward, lead_score at 82); ClientActivityLog (183 to 198).
  • alchemy_labs/backend/services/lead_scoring_service.py: module docstring and SCORING_RULES (1 to 60 onward) - the actual owner of leads.lead_score, cited for the boundary this recipe respects, out of this recipe's own scope.
  • alchemy_labs/backend/api/dependencies.py: get_current_user (187 to 202).
  • alchemy_labs/frontend/src/pages/FormLeads.tsx: LeadInsights interface (82 to 150); fetchSubmissions (270 to 308); renderInsights (494 onward).
  • Test as given: alchemy_labs/backend/tests/test_tracking_scope.py (136 lines, whole file in scope; see Tests for what it does and does not cover).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: zero test coverage of get_lead_insights or any of its twelve private helpers at any level; no try/except anywhere in this call path, unlike its own neighbouring /identify route; two inconsistent metadata parsers over the same column (610 to 618 vs 1018 to 1028); the same two pure functions independently recomputed three and two times respectively inside one request; _get_linked_contacts duplicated, not shared, with the session-merging recipe's function of the same name; no dedicated admin/debug route for the three queries under Frontend.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc. Expected observations are exact, as measured 2026-09-12.

  1. .venv/bin/python -m pytest tests/test_tracking_scope.py -q --no-cov -o addopts="" -p no:cacheprovider prints 14 passed (warning count/timing not load-bearing; observed 162 warnings in 2.15s this run).
  2. grep -c "^def test_" tests/test_tracking_scope.py prints 12; grep -c "@pytest.mark.parametrize" tests/test_tracking_scope.py prints 1.
  3. grep -n "^router = " api/routers/tracking_router.py prints 260:router = APIRouter(prefix="/api/scribe", tags=["scribe"]), confirming the live path for this recipe's route is /api/scribe/insights/lead/{email}.
  4. grep -n "def get_lead_insights" services/tracking_insights_service.py api/routers/tracking_router.py prints two matches (509 in the service, 2923 in the router) - the route function and the service method share a name, confirming the shadowing noted under Reference implementation.
  5. grep -n "lead.lead_score" services/tracking_insights_service.py prints exactly one match, line 639, inside _analyze_lead_engagement; grep -c "lead_score =" services/tracking_insights_service.py prints 0, confirming this recipe reads the column once and assigns to it nowhere.
  6. grep -c "except" services/tracking_insights_service.py prints 2 (the bare/broad excepts inside _analyze_lead_engagement's inline parse, 617, and _safe_parse_metadata, 1027) - confirms no other function in this file, including get_lead_insights itself, catches anything.
  7. grep -rn "insights/lead" frontend/src prints exactly one file, frontend/src/pages/FormLeads.tsx, confirming TrackingDashboard.tsx (the page the required frontend_modules id nominally documents) has no caller for this route at this commit.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.