HarborCRM CRM-Marketing Handoff, Prospecting & Import Skill
Self-contained conventions for solving HarborCRM task-group tasks against the shared
read-only API. Three task families share one dataset and one set of conventions:
- Event handoff / reconciliation — post-event CRM handoff for a completed event
(sponsor status reconciliation, badge→lead classification, opportunity totals,
finance follow-up, due dates, CRM action counts).
- Trade-show prospecting — qualify exhibitors at a trade show into a CRM-ready
lead list (platform coverage, priority tiers, opportunity sizing, CRM
create/update decisions, ranking, exclusions).
- Import cleaning — prepare a raw contact batch for CRM import (normalize,
de-duplicate, suppress, drop unusable, decide create/update, count campaign
members).
0. Environment & API access
- Base URL (the ONLY allowed data source):
<remote-env-url>
- Read-only GET endpoints only. Never POST/PUT/DELETE. Never call any judge/eval endpoint.
- Call with
curl -s <remote-env-url><endpoint> and parse JSON (pipe to
python3 -m json.tool or jq). The dataset is deterministic (seed 41001).
Public endpoints:
GET /health
GET /api/policies # authoritative rule constants
GET /api/events
GET /api/events/{event_id}
GET /api/events/{event_id}/orders # sponsor orders
GET /api/events/{event_id}/badges # badge scans
GET /api/events/{event_id}/sponsor_packages # same shape as orders
GET /api/finance/invoices?event_id={event_id} # invoices for the event
GET /api/crm/accounts
GET /api/crm/contacts
GET /api/crm/opportunities
GET /api/crm/campaign_members?event_id={event_id}
GET /api/tradeshows
GET /api/tradeshows/{show_id}/exhibitors
GET /api/tradeshows/{show_id}/meeting_interest
GET /api/import_batches
GET /api/import_batches/{batch_id}/raw_contacts
GET /api/import_batches/{batch_id}/suppression # global suppression list
1. Authoritative constants — read /api/policies first
/api/policies returns the controlled vocabularies. Always conform to these enums:
sponsor_handoff.status_enums: paid_deferred, open_invoice, proposal_only, not_sponsor
prospecting.platform_enums (and the required output order): AUV, ROV, Underwater Camera
prospecting.qualification_note: an exhibitor qualifies only if it builds or OEM-builds
target underwater platforms; an exhibitor that is merely adjacent (distributor,
service/rental, sensor-only, research, software-only) does not qualify.
Values stated explicitly in a task prompt (e.g. tier→opportunity amounts, tier score
thresholds) take precedence over the defaults in section 5; if the prompt does not state
them, use the defaults documented there.
2. Cross-cutting conventions
Value normalization
- Email: trim surrounding whitespace, lowercase. Empty string
"" when absent/blank.
- Phone: keep digits only (strip everything else). Do NOT add or strip a leading
country code —
"415-555-0188" → "4155550188", "+1 (206) 555-0150" → "12065550150".
Empty string "" when no digits present.
- Currency: integer USD. Never float.
- Dates:
YYYY-MM-DD. Due dates are computed by calendar-day addition (see 2.3).
CRM record matching
- Existing account: a lead/contact matches an existing CRM account when the normalized
email domain equals a CRM account
domain, or the company name matches a CRM account
name. Use the matched account_id.
- Existing contact: match by normalized email against a CRM contact
email; if no email
match, fall back to same account_id + same contact_name. Use the matched contact_id.
- A CRM account with
status == "disqualified" (non-null disqualified_reason) is an
existing_disqualified exclusion — leads for these accounts are not imported/qualified.
Due-date arithmetic (uses event constants from /api/events/{event_id})
lead_followup_due_date = event.end_date + event.followup_days_after_end (calendar days).
sponsor_followup_due_date = event.end_date + event.sponsor_followup_days_after_end.
- Both rendered
YYYY-MM-DD. These constants are per-event; never assume fixed offsets.
Output discipline
- Return exactly ONE JSON object conforming to the provided
answer_template.json.
- Do not add fields not in the template. Do not include prose outside the JSON.
- Enum values must exactly match the template's
allowed_values (note family-specific
spelling variants, e.g. sensor_only vs sensor_vendor_only — see section 5).
- Apply every template
ordering rule. Common sort keys summarized in section 7.
3. Family A — Event handoff / reconciliation
Inputs: /api/events/{id}, /orders (or /sponsor_packages), /badges,
/api/finance/invoices?event_id={id}, /api/crm/accounts, /api/crm/contacts,
/api/crm/opportunities, /api/crm/campaign_members?event_id={id}, /api/policies.
3.1 Sponsor status reconciliation
Sponsor orders carry order_status. Treat order statuses as:
- Active =
confirmed or proposal_sent → appears in sponsor_statuses.
- Inactive =
canceled or no_show → excluded from sponsor_statuses; their
ticket contacts become inactive_sponsor_record exclusions (section 3.2).
For each active sponsor order, aggregate that account's invoices for the event
(match on account_id + event_id). Compute:
package_amount = the sponsor order amount.
paid_amount = sum of paid_amount across the account's invoices for this event.
open_balance = package_amount − paid_amount.
- Status decision:
proposal_sent with no invoice → proposal_only; invoice_id = null;
paid_amount = 0; open_balance = 0.
- Has invoice(s) and
open_balance > 0 → open_invoice; invoice_id = the open
invoice's id (the one with unpaid balance).
- Has invoice(s) and fully paid (
open_balance == 0) → paid_deferred;
invoice_id = the paid invoice's id.
- (A sponsor may have multiple invoices, e.g. one paid-deferred + one open; the
overall status is
open_invoice whenever any balance remains.)
Revenue totals (integer USD):
paid_deferred = Σ package_amount of paid_deferred sponsors.
open_invoice = Σ package_amount of open_invoice sponsors.
proposal_only = Σ package_amount of proposal_only sponsors.
open_invoice_balance = Σ open_balance of open_invoice sponsors.
Sort sponsor_statuses by account_name ascending.
3.2 Badge → lead classification (precedence order)
Apply in this order; first match wins:
- Non-business badge —
badge_type not a business type (e.g. student, press)
→ classification excluded, reason non_business_badge, crm_action no_action/no_import.
- Sponsor attendee —
badge_type == "sponsor", or the (company_name, contact_name)
is a ticket_contacts entry of a confirmed sponsor order → classification
sponsor_attendee, excluded from leads. (Campaign-member target status
attended_sponsor if scanned/attended, else registered_sponsor.)
- Inactive sponsor record — the contact is a ticket contact of a
canceled/no_show
sponsor order → classification excluded, reason inactive_sponsor_record.
- Existing disqualified — the company's CRM account
status == "disqualified" →
classification excluded, reason existing_disqualified. (Sponsor-order reasons in
steps 2–3 take precedence over this for companies that also had a sponsor order.)
- Missing contact — the badge has neither a normalizable email nor a
normalizable phone → classification
excluded, reason missing_contact.
- Qualified non-sponsor lead — otherwise → classification
qualified_non_sponsor_lead. These are the sales-handoff leads.
Note on proposal_sent sponsors: only confirmed sponsors' ticket contacts are
sponsor_attendee. A proposal_sent sponsor's ticket contact who attended on a regular
(attendee) badge is treated as a normal lead subject to steps 4–6, while the company
still appears in sponsor_statuses as proposal_only (a finance/proposal matter, not a
lead-exclusion matter).
3.3 Qualified non-sponsor leads
- Each qualified lead's
opportunity_amount = event.lead_opportunity_amount (per-lead
constant from the event record).
lead_pipeline_total (Family-A1 schema) = (number of qualified leads) × lead_opportunity_amount.
opportunity_summary.open_opportunity_total_usd / open_opportunity_count (Family-A2
schema) = same total / count of qualified non-sponsor leads (the new open opportunities
being handed off). lead_opportunity_amount_usd = the per-lead unit.
qualified_non_sponsor_account_names = company (account) names of qualified leads,
sorted ascending.
- CRM action per qualified lead:
- Account exists (section 2.2) →
crm_account_action = update_existing; else create_account.
- Contact exists →
crm_contact_action = update_existing; else create_contact.
- Campaign member: if a campaign member already exists for (account, contact, event) →
update (or no_action if its status is already the correct target); else create
(add_campaign_member). Target status attended for non-sponsor leads.
- For badge-only leads (no CRM contact), emit
normalized_email / normalized_phone
(empty string allowed when absent).
CRM action counts (Family-A1): accounts_create, accounts_update, contacts_create,
contacts_update, campaign_members_create, campaign_members_update — counts of the
above actions across all qualified leads.
3.4 Sponsor finance follow-up
- Targets = sponsors with
open_balance > 0 (i.e. open_invoice sponsors).
sponsor_finance_accounts / unpaid_sponsor_account_names = their account names
(sorted ascending). unpaid_sponsor_total_usd / open_invoice_balance = Σ open balances.
sponsor_finance_task_count = number of such accounts.
sponsor_finance_due_date / sponsor_followup.followup_due_date =
event.end_date + event.sponsor_followup_days_after_end.
proposal_only sponsors are not finance collection targets (no invoice, no balance).
3.5 Exclusions & sorting
excluded_records (Family-A1): {company_name, contact_name, reason} where reason ∈
sponsor_attendee | existing_disqualified | inactive_sponsor_record | non_business_badge.
Sort by company_name asc, then contact_name asc.
badge_decisions (Family-A2): one row per badge with classification, crm_action,
exclusion_reason (null when qualified). Sort by badge_id asc.
exclusion_counts (Family-A2): counts for sponsor_attendee, non_business_badge,
existing_disqualified, missing_contact (integer, may be 0).
campaign_member_actions (Family-A2): {subject_key, account_name, contact_name, action, target_status}. action ∈ create|update|no_action|no_import; target_status ∈
attended_sponsor|registered_sponsor|attended|excluded. subject_key = a stable sort
key for the contact (contact_name, or account_name+contact_name). Sort by subject_key asc.
badge_only_contacts (Family-A2): {company_name, contact_name, normalized_email, normalized_phone}. Sort by company_name asc.
4. Family B — Trade-show prospecting
Inputs: /api/tradeshows/{show}/exhibitors, /api/tradeshows/{show}/meeting_interest,
/api/crm/accounts, /api/crm/contacts, /api/policies.
meeting_interest is a list keyed by company_name; match an exhibitor to its
meeting-interest entry (if any) by company_name. An exhibitor with no
meeting-interest entry has requested_demo = false and interest_score = 0.
4.1 Qualification & platform classification
From each exhibitor's description (and company context), decide whether it builds or
OEM-builds one or more target platforms. Assign the platform enums it builds:
AUV — builds autonomous/autonomous underwater vehicles, AUV scouts/pods/mapping pods.
ROV — builds (manufactures) ROVs / inspection-class / pen-cleaning / resident ROVs.
Underwater Camera — OEM-builds underwater camera modules/systems/cameras.
A description may yield multiple platforms. Output platforms in enum order:
AUV, ROV, Underwater Camera. Qualified = builds/OEM-builds ≥1 target platform.
4.2 Exclusion classification (relationship type)
Exhibitors that do not build a target platform are excluded. Classify the relationship:
- distributor — reseller, dealer, sales agent, distributor of others' platforms.
- service_provider — consulting, rental fleet, inspection services that operate
others' rented/platforms; or a software/analytics-only company with no hardware.
- sensor_vendor — makes sensors/probes only (no platform).
- research — academic / research lab.
- other / not target market — anything else non-adjacent (only when the template allows
a
not_target_market-style reason).
Map the relationship to the exclusion-reason enum the specific template permits:
- distributor →
distributor_only
- service_provider →
service_only
- sensor_vendor →
sensor_only (Family-B2) or sensor_vendor_only (Family-B1)
- research →
research_only
- other →
not_target_market (Family-B1 only)
Emit exactly the strings in the template's allowed_values. Family-B2 also requires a
relationship_type field (distributor|service_provider|sensor_vendor|research).
4.3 Priority tier (shared rule)
Default thresholds (override per task prompt if it states different numbers):
- A =
requested_demo == true AND interest_score ≥ 90.
- B =
requested_demo == true AND interest_score ≥ 80.
- C = all other qualified leads.
4.4 Opportunity sizing (Family-B2)
Default amounts (override per task prompt): A = USD 120000, B = USD 90000, C = USD 50000.
opportunity_estimate_usd per lead by tier; total_estimated_opportunity_usd = Σ.
4.5 CRM action (Family-B2)
- Qualified exhibitor with non-null
crm_account_id → crm_action = update_existing;
crm_account_id echoed.
- Qualified exhibitor with null
crm_account_id → crm_action = create_account;
crm_account_id = null.
- Excluded exhibitors →
crm_action = no_import.
existing_crm_overlap_count / existing_crm_overlap_account_ids = qualified exhibitors
that already have a CRM account; account IDs sorted ascending.
4.6 Ranking & counts
- Family-B2
ranked_leads sort: (1) requested_demo true first, (2) interest_score
descending, (3) broader platform coverage (count of platforms) descending,
(4) company_name ascending. rank is 1-based contiguous.
- Family-B1
qualified_exhibitors sort: company_name ascending (no ranking).
excluded_* lists sort by company_name ascending.
platform_coverage_counts / platform_counts: for each platform enum, the number of
qualified leads that cover it (a lead covering 2 platforms counts in both).
priority_counts: {A, B, C} counts of qualified leads.
qualified_total / qualified_lead_count = number of qualified leads.
excluded_count / excluded_near_misses_total = number of excluded exhibitors.
5. Family C — Import cleaning
Inputs: /api/import_batches (batch metadata incl. campaign_code),
/api/import_batches/{batch}/raw_contacts, /api/import_batches/{batch}/suppression
(the suppression list is global/shared across batches), /api/crm/accounts,
/api/crm/contacts, /api/policies.
5.1 Normalize every raw row
email → normalized (trim+lowercase) or "".
phone → digits-only or "".
- Keep
row_id, source_name, captured_at, company_name, contact_name from the row.
5.2 Removal decisions (apply in this order)
- Duplicate — group rows by normalized email (primary duplicate key). Within a group:
- Winner = the row with the earliest
captured_at; tie-break by lowest
row_id ascending. (Earliest capture = original record; its data is kept.)
- All other rows in the group are removed with reason
duplicate.
- Record in
duplicate_summary: per key {key, winner_row_id, removed_row_ids};
duplicate_keys sorted by key ascending; duplicate_removed_count = total losers.
- The winner's
clean_contact_id and source_row_id = the winner row_id;
captured_at = the winner's timestamp.
- Suppressed — if the row's normalized email OR normalized phone matches any
suppression-list entry → removed with reason
suppressed; action suppress.
- Missing contact — if the row has neither a normalized email nor a normalized
phone → removed with reason
missing_contact; action no_import.
A row with email but no phone (or phone but no email) is not missing-contact — it is
usable. Only the total absence of both contact channels makes a row unusable.
Duplicates are resolved before suppression/missing checks; only the surviving winner of
a duplicate group is screened against suppression/missing.
5.3 Clean contacts (survivors)
For each surviving (non-removed) row:
crm_action:
update_existing if an existing CRM account matches (email domain or company name);
set existing_account_id.
- else
create_account; existing_account_id = null.
existing_contact_id: set if a CRM contact matches (by normalized email, else same
account + same contact_name); else null.
email/phone = normalized values (or "").
source_name ∈ badge_scan|sponsor_form|partner_upload|webinar_form|exhibitor_form|manual_upload.
5.4 Totals & counts
import_action_totals = {create_account, update_existing, no_import, suppress}:
create_account = clean survivors that are new accounts.
update_existing = clean survivors matching an existing account.
no_import = removed-as-missing-contact rows.
suppress = removed-as-suppressed rows.
- (Duplicate losers are tracked in
duplicate_summary, not in import_action_totals.)
removal_summary:
unusable_removed_count = missing_contact removals.
suppressed_removed_count = suppressed removals.
removed_rows = all removed rows (duplicates + missing + suppressed), each
{row_id, reason} with reason ∈ duplicate|missing_contact|suppressed; sort by
row_id ascending.
campaign_member_import_count = number of clean survivors (each becomes a member of the
batch's campaign_code).
batch_id and campaign_code come from /api/import_batches.
clean_contacts sorted by clean_contact_id ascending.
6. Opportunity / pipeline math summary
- Event handoff: per-qualified-lead amount =
event.lead_opportunity_amount; pipeline =
count × amount. (Do not use CRM opportunity records for the lead pipeline.)
- CRM
opportunities are sponsor/opportunity records tied to accounts via event_id;
stage == "closed_won" is closed; other stages (proposal, qualification,
discovery) are open. Use these only where a template explicitly asks about existing
open opportunities.
- Prospecting: per-lead amount by priority tier (prompt-specified; defaults A=120000,
B=90000, C=50000); total = Σ.
7. Sort-key reference
| List |
Sort key |
sponsor_statuses |
account_name asc |
qualified_lead_accounts |
account_name asc |
excluded_records |
company_name asc, then contact_name asc |
badge_decisions |
badge_id asc |
badge_only_contacts |
company_name asc |
campaign_member_actions |
subject_key asc |
qualified_non_sponsor_account_names |
string asc |
unpaid_sponsor_account_names |
string asc |
qualified_exhibitors (B1) |
company_name asc |
excluded_near_misses (B1) |
company_name asc |
ranked_leads (B2) |
rank asc (1-based contiguous) |
excluded_exhibitors (B2) |
company_name asc |
existing_crm_overlap_account_ids |
account ID asc |
clean_contacts |
clean_contact_id asc |
duplicate_keys |
key asc |
removed_rows |
row_id asc |
8. Execution checklist for any task
- Read the task prompt; identify the family (event handoff / prospecting / import) and the
target
event_id / show_id / batch_id.
- Read the
answer_template.json — it defines the exact required keys, enums, and
ordering. Conform to its allowed_values (mind spelling variants).
GET /api/policies; conform sponsor-status and platform enums to it.
- Fetch the family's input endpoints (section 3/4/5).
- Apply the family's decision procedure (precedence order matters).
- Compute due dates from the event's own
followup_days_after_end /
sponsor_followup_days_after_end constants.
- Normalize all emails/phones; match CRM accounts/contacts per section 2.2.
- Apply every template sort rule (section 7); use integers for USD and counts.
- Emit exactly one JSON object matching the template — no extra fields, no prose.
9. Reasoning notes & judgment calls (apply consistently)
- Duplicate winner = earliest
captured_at, tie-break lowest row_id. The earliest
capture is the original record and tends to carry the canonical company spelling that
matches the CRM account name.
- Sponsor status precedence: any unpaid balance ⇒
open_invoice (even if a separate
invoice for the same sponsor is paid_deferred). Handle multi-invoice sponsors by
aggregating per (account, event).
- Badge classification precedence: non-business → sponsor-attendee (confirmed) →
inactive-sponsor (canceled/no_show) → existing-disqualified → missing-contact →
qualified lead. Sponsor-order reasons outrank CRM-disqualified for companies that had a
sponsor order.
- Sponsor finance follow-up = open-balance sponsors only (
open_invoice).
proposal_only and fully-paid paid_deferred sponsors are not collection targets.
- Missing-contact = neither email nor phone (symmetric: email-only and phone-only rows
are usable).
- Prospecting exclusions: the relationship classification is stable, but the emitted
enum string must match the template (
sensor_only vs sensor_vendor_only;
not_target_market only where allowed).
- When a field is genuinely ambiguous and the template does not disambiguate, choose the
reading that keeps the dataset internally consistent (e.g. totals reconcile with the
row counts; CRM action counts reconcile with the lead list).
1---2name: self-attempt-02-83description: HarborCRM CRM-Marketing Handoff, Prospecting & Import Skill4---5# HarborCRM CRM-Marketing Handoff, Prospecting & Import Skill67Self-contained conventions for solving HarborCRM task-group tasks against the shared8read-only API. Three task families share one dataset and one set of conventions:9101. **Event handoff / reconciliation** — post-event CRM handoff for a completed event11 (sponsor status reconciliation, badge→lead classification, opportunity totals,12 finance follow-up, due dates, CRM action counts).132. **Trade-show prospecting** — qualify exhibitors at a trade show into a CRM-ready14 lead list (platform coverage, priority tiers, opportunity sizing, CRM15 create/update decisions, ranking, exclusions).163. **Import cleaning** — prepare a raw contact batch for CRM import (normalize,17 de-duplicate, suppress, drop unusable, decide create/update, count campaign18 members).1920## 0. Environment & API access2122- Base URL (the ONLY allowed data source): `<remote-env-url>`23- Read-only GET endpoints only. Never POST/PUT/DELETE. Never call any judge/eval endpoint.24- Call with `curl -s <remote-env-url><endpoint>` and parse JSON (pipe to25 `python3 -m json.tool` or `jq`). The dataset is deterministic (seed 41001).2627Public endpoints:2829```30GET /health31GET /api/policies # authoritative rule constants32GET /api/events33GET /api/events/{event_id}34GET /api/events/{event_id}/orders # sponsor orders35GET /api/events/{event_id}/badges # badge scans36GET /api/events/{event_id}/sponsor_packages # same shape as orders37GET /api/finance/invoices?event_id={event_id} # invoices for the event38GET /api/crm/accounts39GET /api/crm/contacts40GET /api/crm/opportunities41GET /api/crm/campaign_members?event_id={event_id}42GET /api/tradeshows43GET /api/tradeshows/{show_id}/exhibitors44GET /api/tradeshows/{show_id}/meeting_interest45GET /api/import_batches46GET /api/import_batches/{batch_id}/raw_contacts47GET /api/import_batches/{batch_id}/suppression # global suppression list48```4950## 1. Authoritative constants — read `/api/policies` first5152`/api/policies` returns the controlled vocabularies. Always conform to these enums:5354- `sponsor_handoff.status_enums`: `paid_deferred`, `open_invoice`, `proposal_only`, `not_sponsor`55- `prospecting.platform_enums` (and the required output order): `AUV`, `ROV`, `Underwater Camera`56- `prospecting.qualification_note`: an exhibitor qualifies only if it **builds or OEM-builds**57 target underwater platforms; an exhibitor that is merely *adjacent* (distributor,58 service/rental, sensor-only, research, software-only) does **not** qualify.5960Values stated explicitly in a task prompt (e.g. tier→opportunity amounts, tier score61thresholds) take precedence over the defaults in section 5; if the prompt does not state62them, use the defaults documented there.6364## 2. Cross-cutting conventions6566### Value normalization67- **Email**: trim surrounding whitespace, lowercase. Empty string `""` when absent/blank.68- **Phone**: keep digits only (strip everything else). Do NOT add or strip a leading69 country code — `"415-555-0188"` → `"4155550188"`, `"+1 (206) 555-0150"` → `"12065550150"`.70 Empty string `""` when no digits present.71- **Currency**: integer USD. Never float.72- **Dates**: `YYYY-MM-DD`. Due dates are computed by calendar-day addition (see 2.3).7374### CRM record matching75- **Existing account**: a lead/contact matches an existing CRM account when the normalized76 email **domain** equals a CRM account `domain`, or the company name matches a CRM account77 `name`. Use the matched `account_id`.78- **Existing contact**: match by normalized email against a CRM contact `email`; if no email79 match, fall back to same `account_id` + same `contact_name`. Use the matched `contact_id`.80- A CRM account with `status == "disqualified"` (non-null `disqualified_reason`) is an81 **existing_disqualified** exclusion — leads for these accounts are not imported/qualified.8283### Due-date arithmetic (uses event constants from `/api/events/{event_id}`)84- `lead_followup_due_date` = `event.end_date` + `event.followup_days_after_end` (calendar days).85- `sponsor_followup_due_date` = `event.end_date` + `event.sponsor_followup_days_after_end`.86- Both rendered `YYYY-MM-DD`. These constants are per-event; never assume fixed offsets.8788### Output discipline89- Return exactly ONE JSON object conforming to the provided `answer_template.json`.90- Do not add fields not in the template. Do not include prose outside the JSON.91- Enum values must exactly match the template's `allowed_values` (note family-specific92 spelling variants, e.g. `sensor_only` vs `sensor_vendor_only` — see section 5).93- Apply every template `ordering` rule. Common sort keys summarized in section 7.9495## 3. Family A — Event handoff / reconciliation9697Inputs: `/api/events/{id}`, `/orders` (or `/sponsor_packages`), `/badges`,98`/api/finance/invoices?event_id={id}`, `/api/crm/accounts`, `/api/crm/contacts`,99`/api/crm/opportunities`, `/api/crm/campaign_members?event_id={id}`, `/api/policies`.100101### 3.1 Sponsor status reconciliation102Sponsor orders carry `order_status`. Treat order statuses as:103- **Active** = `confirmed` or `proposal_sent` → appears in `sponsor_statuses`.104- **Inactive** = `canceled` or `no_show` → **excluded** from `sponsor_statuses`; their105 ticket contacts become `inactive_sponsor_record` exclusions (section 3.2).106107For each **active** sponsor order, aggregate that account's invoices for the event108(match on `account_id` + `event_id`). Compute:109- `package_amount` = the sponsor order `amount`.110- `paid_amount` = sum of `paid_amount` across the account's invoices for this event.111- `open_balance` = `package_amount` − `paid_amount`.112- Status decision:113 - `proposal_sent` with **no invoice** → `proposal_only`; `invoice_id = null`;114 `paid_amount = 0`; `open_balance = 0`.115 - Has invoice(s) and `open_balance > 0` → `open_invoice`; `invoice_id` = the open116 invoice's id (the one with unpaid balance).117 - Has invoice(s) and fully paid (`open_balance == 0`) → `paid_deferred`;118 `invoice_id` = the paid invoice's id.119 - (A sponsor may have multiple invoices, e.g. one paid-deferred + one open; the120 overall status is `open_invoice` whenever any balance remains.)121122Revenue totals (integer USD):123- `paid_deferred` = Σ `package_amount` of `paid_deferred` sponsors.124- `open_invoice` = Σ `package_amount` of `open_invoice` sponsors.125- `proposal_only` = Σ `package_amount` of `proposal_only` sponsors.126- `open_invoice_balance` = Σ `open_balance` of `open_invoice` sponsors.127128Sort `sponsor_statuses` by `account_name` ascending.129130### 3.2 Badge → lead classification (precedence order)131Apply in this order; first match wins:1321331. **Non-business badge** — `badge_type` not a business type (e.g. `student`, `press`)134 → classification `excluded`, reason `non_business_badge`, crm_action `no_action`/`no_import`.1352. **Sponsor attendee** — `badge_type == "sponsor"`, **or** the (company_name, contact_name)136 is a `ticket_contacts` entry of a **confirmed** sponsor order → classification137 `sponsor_attendee`, excluded from leads. (Campaign-member target status138 `attended_sponsor` if scanned/attended, else `registered_sponsor`.)1393. **Inactive sponsor record** — the contact is a ticket contact of a `canceled`/`no_show`140 sponsor order → classification `excluded`, reason `inactive_sponsor_record`.1414. **Existing disqualified** — the company's CRM account `status == "disqualified"` →142 classification `excluded`, reason `existing_disqualified`. (Sponsor-order reasons in143 steps 2–3 take precedence over this for companies that also had a sponsor order.)1445. **Missing contact** — the badge has **neither** a normalizable email **nor** a145 normalizable phone → classification `excluded`, reason `missing_contact`.1466. **Qualified non-sponsor lead** — otherwise → classification147 `qualified_non_sponsor_lead`. These are the sales-handoff leads.148149> Note on `proposal_sent` sponsors: only **confirmed** sponsors' ticket contacts are150> `sponsor_attendee`. A `proposal_sent` sponsor's ticket contact who attended on a regular151> (attendee) badge is treated as a normal lead subject to steps 4–6, while the company152> still appears in `sponsor_statuses` as `proposal_only` (a finance/proposal matter, not a153> lead-exclusion matter).154155### 3.3 Qualified non-sponsor leads156- Each qualified lead's `opportunity_amount` = `event.lead_opportunity_amount` (per-lead157 constant from the event record).158- `lead_pipeline_total` (Family-A1 schema) = (number of qualified leads) × `lead_opportunity_amount`.159- `opportunity_summary.open_opportunity_total_usd` / `open_opportunity_count` (Family-A2160 schema) = same total / count of qualified non-sponsor leads (the new open opportunities161 being handed off). `lead_opportunity_amount_usd` = the per-lead unit.162- `qualified_non_sponsor_account_names` = company (account) names of qualified leads,163 sorted ascending.164- CRM action per qualified lead:165 - Account exists (section 2.2) → `crm_account_action = update_existing`; else `create_account`.166 - Contact exists → `crm_contact_action = update_existing`; else `create_contact`.167 - Campaign member: if a campaign member already exists for (account, contact, event) →168 `update` (or `no_action` if its status is already the correct target); else `create`169 (`add_campaign_member`). Target status `attended` for non-sponsor leads.170- For badge-only leads (no CRM contact), emit `normalized_email` / `normalized_phone`171 (empty string allowed when absent).172173CRM action counts (Family-A1): `accounts_create`, `accounts_update`, `contacts_create`,174`contacts_update`, `campaign_members_create`, `campaign_members_update` — counts of the175above actions across all qualified leads.176177### 3.4 Sponsor finance follow-up178- Targets = sponsors with `open_balance > 0` (i.e. `open_invoice` sponsors).179- `sponsor_finance_accounts` / `unpaid_sponsor_account_names` = their account names180 (sorted ascending). `unpaid_sponsor_total_usd` / `open_invoice_balance` = Σ open balances.181- `sponsor_finance_task_count` = number of such accounts.182- `sponsor_finance_due_date` / `sponsor_followup.followup_due_date` =183 `event.end_date` + `event.sponsor_followup_days_after_end`.184- `proposal_only` sponsors are **not** finance collection targets (no invoice, no balance).185186### 3.5 Exclusions & sorting187- `excluded_records` (Family-A1): `{company_name, contact_name, reason}` where reason ∈188 `sponsor_attendee | existing_disqualified | inactive_sponsor_record | non_business_badge`.189 Sort by `company_name` asc, then `contact_name` asc.190- `badge_decisions` (Family-A2): one row per badge with `classification`, `crm_action`,191 `exclusion_reason` (null when qualified). Sort by `badge_id` asc.192- `exclusion_counts` (Family-A2): counts for `sponsor_attendee`, `non_business_badge`,193 `existing_disqualified`, `missing_contact` (integer, may be 0).194- `campaign_member_actions` (Family-A2): `{subject_key, account_name, contact_name, action,195 target_status}`. `action` ∈ `create|update|no_action|no_import`; `target_status` ∈196 `attended_sponsor|registered_sponsor|attended|excluded`. `subject_key` = a stable sort197 key for the contact (contact_name, or account_name+contact_name). Sort by `subject_key` asc.198- `badge_only_contacts` (Family-A2): `{company_name, contact_name, normalized_email,199 normalized_phone}`. Sort by `company_name` asc.200201## 4. Family B — Trade-show prospecting202203Inputs: `/api/tradeshows/{show}/exhibitors`, `/api/tradeshows/{show}/meeting_interest`,204`/api/crm/accounts`, `/api/crm/contacts`, `/api/policies`.205206`meeting_interest` is a list keyed by `company_name`; match an exhibitor to its207meeting-interest entry (if any) by `company_name`. An exhibitor with no208meeting-interest entry has `requested_demo = false` and `interest_score = 0`.209210### 4.1 Qualification & platform classification211From each exhibitor's `description` (and company context), decide whether it **builds or212OEM-builds** one or more target platforms. Assign the platform enums it builds:213- `AUV` — builds autonomous/autonomous underwater vehicles, AUV scouts/pods/mapping pods.214- `ROV` — builds (manufactures) ROVs / inspection-class / pen-cleaning / resident ROVs.215- `Underwater Camera` — OEM-builds underwater camera modules/systems/cameras.216217A description may yield multiple platforms. Output `platforms` in enum order:218`AUV, ROV, Underwater Camera`. Qualified = builds/OEM-builds ≥1 target platform.219220### 4.2 Exclusion classification (relationship type)221Exhibitors that do **not** build a target platform are excluded. Classify the relationship:222- **distributor** — reseller, dealer, sales agent, distributor of others' platforms.223- **service_provider** — consulting, rental fleet, inspection *services* that operate224 others' rented/platforms; or a software/analytics-only company with no hardware.225- **sensor_vendor** — makes sensors/probes only (no platform).226- **research** — academic / research lab.227- **other / not target market** — anything else non-adjacent (only when the template allows228 a `not_target_market`-style reason).229230Map the relationship to the exclusion-reason enum **the specific template permits**:231- distributor → `distributor_only`232- service_provider → `service_only`233- sensor_vendor → `sensor_only` (Family-B2) **or** `sensor_vendor_only` (Family-B1)234- research → `research_only`235- other → `not_target_market` (Family-B1 only)236237Emit exactly the strings in the template's `allowed_values`. Family-B2 also requires a238`relationship_type` field (`distributor|service_provider|sensor_vendor|research`).239240### 4.3 Priority tier (shared rule)241Default thresholds (override per task prompt if it states different numbers):242- **A** = `requested_demo == true` AND `interest_score ≥ 90`.243- **B** = `requested_demo == true` AND `interest_score ≥ 80`.244- **C** = all other qualified leads.245246### 4.4 Opportunity sizing (Family-B2)247Default amounts (override per task prompt): A = USD 120000, B = USD 90000, C = USD 50000.248`opportunity_estimate_usd` per lead by tier; `total_estimated_opportunity_usd` = Σ.249250### 4.5 CRM action (Family-B2)251- Qualified exhibitor with non-null `crm_account_id` → `crm_action = update_existing`;252 `crm_account_id` echoed.253- Qualified exhibitor with null `crm_account_id` → `crm_action = create_account`;254 `crm_account_id = null`.255- Excluded exhibitors → `crm_action = no_import`.256- `existing_crm_overlap_count` / `existing_crm_overlap_account_ids` = qualified exhibitors257 that already have a CRM account; account IDs sorted ascending.258259### 4.6 Ranking & counts260- **Family-B2 `ranked_leads`** sort: (1) `requested_demo` true first, (2) `interest_score`261 descending, (3) broader platform coverage (count of platforms) descending,262 (4) `company_name` ascending. `rank` is 1-based contiguous.263- **Family-B1 `qualified_exhibitors`** sort: `company_name` ascending (no ranking).264- `excluded_*` lists sort by `company_name` ascending.265- `platform_coverage_counts` / `platform_counts`: for each platform enum, the number of266 qualified leads that cover it (a lead covering 2 platforms counts in both).267- `priority_counts`: {A, B, C} counts of qualified leads.268- `qualified_total` / `qualified_lead_count` = number of qualified leads.269- `excluded_count` / `excluded_near_misses_total` = number of excluded exhibitors.270271## 5. Family C — Import cleaning272273Inputs: `/api/import_batches` (batch metadata incl. `campaign_code`),274`/api/import_batches/{batch}/raw_contacts`, `/api/import_batches/{batch}/suppression`275(the suppression list is global/shared across batches), `/api/crm/accounts`,276`/api/crm/contacts`, `/api/policies`.277278### 5.1 Normalize every raw row279- `email` → normalized (trim+lowercase) or `""`.280- `phone` → digits-only or `""`.281- Keep `row_id`, `source_name`, `captured_at`, `company_name`, `contact_name` from the row.282283### 5.2 Removal decisions (apply in this order)2841. **Duplicate** — group rows by normalized email (primary duplicate key). Within a group:285 - **Winner** = the row with the **earliest `captured_at`**; tie-break by **lowest286 `row_id`** ascending. (Earliest capture = original record; its data is kept.)287 - All other rows in the group are removed with reason `duplicate`.288 - Record in `duplicate_summary`: per key `{key, winner_row_id, removed_row_ids}`;289 `duplicate_keys` sorted by `key` ascending; `duplicate_removed_count` = total losers.290 - The winner's `clean_contact_id` and `source_row_id` = the winner `row_id`;291 `captured_at` = the winner's timestamp.2922. **Suppressed** — if the row's normalized email OR normalized phone matches any293 suppression-list entry → removed with reason `suppressed`; action `suppress`.2943. **Missing contact** — if the row has **neither** a normalized email **nor** a normalized295 phone → removed with reason `missing_contact`; action `no_import`.296297> A row with email but no phone (or phone but no email) is **not** missing-contact — it is298> usable. Only the total absence of both contact channels makes a row unusable.299> Duplicates are resolved before suppression/missing checks; only the surviving winner of300> a duplicate group is screened against suppression/missing.301302### 5.3 Clean contacts (survivors)303For each surviving (non-removed) row:304- `crm_action`:305 - `update_existing` if an existing CRM account matches (email domain or company name);306 set `existing_account_id`.307 - else `create_account`; `existing_account_id = null`.308- `existing_contact_id`: set if a CRM contact matches (by normalized email, else same309 account + same `contact_name`); else `null`.310- `email`/`phone` = normalized values (or `""`).311- `source_name` ∈ `badge_scan|sponsor_form|partner_upload|webinar_form|exhibitor_form|manual_upload`.312313### 5.4 Totals & counts314- `import_action_totals` = `{create_account, update_existing, no_import, suppress}`:315 - `create_account` = clean survivors that are new accounts.316 - `update_existing` = clean survivors matching an existing account.317 - `no_import` = removed-as-missing-contact rows.318 - `suppress` = removed-as-suppressed rows.319 - (Duplicate losers are tracked in `duplicate_summary`, not in `import_action_totals`.)320- `removal_summary`:321 - `unusable_removed_count` = `missing_contact` removals.322 - `suppressed_removed_count` = `suppressed` removals.323 - `removed_rows` = **all** removed rows (duplicates + missing + suppressed), each324 `{row_id, reason}` with reason ∈ `duplicate|missing_contact|suppressed`; sort by325 `row_id` ascending.326- `campaign_member_import_count` = number of clean survivors (each becomes a member of the327 batch's `campaign_code`).328- `batch_id` and `campaign_code` come from `/api/import_batches`.329- `clean_contacts` sorted by `clean_contact_id` ascending.330331## 6. Opportunity / pipeline math summary332333- Event handoff: per-qualified-lead amount = `event.lead_opportunity_amount`; pipeline =334 count × amount. (Do not use CRM opportunity records for the lead pipeline.)335- CRM `opportunities` are sponsor/opportunity records tied to accounts via `event_id`;336 `stage == "closed_won"` is closed; other stages (`proposal`, `qualification`,337 `discovery`) are open. Use these only where a template explicitly asks about existing338 open opportunities.339- Prospecting: per-lead amount by priority tier (prompt-specified; defaults A=120000,340 B=90000, C=50000); total = Σ.341342## 7. Sort-key reference343344| List | Sort key |345|---|---|346| `sponsor_statuses` | `account_name` asc |347| `qualified_lead_accounts` | `account_name` asc |348| `excluded_records` | `company_name` asc, then `contact_name` asc |349| `badge_decisions` | `badge_id` asc |350| `badge_only_contacts` | `company_name` asc |351| `campaign_member_actions` | `subject_key` asc |352| `qualified_non_sponsor_account_names` | string asc |353| `unpaid_sponsor_account_names` | string asc |354| `qualified_exhibitors` (B1) | `company_name` asc |355| `excluded_near_misses` (B1) | `company_name` asc |356| `ranked_leads` (B2) | `rank` asc (1-based contiguous) |357| `excluded_exhibitors` (B2) | `company_name` asc |358| `existing_crm_overlap_account_ids` | account ID asc |359| `clean_contacts` | `clean_contact_id` asc |360| `duplicate_keys` | `key` asc |361| `removed_rows` | `row_id` asc |362363## 8. Execution checklist for any task3643651. Read the task prompt; identify the family (event handoff / prospecting / import) and the366 target `event_id` / `show_id` / `batch_id`.3672. Read the `answer_template.json` — it defines the exact required keys, enums, and368 ordering. Conform to **its** allowed_values (mind spelling variants).3693. `GET /api/policies`; conform sponsor-status and platform enums to it.3704. Fetch the family's input endpoints (section 3/4/5).3715. Apply the family's decision procedure (precedence order matters).3726. Compute due dates from the event's own `followup_days_after_end` /373 `sponsor_followup_days_after_end` constants.3747. Normalize all emails/phones; match CRM accounts/contacts per section 2.2.3758. Apply every template sort rule (section 7); use integers for USD and counts.3769. Emit exactly one JSON object matching the template — no extra fields, no prose.377378## 9. Reasoning notes & judgment calls (apply consistently)379380- **Duplicate winner = earliest `captured_at`, tie-break lowest `row_id`.** The earliest381 capture is the original record and tends to carry the canonical company spelling that382 matches the CRM account name.383- **Sponsor status precedence**: any unpaid balance ⇒ `open_invoice` (even if a separate384 invoice for the same sponsor is `paid_deferred`). Handle multi-invoice sponsors by385 aggregating per (account, event).386- **Badge classification precedence**: non-business → sponsor-attendee (confirmed) →387 inactive-sponsor (canceled/no_show) → existing-disqualified → missing-contact →388 qualified lead. Sponsor-order reasons outrank CRM-disqualified for companies that had a389 sponsor order.390- **Sponsor finance follow-up = open-balance sponsors only** (`open_invoice`).391 `proposal_only` and fully-paid `paid_deferred` sponsors are not collection targets.392- **Missing-contact = neither email nor phone** (symmetric: email-only and phone-only rows393 are usable).394- **Prospecting exclusions**: the relationship classification is stable, but the emitted395 enum string must match the template (`sensor_only` vs `sensor_vendor_only`;396 `not_target_market` only where allowed).397- When a field is genuinely ambiguous and the template does not disambiguate, choose the398 reading that keeps the dataset internally consistent (e.g. totals reconcile with the399 row counts; CRM action counts reconcile with the lead list).