HarborCRM Front-of-Funnel Solver
You produce one JSON object that conforms EXACTLY to the task's answer_template.json
(input/payloads/answer_template.json). No prose outside the JSON. Do not add or drop
keys. Match every enum value verbatim. Sort every list exactly as the template's
ordering/sorting rules say. Integers for all USD and counts (no decimals, no strings).
Environment
- HTTP-only data API. Use
curl (or python urllib/requests). Never use a tool that forces HTTPS.
- Base URL is supplied by the runner (e.g.
http://<host>:<port>). All endpoints below are
relative to it. Always GET /api/policies first; it carries the controlled enums
(status_enums, platform_enums) even though most decision logic lives in the record data.
- Read-only endpoints:
/health, /api/policies, /api/events, /api/events/{id}, /api/events/{id}/orders,
/api/events/{id}/badges, /api/events/{id}/sponsor_packages,
/api/finance/invoices?event_id=<id>, /api/crm/accounts, /api/crm/contacts,
/api/crm/opportunities, /api/crm/campaign_members?event_id=<id>, /api/tradeshows,
/api/tradeshows/{show}/exhibitors, /api/tradeshows/{show}/meeting_interest,
/api/import_batches, /api/import_batches/{batch}/raw_contacts,
/api/import_batches/{batch}/suppression.
Universal normalization rules (apply everywhere a normalized value is requested)
- Email: trim leading/trailing whitespace, then lowercase the whole string. A value that
is empty or only whitespace normalizes to
"" (empty string), never null.
- Phone: keep digits only (strip
+, spaces, (), -, .). Do NOT strip a leading 1
— keep every digit the source supplied. So +1 415 555 0188 -> 14155550188, but
415-555-0188 -> 4155550188. Empty/absent phone -> "".
- A contact is "contactable" if it has a non-empty normalized email OR a non-empty normalized
phone. A row/badge with neither is a
missing_contact exclusion.
Universal date arithmetic
lead_followup_due_date / lead follow-up = event end_date + followup_days_after_end days.
sponsor_followup_due_date / sponsor finance follow-up = event end_date +
sponsor_followup_days_after_end days.
- Add calendar days to
end_date (YYYY-MM-DD), output YYYY-MM-DD. Use end_date, not start_date.
TASK FAMILY A — Post-event sponsor/lead handoff reconciliation
(e.g. neuralops_2026, edgeai_field_2026)
Endpoints to combine: /api/events/{id} (dates, lead_opportunity_amount, *_days_after_end),
/api/events/{id}/orders (= sponsor_packages; sponsor order_status + amount + ticket_contacts),
/api/finance/invoices?event_id= (paid/deferred/open per sponsor),
/api/events/{id}/badges (attendees/leads), /api/crm/accounts (status, disqualified_reason),
/api/crm/contacts, /api/crm/opportunities, /api/crm/campaign_members?event_id=.
A1. Sponsor status (controlled enum: paid_deferred | open_invoice | proposal_only | not_sponsor)
For each sponsor ORDER, decide status by combining order_status + invoice status:
- order_status
canceled -> NOT an active sponsor. Exclude it from sponsor_statuses
entirely, and exclude its ticket contact from leads (reason inactive_sponsor_record).
- order_status
proposal_sent and NO invoice -> proposal_only (invoice_id null, paid 0,
open_balance 0; package_amount = order amount).
- order_status
confirmed WITH an invoice:
- invoice.status
paid_deferred (paid_amount == amount) -> paid_deferred
(open_balance = 0).
- invoice.status
open -> open_invoice, paid_amount = invoice.paid_amount,
open_balance = invoice.amount − invoice.paid_amount (NOT amount−paid−deferred).
package_amount = the sponsor order/invoice amount (integer USD).
- Sort
sponsor_statuses by account_name ascending.
A2. Sponsor revenue totals (when the template asks)
paid_deferred = sum of package amounts of paid_deferred sponsors.
open_invoice = sum of package amounts of open_invoice sponsors (the FULL package amount,
not the open balance).
proposal_only = sum of package amounts of proposal_only sponsors.
open_invoice_balance = sum of open balances of open_invoice sponsors (amount − paid).
A3. Sponsor finance follow-up (LESSON — do X not Y)
- Finance follow-up targets = all active sponsors that are NOT fully paid, i.e.
open_invoice AND proposal_only. Do NOT restrict it to open_invoice only.
paid_deferred sponsors are settled -> never in finance follow-up.
sponsor_finance_task_count = number of those accounts; list their account_names
(sponsor_finance_accounts / unpaid_sponsor_account_names) sorted ascending.
unpaid_sponsor_total_usd = sum of their package amounts (open_invoice amount +
proposal_only amount), integer USD.
- Due date = sponsor finance follow-up date (see date arithmetic).
A4. Qualified non-sponsor leads from badges
Walk badges. A badge becomes a qualified non-sponsor lead only if ALL hold:
- badge_type is a business attendee type (
attendee). EXCLUDE non-business badges:
student, press, and similar -> exclusion reason non_business_badge.
- The company is NOT an active sponsor and the contact is NOT a sponsor ticket_contact
(those are
sponsor_attendee).
- The matching CRM account is NOT disqualified. A CRM account with status
disqualified
/ non-null disqualified_reason -> exclude reason existing_disqualified.
- The badge is contactable (has email or phone); otherwise
missing_contact.
- Exclusion-reason precedence for a badge that hits several rules: sponsor_attendee >
inactive_sponsor_record > non_business_badge > existing_disqualified > missing_contact.
(A canceled-sponsor ticket contact whose CRM account is also disqualified is reported as
inactive_sponsor_record — the more specific sponsor-side reason.)
For each qualified lead:
opportunity_amount / lead opp = the EVENT's lead_opportunity_amount (same for every lead).
account_id: existing CRM account_id if the company already exists in CRM, else null.
crm_account_action: update_existing if the account already exists, else create_account.
crm_contact_action: create_contact when the badge person is a new contact (a different
existing contact at that account, or an opted-out/suppressed existing contact, does NOT make
the new badge person an update). Use update_existing only when that exact contact already
exists and is being refreshed.
campaign_member_action: add_campaign_member for a brand-new event member.
lead_pipeline_total = (#qualified leads) × lead_opportunity_amount.
A5. CRM action counts (the work implied by the handoff)
Count only the lead/handoff work:
accounts_create = new accounts among qualified leads; accounts_update = existing accounts.
contacts_create = new badge contacts; contacts_update = refreshed existing contacts.
campaign_members_create = new event members added for qualified leads;
campaign_members_update = existing members whose status is being changed.
A6. Badge decisions / campaign member actions (richer reconciliation variant, e.g. edgeai)
badge_decisions (sort by badge_id): each badge -> {classification, crm_action, exclusion_reason}.
- classification enum:
sponsor_attendee | qualified_non_sponsor_lead | excluded.
- A sponsor ticket-contact badge -> classification
sponsor_attendee, exclusion_reason
sponsor_attendee. If that sponsor member already exists and is already correct in the
campaign -> crm_action no_action (keep badge crm_action consistent with the campaign-member
action for the same person; do not say update when nothing changes).
- A new business attendee ->
qualified_non_sponsor_lead. crm_action:
create_account_contact_campaign_member when the account is brand new;
create_contact_campaign_member when the account exists but the contact is new;
add_campaign_member when account+contact exist but no member yet.
- non-business badge -> classification
excluded, crm_action no_import,
exclusion_reason non_business_badge. exclusion_reason is null for qualified leads.
campaign_member_actions (sort by subject_key):
subject_key is an account/CRM-style key (e.g. the account_id), NOT the contact name.
(Using contact names here scored worse.)
- action enum:
create | update | no_action | no_import.
target_status enum: attended_sponsor | registered_sponsor | attended | excluded.
- Existing member already at the right status ->
no_action keeping its current target_status
(attended_sponsor / registered_sponsor). A sponsor with no attendance badge stays
registered_sponsor.
- New qualified non-sponsor lead ->
create, target_status attended.
opportunity_summary:
qualified_non_sponsor_account_names = sorted account names of qualified non-sponsor leads.
lead_opportunity_amount_usd = event lead_opportunity_amount.
open_opportunity_total_usd / open_opportunity_count = the NEW lead opportunities created
for qualified non-sponsor leads: count = #qualified-non-sponsor leads, total = count ×
lead_opportunity_amount. (Confirmed: this beat using existing CRM open-stage opps = 0.)
badge_only_contacts (sort by company_name): one row per qualified non-sponsor (badge-only)
lead with normalized_email / normalized_phone per the universal normalization rules.
exclusion_counts: integer tally per reason key (sponsor_attendee, non_business_badge,
existing_disqualified, missing_contact). Count each excluded badge once under its reason.
NOTE / residual uncertainty (Family A): the handling of a proposal-only sponsor's attending
ticket contact (sponsor_attendee vs lead) and the exact target_status for that person were the
hardest calls and may need re-derivation per task; default to treating any sponsor ticket-contact
(even proposal_only) as sponsor_attendee.
TASK FAMILY B — Trade-show OEM / robotics prospecting (FULLY VALIDATED, score 1.0)
(e.g. marinesense_2026 dissolved-oxygen sensor, aquafarm_robotics_2026 aquaculture robotics)
Endpoints: /api/tradeshows/{show}/exhibitors (company_id, name, booth, country, website,
description, crm_account_id), /api/tradeshows/{show}/meeting_interest
(interest_score, requested_demo, by company_name), /api/crm/accounts, /api/policies.
B1. Platform classification (enum order ALWAYS: AUV, ROV, Underwater Camera)
Read the exhibitor description and assign every target platform the company makes / OEM-builds:
- "AUV" / "autonomous underwater vehicle" / "AUV scout" -> AUV.
- "ROV" / "remotely operated" / pen-cleaning/inspection ROV builder -> ROV.
- "underwater camera" / "camera module/array" maker -> Underwater Camera.
- A company can have multiple platforms (e.g. ROVs WITH camera arrays -> [ROV, Underwater Camera]).
- Always emit the platforms list sorted in enum order AUV, ROV, Underwater Camera.
B2. Qualify vs exclude (a company must MANUFACTURE/OEM-BUILD a target platform)
Exclude near-misses and give a controlled reason. Two reason vocabularies appear; pick the one
in THIS task's template:
- distributor / reseller / sales agent / "does not manufacture" ->
reason
distributor_only; relationship_type distributor.
- consulting / operates-rented / service team -> reason
service_only;
relationship_type service_provider.
- sensor-/probe-only vendor (sells sensors for others to integrate, no platform) ->
reason
sensor_vendor_only (marinesense vocab) or sensor_only (aquafarm vocab);
relationship_type sensor_vendor.
- research-only org ->
research_only / relationship_type research.
- analytics/software-only "dashboard, no hardware manufacturing" -> treat as service ->
service_only / service_provider.
- excluded exhibitors always get crm_action
no_import and stay visible in the exclusion list.
- Sort exclusion list by
company_name ascending.
B3. CRM action for qualified exhibitors
- If exhibitor
crm_account_id is non-null (already in CRM) -> crm_action update_existing,
carry that crm_account_id.
- Else -> crm_action
create_account, crm_account_id null.
existing_crm_overlap_* = qualified leads whose crm_account_id is non-null: count + the
account_ids sorted ascending.
B4. Priority tier + opportunity sizing (demo + interest_score)
- Tier A = requested_demo true AND interest_score >= 90 -> USD 120000.
- Tier B = requested_demo true AND interest_score >= 80 -> USD 90000.
- Tier C = everything else (no demo, or demo with score < 80) -> USD 50000.
- (For marinesense, the same A/B/C demo+score thresholds applied; tier A=demo&>=90, B=demo&>=80.)
total_estimated_opportunity_usd = sum of opportunity_estimate over qualified leads.
- A company with no meeting_interest record = no demo, treat interest_score per template
(if a score field is required and none exists, it is not demo-requested -> tier C).
B5. Ranking (when rank is required, e.g. aquafarm)
Sort qualified leads by, in order:
- requested_demo DESC (demo-requested first),
- interest_score DESC,
- broader platform coverage first (more platforms first),
- company_name ascending.
Then assign 1-based contiguous
rank. (When no rank field, just sort by company_name ascending.)
B6. Aggregate counts
qualified_total / qualified_lead_count, excluded_*_total / excluded_count = list lengths.
platform_counts / platform_coverage_counts = per-platform count of qualified leads whose
platforms include that enum (a 2-platform company increments two buckets). Keys AUV, ROV,
Underwater Camera, integer values.
priority_counts = count of qualified leads per tier A/B/C.
TASK FAMILY C — Raw contact-import hygiene
(e.g. fall_webinar_import)
Endpoints: /api/import_batches (campaign_code), /api/import_batches/{batch}/raw_contacts,
/api/import_batches/{batch}/suppression, /api/crm/accounts, /api/crm/contacts, /api/policies.
C1. Per-row pipeline (apply in this order)
- Normalize email + phone for every raw row (universal rules above).
- Suppression: a row whose normalized email OR normalized phone matches any suppression
entry is suppressed (reason
suppressed). Suppression matches on email and on phone
independently.
- Missing contact: a row with neither a usable email nor phone after normalization is
unusable (reason
missing_contact).
- Deduplicate the remaining rows. Dedup KEY = normalized email (the two HelioWare rows
with different company spellings still collide because email matches; the two Quartz rows
collide on identical email). Within a duplicate group, choose ONE winner; the others are
removed with reason
duplicate. For the duplicate-key entries report
{key, winner_row_id, removed_row_ids}. Sort duplicate_keys by key ascending.
- Tie-break when timestamps are equal: lowest
row_id wins.
- Winner selection by
captured_at was ambiguous in training (latest-wins and earliest-wins
both scored the same low number) — see the warning below; pick a single deterministic rule
(recommend: most-recent captured_at, tie -> lowest row_id) and apply it consistently.
C2. CRM action per surviving contact (enum: create_account | update_existing | no_import | suppress)
- Account already exists in CRM (match by email domain to an
/api/crm/accounts domain) ->
update_existing, set existing_account_id (and existing_contact_id if that exact contact
exists, else null).
- New company ->
create_account, existing ids null.
- A missing-contact row ->
no_import; a suppressed row -> suppress.
clean_contact_id and source_row_id = the winning source row_id. captured_at,
source_name, company_name, contact_name come from the WINNING row.
source_name enum: badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form,
manual_upload.
- Sort
clean_contacts by clean_contact_id ascending.
C3. Summaries
duplicate_summary.duplicate_removed_count = number of rows removed as duplicates (losers).
removal_summary: unusable_removed_count (missing_contact rows),
suppressed_removed_count (suppressed rows), and removed_rows = every removed row as
{row_id, reason} with reason in {duplicate, missing_contact, suppressed}, sorted by row_id asc.
import_action_totals = integer tally over the per-row crm_action dispositions
(create_account, update_existing, no_import, suppress).
campaign_member_import_count = number of importable cleaned contacts (the create_account +
update_existing survivors) to add as members of the batch campaign.
C4. WARNING — unresolved membership shape (Family C)
Training could not fully pin down the exact clean_contacts membership/field expectations for
this family within the available feedback (multiple plausible models scored identically low).
Two candidate models exist and you must decide per the template wording:
(a) clean_contacts = only the importable survivors (create_account + update_existing);
suppressed/missing rows live ONLY in removal_summary.
(b) clean_contacts = every deduplicated survivor INCLUDING suppressed (suppress) and
missing-contact (no_import) rows, each carrying its crm_action; those rows ALSO appear
in removal_summary.
The crm_action enum containing suppress and no_import hints toward model (b); but verify
against the template's field_types/descriptions for the specific task and keep
import_action_totals consistent with whichever membership you choose. Do NOT assume either
blindly — re-read the template's clean_contacts.field_types and campaign_member_import_count
description and let them decide.
Cross-cutting output discipline (mistakes the feedback punished — do X, not Y)
- DO include open_invoice AND proposal_only sponsors in finance follow-up; do NOT limit to open_invoice.
- DO compute open_balance as invoice.amount − paid_amount; do NOT subtract deferred_amount again.
- DO set open_invoice revenue total to the full package amount; the open money goes in the
separate
open_invoice_balance field only.
- DO use lead opportunities (count × lead_opportunity_amount) for
open_opportunity_total/count;
do NOT report existing CRM open-stage opps (which were 0) for that field.
- DO use account-id-style
subject_key in campaign_member_actions; do NOT use contact names.
- DO keep a badge's crm_action consistent with that person's campaign-member action (no_action
when nothing changes); do NOT mark
update when the member is already correct.
- DO keep every digit when normalizing phones (including a leading country
1).
- DO treat a new badge person at an account whose only existing contact is opted-out/suppressed
as
create_contact, not update.
- DO sort exactly as the template states, emit integers, and never add undeclared fields.
1---2name: harborcrm-front-of-funnel-23description: End-to-end SOP for HarborCRM front-of-funnel CRM marketing tasks — post-event sponsor/lead handoff reconciliation, trade-show OEM/robotics prospecting with ranking & tiering, and raw contact-import hygiene — producing strict JSON that matches the per-task answer_template.4---56# HarborCRM Front-of-Funnel Solver78You produce one JSON object that conforms EXACTLY to the task's `answer_template.json`9(`input/payloads/answer_template.json`). No prose outside the JSON. Do not add or drop10keys. Match every enum value verbatim. Sort every list exactly as the template's11`ordering`/sorting rules say. Integers for all USD and counts (no decimals, no strings).1213## Environment14- HTTP-only data API. Use `curl` (or python urllib/requests). Never use a tool that forces HTTPS.15- Base URL is supplied by the runner (e.g. `http://<host>:<port>`). All endpoints below are16 relative to it. Always `GET /api/policies` first; it carries the controlled enums17 (`status_enums`, `platform_enums`) even though most decision logic lives in the record data.18- Read-only endpoints:19 `/health`, `/api/policies`, `/api/events`, `/api/events/{id}`, `/api/events/{id}/orders`,20 `/api/events/{id}/badges`, `/api/events/{id}/sponsor_packages`,21 `/api/finance/invoices?event_id=<id>`, `/api/crm/accounts`, `/api/crm/contacts`,22 `/api/crm/opportunities`, `/api/crm/campaign_members?event_id=<id>`, `/api/tradeshows`,23 `/api/tradeshows/{show}/exhibitors`, `/api/tradeshows/{show}/meeting_interest`,24 `/api/import_batches`, `/api/import_batches/{batch}/raw_contacts`,25 `/api/import_batches/{batch}/suppression`.2627## Universal normalization rules (apply everywhere a normalized value is requested)28- **Email**: trim leading/trailing whitespace, then lowercase the whole string. A value that29 is empty or only whitespace normalizes to `""` (empty string), never null.30- **Phone**: keep digits only (strip `+`, spaces, `()`, `-`, `.`). Do NOT strip a leading `1`31 — keep every digit the source supplied. So `+1 415 555 0188` -> `14155550188`, but32 `415-555-0188` -> `4155550188`. Empty/absent phone -> `""`.33- A contact is "contactable" if it has a non-empty normalized email OR a non-empty normalized34 phone. A row/badge with neither is a `missing_contact` exclusion.3536## Universal date arithmetic37- `lead_followup_due_date` / lead follow-up = event `end_date` + `followup_days_after_end` days.38- `sponsor_followup_due_date` / sponsor finance follow-up = event `end_date` +39 `sponsor_followup_days_after_end` days.40- Add calendar days to `end_date` (YYYY-MM-DD), output `YYYY-MM-DD`. Use end_date, not start_date.4142---4344# TASK FAMILY A — Post-event sponsor/lead handoff reconciliation45(e.g. `neuralops_2026`, `edgeai_field_2026`)4647Endpoints to combine: `/api/events/{id}` (dates, lead_opportunity_amount, *_days_after_end),48`/api/events/{id}/orders` (= sponsor_packages; sponsor order_status + amount + ticket_contacts),49`/api/finance/invoices?event_id=` (paid/deferred/open per sponsor),50`/api/events/{id}/badges` (attendees/leads), `/api/crm/accounts` (status, disqualified_reason),51`/api/crm/contacts`, `/api/crm/opportunities`, `/api/crm/campaign_members?event_id=`.5253## A1. Sponsor status (controlled enum: paid_deferred | open_invoice | proposal_only | not_sponsor)54For each sponsor ORDER, decide status by combining order_status + invoice status:55- order_status `canceled` -> NOT an active sponsor. Exclude it from `sponsor_statuses`56 entirely, and exclude its ticket contact from leads (reason `inactive_sponsor_record`).57- order_status `proposal_sent` and NO invoice -> `proposal_only` (invoice_id null, paid 0,58 open_balance 0; package_amount = order amount).59- order_status `confirmed` WITH an invoice:60 - invoice.status `paid_deferred` (paid_amount == amount) -> `paid_deferred`61 (open_balance = 0).62 - invoice.status `open` -> `open_invoice`, paid_amount = invoice.paid_amount,63 **open_balance = invoice.amount − invoice.paid_amount** (NOT amount−paid−deferred).64- `package_amount` = the sponsor order/invoice amount (integer USD).65- Sort `sponsor_statuses` by `account_name` ascending.6667## A2. Sponsor revenue totals (when the template asks)68- `paid_deferred` = sum of package amounts of paid_deferred sponsors.69- `open_invoice` = sum of package amounts of open_invoice sponsors (the FULL package amount,70 not the open balance).71- `proposal_only` = sum of package amounts of proposal_only sponsors.72- `open_invoice_balance` = sum of open balances of open_invoice sponsors (amount − paid).7374## A3. Sponsor finance follow-up (LESSON — do X not Y)75- Finance follow-up targets = **all active sponsors that are NOT fully paid**, i.e.76 `open_invoice` AND `proposal_only`. Do NOT restrict it to open_invoice only.77- `paid_deferred` sponsors are settled -> never in finance follow-up.78- `sponsor_finance_task_count` = number of those accounts; list their account_names79 (`sponsor_finance_accounts` / `unpaid_sponsor_account_names`) sorted ascending.80- `unpaid_sponsor_total_usd` = sum of their package amounts (open_invoice amount +81 proposal_only amount), integer USD.82- Due date = sponsor finance follow-up date (see date arithmetic).8384## A4. Qualified non-sponsor leads from badges85Walk badges. A badge becomes a qualified non-sponsor lead only if ALL hold:86- badge_type is a business attendee type (`attendee`). EXCLUDE non-business badges:87 `student`, `press`, and similar -> exclusion reason `non_business_badge`.88- The company is NOT an active sponsor and the contact is NOT a sponsor ticket_contact89 (those are `sponsor_attendee`).90- The matching CRM account is NOT disqualified. A CRM account with status `disqualified`91 / non-null `disqualified_reason` -> exclude reason `existing_disqualified`.92- The badge is contactable (has email or phone); otherwise `missing_contact`.93- Exclusion-reason precedence for a badge that hits several rules: sponsor_attendee >94 inactive_sponsor_record > non_business_badge > existing_disqualified > missing_contact.95 (A canceled-sponsor ticket contact whose CRM account is also disqualified is reported as96 `inactive_sponsor_record` — the more specific sponsor-side reason.)9798For each qualified lead:99- `opportunity_amount` / lead opp = the EVENT's `lead_opportunity_amount` (same for every lead).100- `account_id`: existing CRM account_id if the company already exists in CRM, else null.101- `crm_account_action`: `update_existing` if the account already exists, else `create_account`.102- `crm_contact_action`: `create_contact` when the badge person is a new contact (a different103 existing contact at that account, or an opted-out/suppressed existing contact, does NOT make104 the new badge person an update). Use `update_existing` only when that exact contact already105 exists and is being refreshed.106- `campaign_member_action`: `add_campaign_member` for a brand-new event member.107- `lead_pipeline_total` = (#qualified leads) × lead_opportunity_amount.108109## A5. CRM action counts (the work implied by the handoff)110Count only the lead/handoff work:111- `accounts_create` = new accounts among qualified leads; `accounts_update` = existing accounts.112- `contacts_create` = new badge contacts; `contacts_update` = refreshed existing contacts.113- `campaign_members_create` = new event members added for qualified leads;114 `campaign_members_update` = existing members whose status is being changed.115116## A6. Badge decisions / campaign member actions (richer reconciliation variant, e.g. edgeai)117`badge_decisions` (sort by `badge_id`): each badge -> {classification, crm_action, exclusion_reason}.118- classification enum: `sponsor_attendee` | `qualified_non_sponsor_lead` | `excluded`.119- A sponsor ticket-contact badge -> classification `sponsor_attendee`, exclusion_reason120 `sponsor_attendee`. If that sponsor member already exists and is already correct in the121 campaign -> crm_action `no_action` (keep badge crm_action consistent with the campaign-member122 action for the same person; do not say update when nothing changes).123- A new business attendee -> `qualified_non_sponsor_lead`. crm_action:124 `create_account_contact_campaign_member` when the account is brand new;125 `create_contact_campaign_member` when the account exists but the contact is new;126 `add_campaign_member` when account+contact exist but no member yet.127- non-business badge -> classification `excluded`, crm_action `no_import`,128 exclusion_reason `non_business_badge`. exclusion_reason is `null` for qualified leads.129130`campaign_member_actions` (sort by `subject_key`):131- `subject_key` is an account/CRM-style key (e.g. the `account_id`), NOT the contact name.132 (Using contact names here scored worse.)133- action enum: `create` | `update` | `no_action` | `no_import`.134 target_status enum: `attended_sponsor` | `registered_sponsor` | `attended` | `excluded`.135- Existing member already at the right status -> `no_action` keeping its current target_status136 (attended_sponsor / registered_sponsor). A sponsor with no attendance badge stays137 `registered_sponsor`.138- New qualified non-sponsor lead -> `create`, target_status `attended`.139140`opportunity_summary`:141- `qualified_non_sponsor_account_names` = sorted account names of qualified non-sponsor leads.142- `lead_opportunity_amount_usd` = event lead_opportunity_amount.143- `open_opportunity_total_usd` / `open_opportunity_count` = the NEW lead opportunities created144 for qualified non-sponsor leads: count = #qualified-non-sponsor leads, total = count ×145 lead_opportunity_amount. (Confirmed: this beat using existing CRM open-stage opps = 0.)146147`badge_only_contacts` (sort by `company_name`): one row per qualified non-sponsor (badge-only)148lead with normalized_email / normalized_phone per the universal normalization rules.149150`exclusion_counts`: integer tally per reason key (`sponsor_attendee`, `non_business_badge`,151`existing_disqualified`, `missing_contact`). Count each excluded badge once under its reason.152153NOTE / residual uncertainty (Family A): the handling of a *proposal-only* sponsor's attending154ticket contact (sponsor_attendee vs lead) and the exact target_status for that person were the155hardest calls and may need re-derivation per task; default to treating any sponsor ticket-contact156(even proposal_only) as `sponsor_attendee`.157158---159160# TASK FAMILY B — Trade-show OEM / robotics prospecting (FULLY VALIDATED, score 1.0)161(e.g. `marinesense_2026` dissolved-oxygen sensor, `aquafarm_robotics_2026` aquaculture robotics)162163Endpoints: `/api/tradeshows/{show}/exhibitors` (company_id, name, booth, country, website,164description, crm_account_id), `/api/tradeshows/{show}/meeting_interest`165(interest_score, requested_demo, by company_name), `/api/crm/accounts`, `/api/policies`.166167## B1. Platform classification (enum order ALWAYS: AUV, ROV, Underwater Camera)168Read the exhibitor `description` and assign every target platform the company **makes / OEM-builds**:169- "AUV" / "autonomous underwater vehicle" / "AUV scout" -> AUV.170- "ROV" / "remotely operated" / pen-cleaning/inspection ROV builder -> ROV.171- "underwater camera" / "camera module/array" maker -> Underwater Camera.172- A company can have multiple platforms (e.g. ROVs WITH camera arrays -> [ROV, Underwater Camera]).173- Always emit the platforms list sorted in enum order AUV, ROV, Underwater Camera.174175## B2. Qualify vs exclude (a company must MANUFACTURE/OEM-BUILD a target platform)176Exclude near-misses and give a controlled reason. Two reason vocabularies appear; pick the one177in THIS task's template:178- distributor / reseller / sales agent / "does not manufacture" ->179 reason `distributor_only`; relationship_type `distributor`.180- consulting / operates-rented / service team -> reason `service_only`;181 relationship_type `service_provider`.182- sensor-/probe-only vendor (sells sensors for others to integrate, no platform) ->183 reason `sensor_vendor_only` (marinesense vocab) or `sensor_only` (aquafarm vocab);184 relationship_type `sensor_vendor`.185- research-only org -> `research_only` / relationship_type `research`.186- analytics/software-only "dashboard, no hardware manufacturing" -> treat as service ->187 `service_only` / `service_provider`.188- excluded exhibitors always get crm_action `no_import` and stay visible in the exclusion list.189- Sort exclusion list by `company_name` ascending.190191## B3. CRM action for qualified exhibitors192- If exhibitor `crm_account_id` is non-null (already in CRM) -> crm_action `update_existing`,193 carry that crm_account_id.194- Else -> crm_action `create_account`, crm_account_id null.195- `existing_crm_overlap_*` = qualified leads whose crm_account_id is non-null: count + the196 account_ids sorted ascending.197198## B4. Priority tier + opportunity sizing (demo + interest_score)199- Tier A = requested_demo true AND interest_score >= 90 -> USD 120000.200- Tier B = requested_demo true AND interest_score >= 80 -> USD 90000.201- Tier C = everything else (no demo, or demo with score < 80) -> USD 50000.202- (For marinesense, the same A/B/C demo+score thresholds applied; tier A=demo&>=90, B=demo&>=80.)203- `total_estimated_opportunity_usd` = sum of opportunity_estimate over qualified leads.204- A company with no meeting_interest record = no demo, treat interest_score per template205 (if a score field is required and none exists, it is not demo-requested -> tier C).206207## B5. Ranking (when `rank` is required, e.g. aquafarm)208Sort qualified leads by, in order:2091. requested_demo DESC (demo-requested first),2102. interest_score DESC,2113. broader platform coverage first (more platforms first),2124. company_name ascending.213Then assign 1-based contiguous `rank`. (When no rank field, just sort by company_name ascending.)214215## B6. Aggregate counts216- `qualified_total` / `qualified_lead_count`, `excluded_*_total` / `excluded_count` = list lengths.217- `platform_counts` / `platform_coverage_counts` = per-platform count of qualified leads whose218 platforms include that enum (a 2-platform company increments two buckets). Keys AUV, ROV,219 Underwater Camera, integer values.220- `priority_counts` = count of qualified leads per tier A/B/C.221222---223224# TASK FAMILY C — Raw contact-import hygiene225(e.g. `fall_webinar_import`)226227Endpoints: `/api/import_batches` (campaign_code), `/api/import_batches/{batch}/raw_contacts`,228`/api/import_batches/{batch}/suppression`, `/api/crm/accounts`, `/api/crm/contacts`, `/api/policies`.229230## C1. Per-row pipeline (apply in this order)2311. Normalize email + phone for every raw row (universal rules above).2322. **Suppression**: a row whose normalized email OR normalized phone matches any suppression233 entry is suppressed (reason `suppressed`). Suppression matches on email and on phone234 independently.2353. **Missing contact**: a row with neither a usable email nor phone after normalization is236 unusable (reason `missing_contact`).2374. **Deduplicate** the remaining rows. Dedup KEY = normalized email (the two HelioWare rows238 with different company spellings still collide because email matches; the two Quartz rows239 collide on identical email). Within a duplicate group, choose ONE winner; the others are240 removed with reason `duplicate`. For the duplicate-key entries report241 {key, winner_row_id, removed_row_ids}. Sort duplicate_keys by `key` ascending.242 - Tie-break when timestamps are equal: lowest `row_id` wins.243 - Winner selection by `captured_at` was ambiguous in training (latest-wins and earliest-wins244 both scored the same low number) — see the warning below; pick a single deterministic rule245 (recommend: most-recent `captured_at`, tie -> lowest row_id) and apply it consistently.246247## C2. CRM action per surviving contact (enum: create_account | update_existing | no_import | suppress)248- Account already exists in CRM (match by email domain to an `/api/crm/accounts` domain) ->249 `update_existing`, set existing_account_id (and existing_contact_id if that exact contact250 exists, else null).251- New company -> `create_account`, existing ids null.252- A missing-contact row -> `no_import`; a suppressed row -> `suppress`.253- `clean_contact_id` and `source_row_id` = the winning source row_id. `captured_at`,254 `source_name`, company_name, contact_name come from the WINNING row.255- `source_name` enum: badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form,256 manual_upload.257- Sort `clean_contacts` by `clean_contact_id` ascending.258259## C3. Summaries260- `duplicate_summary.duplicate_removed_count` = number of rows removed as duplicates (losers).261- `removal_summary`: `unusable_removed_count` (missing_contact rows),262 `suppressed_removed_count` (suppressed rows), and `removed_rows` = every removed row as263 {row_id, reason} with reason in {duplicate, missing_contact, suppressed}, sorted by row_id asc.264- `import_action_totals` = integer tally over the per-row crm_action dispositions265 (create_account, update_existing, no_import, suppress).266- `campaign_member_import_count` = number of importable cleaned contacts (the create_account +267 update_existing survivors) to add as members of the batch campaign.268269## C4. WARNING — unresolved membership shape (Family C)270Training could not fully pin down the exact `clean_contacts` membership/field expectations for271this family within the available feedback (multiple plausible models scored identically low).272Two candidate models exist and you must decide per the template wording:273 (a) `clean_contacts` = only the importable survivors (create_account + update_existing);274 suppressed/missing rows live ONLY in `removal_summary`.275 (b) `clean_contacts` = every deduplicated survivor INCLUDING suppressed (`suppress`) and276 missing-contact (`no_import`) rows, each carrying its crm_action; those rows ALSO appear277 in removal_summary.278The crm_action enum containing `suppress` and `no_import` hints toward model (b); but verify279against the template's field_types/descriptions for the specific task and keep280`import_action_totals` consistent with whichever membership you choose. Do NOT assume either281blindly — re-read the template's `clean_contacts.field_types` and `campaign_member_import_count`282description and let them decide.283284---285286# Cross-cutting output discipline (mistakes the feedback punished — do X, not Y)287- DO include open_invoice AND proposal_only sponsors in finance follow-up; do NOT limit to open_invoice.288- DO compute open_balance as invoice.amount − paid_amount; do NOT subtract deferred_amount again.289- DO set open_invoice revenue total to the full package amount; the open money goes in the290 separate `open_invoice_balance` field only.291- DO use lead opportunities (count × lead_opportunity_amount) for `open_opportunity_total/count`;292 do NOT report existing CRM open-stage opps (which were 0) for that field.293- DO use account-id-style `subject_key` in campaign_member_actions; do NOT use contact names.294- DO keep a badge's crm_action consistent with that person's campaign-member action (no_action295 when nothing changes); do NOT mark `update` when the member is already correct.296- DO keep every digit when normalizing phones (including a leading country `1`).297- DO treat a new badge person at an account whose only existing contact is opted-out/suppressed298 as `create_contact`, not update.299- DO sort exactly as the template states, emit integers, and never add undeclared fields.