Lead session follow-ups (same chat, same lead)

Question: "Rerun Hunt Forge and resynthesize with our current info."

One lead per chat

Each demo/API chat binds one CRM lead (contact_id + user-pasted fields). Follow-up hunts must reuse that bound record — not a legal entity name discovered during enrichment (e.g. user pasted "Hoops & Gears" but the graph found HG AutoTech LLC).

Do Don't
Reuse first enrich_lead_session lead (contact_id intact) Rebuild lead from summary_markdown / entities[]
Pass prompts: ["<user request verbatim>"] Pass refresh: true for partial retries
Let the server load prior_backfill from the chat Drop chat_session_id on follow-up

Tool

POST /api/v1/enrich/lead-session or MCP enrich_lead_session

{
  "lead": {
    "contact_id": 1737992709,
    "company": "Hoops & Gears and Clark",
    "domain": "hgautotech.com",
    "source_system": "demo_chat"
  },
  "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
  "prompts": [
    "Rerun the Hunt Forge dig-deeper pass and resynthesize the summary with the current graph."
  ]
}

refresh vs follow-up

User intent Args
"From scratch" / "ignore cache" refresh: true + same bound lead
"Rerun Hunt Forge" / "resynthesize" / "dig deeper on X" prompts: [...] only — no refresh
Gap button from UI prompts: [<button text>]

refresh: true bypasses agent warm-seed and re-runs all hunt slots from zero. It does not mean "retry one model."

Agent warm-seed (cross-chat reuse)

When you open a new chat (or a batch row chat) for a lead this agent already enriched recently, the server loads the latest persisted graph and skips vendor tools already in tool_trace — unless you pass refresh: true.

Lookup order (same agent):

Step Signal Notes
1 chat_title Matches demo_chat_session.title from a prior batch row or enrich chat
2 Exact input Canonical CRM JSON equals prior LEB input_snapshot
3 Batch input Same canonical JSON on a prior matched batch row (batch_input_exact; debug or LEB graph)
4 contact_id User-supplied CRM int, or external string (e.g. docket id) hashed for LEB lookup
5 Phone Shared US last-10
6 Business or person name + address Company or dm_name/name + zip, city+state, or street
7 Global cache Cross-agent phone / company+geo / person+geo index

Check agent_cache.hit, cache_scope (agent vs global), and cache_match (chat_title, input_exact, batch_input_exact, contact_id, company, phone, …) on the hunt response.

Disable batch-row warm-seed only: AGENT_LEAD_BATCH_CACHE_ENABLED=0. TTL: AGENT_LEAD_CACHE_TTL_HOURS (default 168h).

Canonical lead_record_id (shared graph across chats)

When LEAD_RECORD_ENABLED=1, each resolved lead gets one lead_record_id and one canonical leb_id per agent. All demo/API sessions bound to that lead read and write the same persisted graph — person hunts, slot-person, and full lead-session runs from any chat update one row.

Field Meaning
demo_chat_session.lead_record_id Session bound to business identity
lead_enrichment_backfill.leb_id Canonical graph (unique per agent_id + lead_record_id)
backfill.lead_ref.lead_record_id Stamped in every persisted backfill JSON
backfill.lead_ref.canonical_leb_id Same as persist.leb_id after write

Dup resolution order (when LEAD_RECORD_ENABLED=1): session bind → external CRM contact_idinput_fingerprint → lookup index (company+geo before chat_title for company-shaped leads) → create new record.

Person at company: person-shaped sessions may link to the company parent's lead_record_id; person-hunt writes land on the parent canonical leb_id.

Migration: python3 scripts/migrate_lead_records.py --agent-id N --commit collapses duplicate LEB clusters (entity index + CRM contact_id) and backfills session bindings.

Rollout (ordered cutover):

  1. Deploy SQL (python3 db/deploy_sql.py) — includes X076 filtered legacy index + leb_redirect.
  2. Run migration per agent: python3 scripts/migrate_lead_records.py --agent-id N --commit.
  3. Set LEAD_RECORD_ENABLED=1 in prod .env (api-worker + jobs via stack-411data.yml).
  4. Verify duplicate sessions share one leb_id (e.g. re-open follow-up chats, check reconcile).

Cross-agent cache: global warm-seed (Resolve_LeadEnrichmentCache) is unchanged — agent B can still seed from agent A's indexed graph. Merge/migration tombstone absorbed leb_ids via leb_redirect; lookup index is re-unrolled on the surviving canonical row.

Hunt Forge / Swift / Deep (dig-deeper)

Middle-pass parallel slots (UI labels Hunt Forge, Hunt Swift, Hunt Deep) map to internal dig-deeper models. They are not research_model picker values.

There is no public research_model: data411-hunt-forge arg on enrich_lead_session.

Person hunt (per named contact)

Lead-session may run whitepages_person_hunt for owners/contacts without phones, or when the user names someone in follow-up prompts. Person-hunt phones carry vendor scores in backfill.phones[].score and cite as [person hunt](https://411data.io/person-hunt) in summary_markdown — never Whitepages-branded URLs in customer-facing prose.

Hosted UI — Hunt controls

On the last hunt turn, Hunt controls lists named contacts under Person hunt (below SIC when present):

Control When Action
Contact name Person hunt not yet run for that contact Chat follow-up → full enrich/lead-session with a person-hunt prompt (same bound lead + chat_session_id)
Contact name Person hunt already run (match or no-match) Disabled — no new session
Intermediate search Any time (unless that slot is busy) Partial route slot-person (data411-hunt-person) via async job
[person hunt] link in summary No graph phone and hunt not yet run Same as clicking the contact name
[person hunt] link Hunt already complete Non-clickable citation (tooltip: “Person hunt complete”)

Person-hunt detail may live in backfill.person_hunt_sections[] before merge. When prose is orphaned, use Merge into summary (synthesize-patch) — not another hunt.

Detecting “already ran”

Authoritative signals (hosted UI and API partial routes use the same rules):

Signal Notes
backfill.person_hunt_sections[] Row for contact_id or matching target_name
tool_trace tool: whitepages_person_hunt with matching target_contact_id / target_name, or structured person name match
backfill.phones[] Phone linked to contact with person-hunt source

Match and no-match both count as “done” — do not re-run person hunt for the same contact.

Chat follow-up vs slot-person

User intent Route Notes
“Run person hunt for Jane Doe…” POST /api/v1/enrich/lead-session Pass bound lead, chat_session_id, prompts: [<verbatim user text>]no refresh
Intermediate / targeted web research for one contact POST /api/v1/enrich/lead-session/slot-person Per-contact LLM slot; optional chained person hunt
Person hunt already done Skip hunt; use Resynthesize if summary is stale

Slot-person (one contact)

POST /api/v1/enrich/lead-session/slot-person or async job route: enrich/lead-session/slot-person.

Runs catalog slot data411-hunt-person: person-focused intermediate websearch for one row in backfill.contacts, delta-merges into prior_backfill, then optionally queues whitepages_person_hunt when include_person_hunt: true (default) and person hunt has not already run for that contact.

{
  "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
  "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
  "target_contact_id": "c_abc123",
  "target_name": "Jane Doe",
  "prior_backfill": { "...": "current graph" },
  "include_person_hunt": true,
  "prompts": ["Focus on direct cell or mobile for this owner."]
}
Field Notes
target_contact_id Required — id from backfill.contacts[]
target_name Optional display override; resolved from contacts when omitted
include_person_hunt Default true; set false to run intermediate search only
refresh_summary Default false; set true for full summary rewrite after merge

Trace rows use hunt_pass: "person" and slot_action: "slot_person" on the intermediate slot; person-hunt hops append whitepages_person_hunt with target_contact_id / target_name.

Hosted UI: Intermediate search on the contact row (async job only — same as other partial hunt ops).

{
  "route": "enrich/lead-session/slot-person",
  "payload": {
    "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
    "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
    "target_contact_id": "c_abc123",
    "prior_backfill": { "...": "current graph" }
  }
}

Submit at POST /api/v1/enrich/jobs — default precharge $0.35 (bill-on-match refund when no usable rows). Sync POST /api/v1/enrich/lead-session/slot-person remains for one-shot callers.

Slot rerun (one model)

POST /api/v1/enrich/lead-session/slot-rerun or MCP rerun_hunt_slot

{
  "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
  "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
  "model_id": "data411-hunt-forge",
  "prior_backfill": { "...": "from last enrich_lead_session backfill" },
  "refresh_summary": true
}

model_id accepts public ids (data411-hunt-forge, data411-hunt-swift) or provider slugs (x-ai/grok-build-0.1). Runs one slot with refresh=true, delta-merges into prior_backfill, updates tool_trace.parallel_results for that model.

Hosted UI: Hunt controls panel on the last hunt turn → per slot row.

Prefer async jobs for slot reruns (hosted UI and headless agents):

{
  "route": "enrich/lead-session/slot-rerun",
  "payload": {
    "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
    "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
    "model_id": "data411-hunt-forge",
    "prior_backfill": { "...": "from last enrich_lead_session backfill" }
  }
}

Submit at POST /api/v1/enrich/jobs — default precharge $0.35 per slot (bill-on-match refund on no usable rows). Sync POST /api/v1/enrich/lead-session/slot-rerun remains for one-shot callers.

Batch slot rerun (pending + error)

POST /api/v1/enrich/jobs with route: enrich/lead-session/slots-rerun (no sync HTTP route).

Runs eligible parallel slots in one job: vendor hunts in parallel, trace upsert per slot, one delta synthesis at the end (not N synth passes).

{
  "route": "enrich/lead-session/slots-rerun",
  "payload": {
    "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
    "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
    "model_ids": ["data411-hunt-ground", "data411-hunt-sonar"],
    "prior_backfill": { "...": "current graph" },
    "progress_key": "job:slots:optional"
  },
  "bill_up_to_usd": 0.70
}
Field Notes
model_ids Public ids (data411-hunt-forge, …) or provider slugs. Omit to run all trace-eligible slots (see eligibility below).
bill_up_to_usd Default N × $0.35 when omitted (N = count of ids the server will run); override up to job max
progress_key Optional live board key for poll progress

Server eligibility (authoritative — derived from prior_backfill.tool_trace, not the hunt board alone):

Trace state Batch behavior
pending or error in latest parallel trace Runs
Never appeared in trace (e.g. first-pass catalog slot after dig-deeper-only hunt) Runs (first hunt for that slot)
done, cancelled, repetition, or running Skipped (other ids in the same request still run)
No runnable ids after filtering Job refused: status: refused, code: SLOTS_RERUN_REFUSED, error: no_eligible_slots

When the client sends model_ids, the server filters the list — it does not fail the whole job because one id is already done. Settled slots (including aliases like minimax/minimax-m2.7data411-crimson-span) are skipped silently.

Hunt board vs trace: First-pass catalog rows are not injected when that model already has a settled trace row in any pass (e.g. dig-deeper done). Remaining not run rows are truly never-run slots (use per-slot or batch when listed). Run pending & failed (N) counts only trace-eligible targets (huntSlotsEligibleForBatchRerun — pending/error in trace, or never-run first-pass catalog). Batch still filters against tool_trace on the server; already-done ids are skipped silently.

Excludes cancelled, repetition, and in-flight running slots. Does not auto-resynthesize the summary — use Resynthesize if prose is stale after batch completes.

Resynthesize only

POST /api/v1/enrich/lead-session/synthesize-patch or MCP resynthesize_lead_graph

{
  "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
  "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
  "prior_backfill": { "...": "current graph" },
  "refresh_summary": true,
  "prompts": ["Emphasize owner phones and legal contacts in the summary."]
}

No vendor spend — LLM resynthesis from the existing graph only. Hosted UI: Resynthesize in the hunt controls panel.

Summary history (hosted UI)

Each time a follow-up session, slot rerun, batch slot rerun, or synthesize-patch changes summary_markdown, the server archives the prior version on backfill.summary_history[] (with graph_fingerprint for phones/contacts/emails).

Partial routes return the updated backfill including summary_history; no extra API.

Investigate unmerged finding

POST /api/v1/enrich/lead-session/finding-rerun or MCP investigate_unmerged_finding

{
  "lead": { "contact_id": 1737992709, "company": "Hoops & Gears and Clark" },
  "chat_session_id": "ab62eb36cd134d70a58bc174d6b09185",
  "candidate_id": "a1b2c3d4e5f6g7h8",
  "prior_backfill": { "unmerged_candidates": [{ "id": "a1b2c3d4e5f6g7h8", "label": "…" }] }
}

candidate_id is on backfill.unmerged_candidates[].id (stable SHA1 at merge time). Hosted UI: Investigate on each row in Findings not merged.

Agent behavior checklist

  1. Read the user's latest message into prompts if the model omitted it.
  2. Copy the first enrich_lead_session lead object for every follow-up.
  3. Never set lead.company to a discovered DBA/legal name unless the user pasted a new CRM export.
  4. Explain MULTI_LEAD_REFUSED as "same chat, different lead shape" — fix by reusing bound contact_id.
  5. Do not re-run person hunt for a contact when person_hunt_sections, whitepages_person_hunt trace, or person-hunt phones already record that contact (match or no-match). Use slot-person for intermediate search only, or synthesize-patch to refresh prose.