HarborCRM CRM-Marketing Task Skill
A transferable playbook for solving unseen HarborCRM tasks. HarborCRM is a shared
read-only CRM-marketing dataset exposed over a small JSON API. Tasks always end
with one JSON object conforming to a per-task answer_template.json.
The dataset seeds three task families. Identify which family a task belongs to,
then apply the matching rules below.
| Family |
Trigger in prompt |
Train pattern |
| A. Event sponsor / lead handoff |
"event_id", "post-event", "reconcile", sponsor orders + badges + invoices |
train_001 (simple), train_004 (detailed) |
| B. Trade-show prospecting |
"show_id", "exhibitors", "meeting_interest", "campaign" |
train_002 (simple), train_005 (ranked) |
| C. Contact-import cleaning |
"import batch", "raw_contacts", "suppression", "clean" |
train_003 |
0. Environment & data access
- Base URL is given to you by the runner; if absent, use the documented default.
Only call the public GET endpoints. Never POST / call judge endpoints.
- Endpoints (all return JSON, deterministic dataset):
GET /api/events, /api/events/{id}, /api/events/{id}/orders,
/api/events/{id}/badges, /api/events/{id}/sponsor_packages
GET /api/finance/invoices?event_id={id}
GET /api/crm/accounts, /api/crm/contacts, /api/crm/opportunities,
/api/crm/campaign_members?event_id={id}
GET /api/tradeshows, /api/tradeshows/{id}/exhibitors,
/api/tradeshows/{id}/meeting_interest
GET /api/import_batches, /api/import_batches/{id}/raw_contacts,
/api/import_batches/{id}/suppression
GET /api/policies
/api/events/{id}/orders and /api/events/{id}/sponsor_packages return the
same sponsor-order records; either works.
- Always read
/api/policies first — it carries the controlled enums
(sponsor status, platform enums) and the qualification note.
Workflow for every task:
- Read the prompt → family + the exact ids (event_id / show_id / batch_id).
- Read
/api/policies for enums.
- Fetch every endpoint the prompt names (and the cross-cutting CRM endpoints).
Do not skip CRM/accounts or contacts even if not explicitly listed — you need
them for create-vs-update decisions and disqualified-account exclusions.
- Build the answer with the conventions below; respect the template's exact
key names, enums, ordering, and null rules.
- Output one JSON object only, no prose.
1. Universal conventions
Email normalization
- Trim surrounding whitespace, then lowercase.
- Empty / whitespace-only →
"" (empty string).
- Used for: duplicate keys, CRM contact matching, suppression matching,
qualified-lead
normalized_email, badge-only contact facts.
Phone normalization
- Strip every non-digit character; keep the country code (e.g.
+1 (415) 555-0188 → 14155550188, 1.206.555.0177 → 12065550177, 206.555.0150 → 2065550150).
- Empty / whitespace-only →
"".
- Do NOT reformat, add dashes, or strip a leading
1.
Currency
- All money is integer USD. No decimals, no currency symbol, no commas.
Dates
- All dates are
YYYY-MM-DD strings.
- Follow-up dates are calendar-day addition to the event
end_date:
lead_followup_due_date = end_date + followup_days_after_end
sponsor_followup_due_date = end_date + sponsor_followup_days_after_end
- Both
followup_days_after_end and sponsor_followup_days_after_end are
fields on the event record. Use end_date (not start_date), even for
single-day events where start == end.
Sorting (apply exactly as the template states — order matters for scoring)
- Strings ascending = lexicographic (case-sensitive; these datasets use consistent casing).
- Always sort the final list; do not rely on API order.
- When a template says "sort by X ascending, then Y ascending", apply both keys.
Output discipline
- Return only keys declared in the template. No extra fields, no prose.
- Use the exact enum strings.
null only where the template allows null.
- Empty string
"" is not the same as null — follow each field's rule.
2. Family A — Event sponsor / lead handoff
Two schema variants exist; both share the same engine.
- Simple variant (train_001-like):
sponsor_statuses, sponsor_revenue_totals,
qualified_lead_accounts, lead_pipeline_total, excluded_records,
follow_up, crm_action_counts.
- Detailed variant (train_004-like):
event, sponsor_statuses,
badge_decisions, campaign_member_actions, opportunity_summary,
sponsor_followup, badge_only_contacts, exclusion_counts.
The classification logic below is identical across variants; only the output
shape differs.
2.1 Sponsor status classification
Compute per sponsor order on the event. Active sponsors = orders whose
order_status is confirmed or proposal_sent. Inactive orders
(canceled, no_show, etc.) are not sponsor statuses — their people are
handled via exclusions (§2.4).
For each active sponsor, aggregate all its invoices for the event (a sponsor
may have more than one):
| Condition |
sponsor_status |
No invoice at all (e.g. proposal_sent) |
proposal_only |
| Has invoices, and total open balance > 0 (any unpaid amount) |
open_invoice |
| Has invoices, fully paid (open balance == 0) |
paid_deferred |
Where open_balance = sum(invoice.amount) - sum(invoice.paid_amount) across the
sponsor's invoices. package_amount = the sponsor order amount.
paid_amount = sum of paid_amount across invoices. For proposal_only,
invoice_id = null, paid_amount = 0, open_balance = 0.
- For
open_invoice with multiple invoices, report the open invoice's id as
invoice_id. For a single-invoice sponsor, report that invoice's id.
- A sponsor with one
paid_deferred invoice plus one open invoice is
open_invoice (any unpaid balance wins). A sponsor whose invoices are all
fully paid is paid_deferred.
sponsor_statuses is sorted by account_name ascending.
2.2 Sponsor revenue rollup (simple variant)
sponsor_revenue_totals (integer USD):
paid_deferred = sum of package_amount over sponsors with status paid_deferred.
open_invoice = sum of package_amount over open_invoice sponsors.
proposal_only = sum of package_amount over proposal_only sponsors.
open_invoice_balance = sum of open_balance over open_invoice sponsors
(i.e. total still-owed, not the package amounts).
2.3 Qualified non-sponsor leads
From the event's badge scans, a badge becomes a qualified lead when ALL hold:
badge_type is a business type (attendee; also treat sponsor-typed
badges whose company is an active sponsor as sponsor attendees, not leads).
- The badge's company is not an active sponsor (no confirmed/proposal_sent
order for that company).
- The company is not a disqualified CRM account.
- It is not a non-business badge (see exclusions).
- It has usable contact info (a contact name; treat no-name / no-email-and-no-phone as missing).
Each qualified lead is sized at the event's lead_opportunity_amount (integer
USD, from the event record). Pipeline/opportunity totals = lead_opportunity_amount ×
(number of qualified leads).
lead_pipeline_total (simple) = that product.
opportunity_summary.open_opportunity_total_usd (detailed) = that product;
open_opportunity_count = number of qualified leads;
lead_opportunity_amount_usd = the per-lead amount;
qualified_non_sponsor_account_names = sorted ascending.
2.4 Exclusions (precedence top-down)
Evaluate each badge/contact in this order; the first match wins:
| # |
Condition |
Exclusion reason |
| 1 |
Company is an active sponsor (confirmed/proposal_sent order) |
sponsor_attendee |
| 2 |
Company matches a CRM account with status == "disqualified" |
existing_disqualified |
| 3 |
badge_type is non-business (student, press, academic, etc.) |
non_business_badge |
| 4 |
Company has an inactive sponsor order (canceled/no_show) and is not disqualified |
inactive_sponsor_record |
| 5 |
No contact name, or no email AND no phone |
missing_contact |
Notes:
- A disqualified-account check (rule 2) beats inactive-sponsor (rule 4): a
canceled sponsor whose CRM account is disqualified is
existing_disqualified.
- The sponsor-attendee set (rule 1) is sourced from the sponsor order
ticket_contacts (the primary/first ticket contact per active sponsor) plus
any badge whose badge_type == "sponsor". Deduplicate by
(company_name, contact_name). When a sponsor has an existing campaign member,
that contact is the one used.
non_business_badge = any badge_type that isn't a business attendee
(e.g. student, press).
excluded_records (simple) sorted by company_name asc, then contact_name
asc. exclusion_counts (detailed) counts badges per reason among
sponsor_attendee, non_business_badge, existing_disqualified,
missing_contact.
2.5 CRM create/update decisions (qualified leads)
Match each qualified lead to CRM:
- Account: match the lead's email domain to
accounts[].domain.
- Match found →
crm_account_action = "update_existing", account_id = that id.
- No match →
crm_account_action = "create_account", account_id = null.
- Contact: match the lead's normalized email to
contacts[].email.
- Match found →
crm_contact_action = "update_existing", contact exists.
- No match →
crm_contact_action = "create_contact" (even if the account
exists; a lead contact is usually new).
- Campaign member: qualified leads are new campaign members →
add_campaign_member / create.
Simple variant crm_action_counts:
accounts_create / accounts_update = counts of qualified leads by account action.
contacts_create / contacts_update = counts by contact action.
campaign_members_create = number of qualified leads (all new);
campaign_members_update = 0 (unless a lead already had a CM, which is rare).
2.6 Badge decisions & campaign-member actions (detailed variant)
badge_decisions — one row per badge, sorted by badge_id ascending. Fields:
badge_id, company_name, contact_name, classification, crm_action, exclusion_reason.
classification ∈ {sponsor_attendee, qualified_non_sponsor_lead, excluded}
per §2.4. exclusion_reason is the reason enum or null (null for qualified
leads; sponsor_attendee for sponsor attendees even though they are not
"excluded" in the lead sense).
crm_action (badge-level) — choose by what must be created vs already exists:
| Situation |
crm_action |
| Qualified lead, no existing account |
create_account_contact_campaign_member |
| Qualified lead, existing account, no existing contact |
create_contact_campaign_member |
| Qualified lead, existing account AND contact, no CM |
add_campaign_member |
| Qualified lead already has a CM |
update_campaign_member |
| Sponsor attendee with existing contact + existing CM |
no_action |
| Sponsor attendee, account exists but no contact/CM |
create_contact_campaign_member |
| Excluded (non-business / disqualified / missing) |
no_import |
campaign_member_actions — sorted by subject_key ascending. Two sources:
- Existing campaign members (
/crm/campaign_members?event_id=...):
subject_key = "{account_id}:{contact_id}", action = "no_action",
target_status = the existing CM's status (e.g. attended_sponsor,
registered_sponsor).
- New CMs from badges (qualified leads + sponsor attendees lacking a CM):
subject_key = "badge:{badge_id}", action = "create".
- Qualified non-sponsor lead →
target_status = "attended".
- Sponsor attendee (no existing CM) →
target_status = "attended_sponsor".
- Excluded badges (non-business etc.) get no campaign-member action.
Note acct_…:cont_… sorts before badge:… lexicographically.
2.7 badge_only_contacts (detailed variant)
Normalized contact facts for badge-scanned people who do not already have a
matching CRM contact. Includes qualified leads and sponsor attendees whose
contact must be created. Excludes people who already have a CRM contact and
excluded badges. Sorted by company_name ascending. Fields:
company_name, contact_name, normalized_email, normalized_phone
(email/phone per §1; empty string "" when not supplied).
2.8 Sponsor finance follow-up
Unpaid sponsors for finance follow-up = sponsors whose status is open_invoice
or proposal_only (i.e. not paid_deferred).
unpaid_sponsor_account_names / sponsor_finance_accounts = sorted ascending.
unpaid_sponsor_total_usd (detailed) = sum of open balance for open_invoice
sponsors + sum of package_amount for proposal_only sponsors.
sponsor_finance_task_count (simple) = number of unpaid sponsors.
followup_due_date = sponsor_followup_due_date (§1 dates).
- Lead follow-up:
lead_due_date = lead_followup_due_date;
lead_task_count = number of qualified leads.
3. Family B — Trade-show prospecting
Two schema variants, same engine.
- Simple (train_002-like):
qualified_exhibitors (+ priority_tier),
excluded_near_misses, aggregate_counts.
- Ranked (train_005-like):
summary, ranked_leads (with CRM action,
demo/score, tier, opportunity), excluded_exhibitors.
Platform enums (from policy): AUV, ROV, Underwater Camera.
Always emit platform lists in that enum order.
3.1 Qualification (who builds vs who is adjacent)
Per the policy qualification note: use each exhibitor's description to decide
whether it builds / manufactures / OEM-builds a target platform, or is only
adjacent to them.
- Qualified: description says it builds/manufactures/designs/OEMs one or more
of AUV / ROV / Underwater Camera (e.g. "builds compact AUVs and ROVs",
"designs rugged underwater camera modules", "OEM underwater camera
manufacturer", "manufactures ROVs with camera arrays").
- Excluded (adjacent only), with relationship + reason:
| Relationship |
Reason (simple) |
relationship_type / reason (ranked) |
Description signals |
| Distributor / reseller / dealer |
distributor_only |
distributor / distributor_only |
"distributor", "sales agent", "reseller", "dealer", "does not manufacture" |
| Service provider / consulting |
service_only |
service_provider / service_only |
"consulting", "operates rented", "analytics dashboard … no hardware manufacturing", "service" |
| Sensor-only vendor |
sensor_vendor_only |
sensor_vendor / sensor_only |
"sensor-only", "probes", "sensor vendor", makes sensors but no platform |
| Research / academic |
research_only |
research / research_only |
research institute / lab |
| Off-theme |
not_target_market |
(not in ranked enum) |
doesn't fit campaign theme |
Mind the per-schema labels: the simple schema uses
exclusion_reason ∈ {distributor_only, service_only, sensor_vendor_only, research_only, not_target_market}. The ranked schema uses
relationship_type ∈ {distributor, service_provider, sensor_vendor, research}
and exclusion_reason ∈ {distributor_only, service_only, sensor_only, research_only} (note sensor_only, not sensor_vendor_only). Always copy the
enum from the task's own template.
3.2 Platform assignment
From the qualified exhibitor's description, assign every target platform it
actually builds. Map description terms → enum:
- "AUV" →
AUV
- "ROV" →
ROV
- "underwater camera" / "camera module" / "camera array" (when built) →
Underwater Camera
Sort the list in enum order AUV, ROV, Underwater Camera.
3.3 Priority tier & opportunity sizing
Tier is computed from the exhibitor's meeting_interest record (matched by
company_name). If no meeting-interest record exists for a qualified exhibitor,
treat requested_demo = false and interest_score = 0.
| Tier |
Rule |
Opportunity (ranked schema) |
A |
requested_demo == true AND interest_score >= 90 |
120000 |
B |
requested_demo == true AND interest_score >= 80 (and < 90) |
90000 |
C |
otherwise (no demo, or score < 80) |
50000 |
The simple schema outputs priority_tier only (still computed from
meeting-interest, even though interest_score is not in the output). The ranked
schema also outputs requested_demo, interest_score, and
opportunity_estimate_usd.
3.4 CRM overlap & action (ranked schema)
- A qualified exhibitor with a non-null
crm_account_id (already in CRM) →
crm_action = "update_existing", crm_account_id echoed.
- A qualified exhibitor with
crm_account_id == null →
crm_action = "create_account", crm_account_id = null.
- Excluded exhibitors →
crm_action = "no_import".
summary.existing_crm_overlap_count = number of qualified exhibitors already in
CRM; existing_crm_overlap_account_ids = their account ids sorted ascending.
total_estimated_opportunity_usd = sum of opportunity_estimate_usd over
ranked leads.
3.5 Ranking (ranked schema)
ranked_leads ordered by rank ascending (1-based contiguous). Sort key:
requested_demo = true before false (demo request first).
interest_score descending.
- Broader platform coverage first (more platforms ranks higher).
company_name ascending.
3.6 Ordering for the simple schema
qualified_exhibitors sorted by company_name ascending.
excluded_near_misses sorted by company_name ascending.
- Ranked schema
excluded_exhibitors sorted by company_name ascending.
3.7 Aggregate counts
qualified_total = number of qualified exhibitors.
platform_counts / platform_coverage_counts: for each platform enum, how
many qualified exhibitors cover it (an exhibitor covering two platforms
increments both).
priority_counts (simple): A/B/C counts among qualified.
excluded_near_misses_total / excluded_count = number of excluded exhibitors.
3.8 Required literal values
show_id and campaign (when present) are required_values in the template
— copy them verbatim from the prompt/template, do not invent.
4. Family C — Contact-import cleaning
Output: batch_id, campaign_code, clean_contacts, duplicate_summary,
removal_summary, import_action_totals, campaign_member_import_count.
batch_id = the batch id from the prompt.
campaign_code = campaign_code from /api/import_batches metadata for that
batch.
campaign_member_import_count = number of surviving clean contacts.
4.1 Per-row normalization & fate
For each raw contact row, normalize email (trim+lowercase) and phone
(digits-only). Then classify into exactly one fate, in this precedence:
missing_contact — normalized email is empty AND normalized phone is
empty (no contact channel at all). A row with a phone but no email (or vice
versa) is not missing.
suppressed — the row's normalized email or normalized phone matches
any entry in the batch's /suppression list (match on email if the
suppression email is non-empty; match on phone if the suppression phone is
non-empty). Suppression is checked per-row before dedup, so two rows sharing
a suppressed email are both suppressed.
duplicate — among the remaining rows, group by normalized email
(non-empty). Within a group of >1, pick one winner (§4.2); the rest are
duplicates.
- clean — the winner of each unique-email group, plus any row with no email
(phone-only rows can't be email-deduped and survive as clean if not
suppressed/missing).
Rows removed as duplicate or missing count toward no_import; rows removed as
suppressed count toward suppress; survivors count toward create_account +
update_existing.
4.2 Duplicate winner selection
Duplicate key format: "email:{normalized_email}".
- Primary: latest
captured_at wins (most recent timestamp).
- Tie-break: higher source-trust —
source_name earlier in the enum
[badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form, manual_upload] wins. (The enum is a trust ranking, highest first.)
- Final tie-break: later
row_id wins.
The winner's row_id becomes both clean_contact_id and source_row_id; the
winner's captured_at, source_name, company_name, contact_name,
normalized email/phone are used in the clean contact.
duplicate_summary: duplicate_removed_count = total loser rows;
duplicate_keys sorted by key ascending, each with key, winner_row_id,
removed_row_ids (the loser row_ids of that group).
4.3 Clean contact CRM action
For each surviving clean contact:
- Match the contact's email domain to
accounts[].domain.
- Match →
crm_action = "update_existing", existing_account_id = that id.
- No match →
crm_action = "create_account", existing_account_id = null.
- Match the contact's normalized email to
contacts[].email.
- Match →
existing_contact_id = that contact id.
- No match →
existing_contact_id = null.
source_name is the winner row's source_name (enum above).
email / phone are the normalized values (empty string "" if absent).
import_action_totals: counts of clean contacts by crm_action
(create_account, update_existing) plus no_import (duplicate + missing
rows) plus suppress (suppressed rows). These four sum to the raw row count.
4.4 Removal summary & ordering
removal_summary:
unusable_removed_count = number of missing_contact rows only (NOT
including duplicates).
suppressed_removed_count = number of suppressed rows.
removed_rows = all removed rows (duplicates + missing + suppressed),
sorted by row_id ascending, each {row_id, reason} with
reason ∈ {duplicate, missing_contact, suppressed}.
clean_contacts sorted by clean_contact_id ascending.
5. Common pitfalls
- Don't skip CRM endpoints. Disqualified-account exclusion, contact
create-vs-update, and CRM-overlap all need
/api/crm/accounts and
/api/crm/contacts even when the prompt doesn't list them.
- Don't forget campaign members. They drive
no_action rows in the detailed
event variant and confirm which sponsor contact is "primary".
- Multiple invoices per sponsor exist. Classify by aggregate open balance,
not by the first invoice.
no_show / canceled orders are not active sponsors. Exclude them from
sponsor_statuses; route their attendees through the exclusion precedence.
- Phone keeps the country code. Just strip non-digits.
- Empty string vs null. Normalized email/phone use
"" when absent;
account_id/invoice_id use null when absent. Follow each field's rule.
- Per-schema enum labels differ (e.g.
sensor_vendor_only vs sensor_only).
Always copy enums from the task's own answer_template.json.
- Tier needs meeting-interest even when not output. The simple trade-show
schema outputs
priority_tier but not interest_score; you still must fetch
meeting_interest to compute the tier.
- Sort every list. Many points are lost to unsorted or wrongly-sorted arrays.
- One JSON object, no prose.
1---2name: fewshot-attempt-023description: HarborCRM CRM-Marketing Task Skill4---5# HarborCRM CRM-Marketing Task Skill67A transferable playbook for solving unseen HarborCRM tasks. HarborCRM is a shared8read-only CRM-marketing dataset exposed over a small JSON API. Tasks always end9with **one JSON object** conforming to a per-task `answer_template.json`.1011The dataset seeds three task families. Identify which family a task belongs to,12then apply the matching rules below.1314| Family | Trigger in prompt | Train pattern |15|---|---|---|16| **A. Event sponsor / lead handoff** | "event_id", "post-event", "reconcile", sponsor orders + badges + invoices | train_001 (simple), train_004 (detailed) |17| **B. Trade-show prospecting** | "show_id", "exhibitors", "meeting_interest", "campaign" | train_002 (simple), train_005 (ranked) |18| **C. Contact-import cleaning** | "import batch", "raw_contacts", "suppression", "clean" | train_003 |1920---2122## 0. Environment & data access2324- Base URL is given to you by the runner; if absent, use the documented default.25 Only call the public GET endpoints. Never POST / call judge endpoints.26- Endpoints (all return JSON, deterministic dataset):27 - `GET /api/events`, `/api/events/{id}`, `/api/events/{id}/orders`,28 `/api/events/{id}/badges`, `/api/events/{id}/sponsor_packages`29 - `GET /api/finance/invoices?event_id={id}`30 - `GET /api/crm/accounts`, `/api/crm/contacts`, `/api/crm/opportunities`,31 `/api/crm/campaign_members?event_id={id}`32 - `GET /api/tradeshows`, `/api/tradeshows/{id}/exhibitors`,33 `/api/tradeshows/{id}/meeting_interest`34 - `GET /api/import_batches`, `/api/import_batches/{id}/raw_contacts`,35 `/api/import_batches/{id}/suppression`36 - `GET /api/policies`37- `/api/events/{id}/orders` and `/api/events/{id}/sponsor_packages` return the38 **same** sponsor-order records; either works.39- Always read `/api/policies` first — it carries the controlled enums40 (sponsor status, platform enums) and the qualification note.4142**Workflow for every task:**431. Read the prompt → family + the exact ids (event_id / show_id / batch_id).442. Read `/api/policies` for enums.453. Fetch every endpoint the prompt names (and the cross-cutting CRM endpoints).46 Do not skip CRM/accounts or contacts even if not explicitly listed — you need47 them for create-vs-update decisions and disqualified-account exclusions.484. Build the answer with the conventions below; respect the template's exact49 key names, enums, ordering, and null rules.505. Output **one JSON object only**, no prose.5152---5354## 1. Universal conventions5556### Email normalization57- Trim surrounding whitespace, then lowercase.58- Empty / whitespace-only → `""` (empty string).59- Used for: duplicate keys, CRM contact matching, suppression matching,60 qualified-lead `normalized_email`, badge-only contact facts.6162### Phone normalization63- Strip every non-digit character; keep the country code (e.g. `+1 (415) 555-0188` → `14155550188`, `1.206.555.0177` → `12065550177`, `206.555.0150` → `2065550150`).64- Empty / whitespace-only → `""`.65- Do NOT reformat, add dashes, or strip a leading `1`.6667### Currency68- All money is integer USD. No decimals, no currency symbol, no commas.6970### Dates71- All dates are `YYYY-MM-DD` strings.72- Follow-up dates are **calendar-day addition** to the event `end_date`:73 - `lead_followup_due_date = end_date + followup_days_after_end`74 - `sponsor_followup_due_date = end_date + sponsor_followup_days_after_end`75 - Both `followup_days_after_end` and `sponsor_followup_days_after_end` are76 fields on the event record. Use `end_date` (not start_date), even for77 single-day events where start == end.7879### Sorting (apply exactly as the template states — order matters for scoring)80- Strings ascending = lexicographic (case-sensitive; these datasets use consistent casing).81- Always sort the final list; do not rely on API order.82- When a template says "sort by X ascending, then Y ascending", apply both keys.8384### Output discipline85- Return only keys declared in the template. No extra fields, no prose.86- Use the exact enum strings. `null` only where the template allows null.87- Empty string `""` is not the same as `null` — follow each field's rule.8889---9091## 2. Family A — Event sponsor / lead handoff9293Two schema variants exist; both share the same engine.9495- **Simple variant** (train_001-like): `sponsor_statuses`, `sponsor_revenue_totals`,96 `qualified_lead_accounts`, `lead_pipeline_total`, `excluded_records`,97 `follow_up`, `crm_action_counts`.98- **Detailed variant** (train_004-like): `event`, `sponsor_statuses`,99 `badge_decisions`, `campaign_member_actions`, `opportunity_summary`,100 `sponsor_followup`, `badge_only_contacts`, `exclusion_counts`.101102The classification logic below is identical across variants; only the output103shape differs.104105### 2.1 Sponsor status classification106107Compute per sponsor order on the event. **Active sponsors** = orders whose108`order_status` is `confirmed` or `proposal_sent`. Inactive orders109(`canceled`, `no_show`, etc.) are **not** sponsor statuses — their people are110handled via exclusions (§2.4).111112For each active sponsor, aggregate **all** its invoices for the event (a sponsor113may have more than one):114115| Condition | `sponsor_status` |116|---|---|117| No invoice at all (e.g. `proposal_sent`) | `proposal_only` |118| Has invoices, and total open balance > 0 (any unpaid amount) | `open_invoice` |119| Has invoices, fully paid (open balance == 0) | `paid_deferred` |120121Where `open_balance = sum(invoice.amount) - sum(invoice.paid_amount)` across the122sponsor's invoices. `package_amount` = the sponsor **order** `amount`.123`paid_amount` = sum of `paid_amount` across invoices. For `proposal_only`,124`invoice_id = null`, `paid_amount = 0`, `open_balance = 0`.125126- For `open_invoice` with multiple invoices, report the **open** invoice's id as127 `invoice_id`. For a single-invoice sponsor, report that invoice's id.128- A sponsor with one `paid_deferred` invoice plus one `open` invoice is129 `open_invoice` (any unpaid balance wins). A sponsor whose invoices are all130 fully paid is `paid_deferred`.131132`sponsor_statuses` is sorted by `account_name` ascending.133134### 2.2 Sponsor revenue rollup (simple variant)135136`sponsor_revenue_totals` (integer USD):137- `paid_deferred` = sum of `package_amount` over sponsors with status `paid_deferred`.138- `open_invoice` = sum of `package_amount` over `open_invoice` sponsors.139- `proposal_only` = sum of `package_amount` over `proposal_only` sponsors.140- `open_invoice_balance` = sum of `open_balance` over `open_invoice` sponsors141 (i.e. total still-owed, not the package amounts).142143### 2.3 Qualified non-sponsor leads144145From the event's **badge scans**, a badge becomes a qualified lead when ALL hold:1461. `badge_type` is a business type (`attendee`; also treat `sponsor`-typed147 badges whose company is an active sponsor as sponsor attendees, not leads).1482. The badge's company is **not** an active sponsor (no confirmed/proposal_sent149 order for that company).1503. The company is **not** a disqualified CRM account.1514. It is not a non-business badge (see exclusions).1525. It has usable contact info (a contact name; treat no-name / no-email-and-no-phone as missing).153154Each qualified lead is sized at the event's `lead_opportunity_amount` (integer155USD, from the event record). Pipeline/opportunity totals = `lead_opportunity_amount` ×156(number of qualified leads).157158- `lead_pipeline_total` (simple) = that product.159- `opportunity_summary.open_opportunity_total_usd` (detailed) = that product;160 `open_opportunity_count` = number of qualified leads;161 `lead_opportunity_amount_usd` = the per-lead amount;162 `qualified_non_sponsor_account_names` = sorted ascending.163164### 2.4 Exclusions (precedence top-down)165166Evaluate each badge/contact in this order; the first match wins:167168| # | Condition | Exclusion reason |169|---|---|---|170| 1 | Company is an **active sponsor** (confirmed/proposal_sent order) | `sponsor_attendee` |171| 2 | Company matches a CRM account with `status == "disqualified"` | `existing_disqualified` |172| 3 | `badge_type` is non-business (`student`, `press`, `academic`, etc.) | `non_business_badge` |173| 4 | Company has an **inactive** sponsor order (`canceled`/`no_show`) and is not disqualified | `inactive_sponsor_record` |174| 5 | No contact name, or no email AND no phone | `missing_contact` |175176Notes:177- A disqualified-account check (rule 2) beats inactive-sponsor (rule 4): a178 canceled sponsor whose CRM account is disqualified is `existing_disqualified`.179- The sponsor-attendee set (rule 1) is sourced from the sponsor **order180 `ticket_contacts`** (the primary/first ticket contact per active sponsor) plus181 any badge whose `badge_type == "sponsor"`. Deduplicate by182 (company_name, contact_name). When a sponsor has an existing campaign member,183 that contact is the one used.184- `non_business_badge` = any `badge_type` that isn't a business attendee185 (e.g. `student`, `press`).186187`excluded_records` (simple) sorted by `company_name` asc, then `contact_name`188asc. `exclusion_counts` (detailed) counts badges per reason among189`sponsor_attendee`, `non_business_badge`, `existing_disqualified`,190`missing_contact`.191192### 2.5 CRM create/update decisions (qualified leads)193194Match each qualified lead to CRM:195- **Account**: match the lead's email domain to `accounts[].domain`.196 - Match found → `crm_account_action = "update_existing"`, `account_id` = that id.197 - No match → `crm_account_action = "create_account"`, `account_id = null`.198- **Contact**: match the lead's normalized email to `contacts[].email`.199 - Match found → `crm_contact_action = "update_existing"`, contact exists.200 - No match → `crm_contact_action = "create_contact"` (even if the account201 exists; a lead contact is usually new).202- **Campaign member**: qualified leads are new campaign members →203 `add_campaign_member` / `create`.204205Simple variant `crm_action_counts`:206- `accounts_create` / `accounts_update` = counts of qualified leads by account action.207- `contacts_create` / `contacts_update` = counts by contact action.208- `campaign_members_create` = number of qualified leads (all new);209 `campaign_members_update` = 0 (unless a lead already had a CM, which is rare).210211### 2.6 Badge decisions & campaign-member actions (detailed variant)212213`badge_decisions` — one row per badge, sorted by `badge_id` ascending. Fields:214`badge_id, company_name, contact_name, classification, crm_action, exclusion_reason`.215216`classification` ∈ {`sponsor_attendee`, `qualified_non_sponsor_lead`, `excluded`}217per §2.4. `exclusion_reason` is the reason enum or `null` (null for qualified218leads; `sponsor_attendee` for sponsor attendees even though they are not219"excluded" in the lead sense).220221`crm_action` (badge-level) — choose by what must be created vs already exists:222223| Situation | `crm_action` |224|---|---|225| Qualified lead, no existing account | `create_account_contact_campaign_member` |226| Qualified lead, existing account, no existing contact | `create_contact_campaign_member` |227| Qualified lead, existing account AND contact, no CM | `add_campaign_member` |228| Qualified lead already has a CM | `update_campaign_member` |229| Sponsor attendee with existing contact + existing CM | `no_action` |230| Sponsor attendee, account exists but no contact/CM | `create_contact_campaign_member` |231| Excluded (non-business / disqualified / missing) | `no_import` |232233`campaign_member_actions` — sorted by `subject_key` ascending. Two sources:2341. **Existing campaign members** (`/crm/campaign_members?event_id=...`):235 `subject_key = "{account_id}:{contact_id}"`, `action = "no_action"`,236 `target_status` = the existing CM's `status` (e.g. `attended_sponsor`,237 `registered_sponsor`).2382. **New CMs from badges** (qualified leads + sponsor attendees lacking a CM):239 `subject_key = "badge:{badge_id}"`, `action = "create"`.240 - Qualified non-sponsor lead → `target_status = "attended"`.241 - Sponsor attendee (no existing CM) → `target_status = "attended_sponsor"`.242 - Excluded badges (non-business etc.) get **no** campaign-member action.243244Note `acct_…:cont_…` sorts before `badge:…` lexicographically.245246### 2.7 badge_only_contacts (detailed variant)247248Normalized contact facts for badge-scanned people who do **not** already have a249matching CRM contact. Includes qualified leads and sponsor attendees whose250contact must be created. Excludes people who already have a CRM contact and251excluded badges. Sorted by `company_name` ascending. Fields:252`company_name, contact_name, normalized_email, normalized_phone`253(email/phone per §1; empty string `""` when not supplied).254255### 2.8 Sponsor finance follow-up256257Unpaid sponsors for finance follow-up = sponsors whose status is `open_invoice`258**or** `proposal_only` (i.e. not `paid_deferred`).259- `unpaid_sponsor_account_names` / `sponsor_finance_accounts` = sorted ascending.260- `unpaid_sponsor_total_usd` (detailed) = sum of open balance for `open_invoice`261 sponsors + sum of `package_amount` for `proposal_only` sponsors.262- `sponsor_finance_task_count` (simple) = number of unpaid sponsors.263- `followup_due_date` = `sponsor_followup_due_date` (§1 dates).264- Lead follow-up: `lead_due_date` = `lead_followup_due_date`;265 `lead_task_count` = number of qualified leads.266267---268269## 3. Family B — Trade-show prospecting270271Two schema variants, same engine.272273- **Simple** (train_002-like): `qualified_exhibitors` (+ `priority_tier`),274 `excluded_near_misses`, `aggregate_counts`.275- **Ranked** (train_005-like): `summary`, `ranked_leads` (with CRM action,276 demo/score, tier, opportunity), `excluded_exhibitors`.277278Platform enums (from policy): `AUV`, `ROV`, `Underwater Camera`.279Always emit platform lists in that enum order.280281### 3.1 Qualification (who builds vs who is adjacent)282283Per the policy qualification note: use each exhibitor's `description` to decide284whether it **builds / manufactures / OEM-builds** a target platform, or is only285**adjacent** to them.286287- **Qualified**: description says it builds/manufactures/designs/OEMs one or more288 of AUV / ROV / Underwater Camera (e.g. "builds compact AUVs and ROVs",289 "designs rugged underwater camera modules", "OEM underwater camera290 manufacturer", "manufactures ROVs with camera arrays").291- **Excluded** (adjacent only), with relationship + reason:292293| Relationship | Reason (simple) | relationship_type / reason (ranked) | Description signals |294|---|---|---|---|295| Distributor / reseller / dealer | `distributor_only` | `distributor` / `distributor_only` | "distributor", "sales agent", "reseller", "dealer", "does not manufacture" |296| Service provider / consulting | `service_only` | `service_provider` / `service_only` | "consulting", "operates rented", "analytics dashboard … no hardware manufacturing", "service" |297| Sensor-only vendor | `sensor_vendor_only` | `sensor_vendor` / `sensor_only` | "sensor-only", "probes", "sensor vendor", makes sensors but no platform |298| Research / academic | `research_only` | `research` / `research_only` | research institute / lab |299| Off-theme | `not_target_market` | (not in ranked enum) | doesn't fit campaign theme |300301**Mind the per-schema labels**: the simple schema uses302`exclusion_reason ∈ {distributor_only, service_only, sensor_vendor_only,303research_only, not_target_market}`. The ranked schema uses304`relationship_type ∈ {distributor, service_provider, sensor_vendor, research}`305**and** `exclusion_reason ∈ {distributor_only, service_only, sensor_only,306research_only}` (note `sensor_only`, not `sensor_vendor_only`). Always copy the307enum from the task's own template.308309### 3.2 Platform assignment310311From the qualified exhibitor's description, assign every target platform it312actually builds. Map description terms → enum:313- "AUV" → `AUV`314- "ROV" → `ROV`315- "underwater camera" / "camera module" / "camera array" (when built) → `Underwater Camera`316Sort the list in enum order `AUV, ROV, Underwater Camera`.317318### 3.3 Priority tier & opportunity sizing319320Tier is computed from the exhibitor's `meeting_interest` record (matched by321`company_name`). If no meeting-interest record exists for a qualified exhibitor,322treat `requested_demo = false` and `interest_score = 0`.323324| Tier | Rule | Opportunity (ranked schema) |325|---|---|---|326| `A` | `requested_demo == true` AND `interest_score >= 90` | 120000 |327| `B` | `requested_demo == true` AND `interest_score >= 80` (and < 90) | 90000 |328| `C` | otherwise (no demo, or score < 80) | 50000 |329330The simple schema outputs `priority_tier` only (still computed from331meeting-interest, even though `interest_score` is not in the output). The ranked332schema also outputs `requested_demo`, `interest_score`, and333`opportunity_estimate_usd`.334335### 3.4 CRM overlap & action (ranked schema)336337- A qualified exhibitor with a non-null `crm_account_id` (already in CRM) →338 `crm_action = "update_existing"`, `crm_account_id` echoed.339- A qualified exhibitor with `crm_account_id == null` →340 `crm_action = "create_account"`, `crm_account_id = null`.341- Excluded exhibitors → `crm_action = "no_import"`.342343`summary.existing_crm_overlap_count` = number of qualified exhibitors already in344CRM; `existing_crm_overlap_account_ids` = their account ids sorted ascending.345`total_estimated_opportunity_usd` = sum of `opportunity_estimate_usd` over346ranked leads.347348### 3.5 Ranking (ranked schema)349350`ranked_leads` ordered by `rank` ascending (1-based contiguous). Sort key:3511. `requested_demo` = true before false (demo request first).3522. `interest_score` descending.3533. Broader platform coverage first (more platforms ranks higher).3544. `company_name` ascending.355356### 3.6 Ordering for the simple schema357358- `qualified_exhibitors` sorted by `company_name` ascending.359- `excluded_near_misses` sorted by `company_name` ascending.360- Ranked schema `excluded_exhibitors` sorted by `company_name` ascending.361362### 3.7 Aggregate counts363364- `qualified_total` = number of qualified exhibitors.365- `platform_counts` / `platform_coverage_counts`: for each platform enum, how366 many **qualified** exhibitors cover it (an exhibitor covering two platforms367 increments both).368- `priority_counts` (simple): A/B/C counts among qualified.369- `excluded_near_misses_total` / `excluded_count` = number of excluded exhibitors.370371### 3.8 Required literal values372373- `show_id` and `campaign` (when present) are `required_value`s in the template374 — copy them verbatim from the prompt/template, do not invent.375376---377378## 4. Family C — Contact-import cleaning379380Output: `batch_id`, `campaign_code`, `clean_contacts`, `duplicate_summary`,381`removal_summary`, `import_action_totals`, `campaign_member_import_count`.382383- `batch_id` = the batch id from the prompt.384- `campaign_code` = `campaign_code` from `/api/import_batches` metadata for that385 batch.386- `campaign_member_import_count` = number of surviving clean contacts.387388### 4.1 Per-row normalization & fate389390For each raw contact row, normalize `email` (trim+lowercase) and `phone`391(digits-only). Then classify into exactly one fate, in this precedence:3923931. **`missing_contact`** — normalized email is empty AND normalized phone is394 empty (no contact channel at all). A row with a phone but no email (or vice395 versa) is **not** missing.3962. **`suppressed`** — the row's normalized email **or** normalized phone matches397 any entry in the batch's `/suppression` list (match on email if the398 suppression email is non-empty; match on phone if the suppression phone is399 non-empty). Suppression is checked per-row before dedup, so two rows sharing400 a suppressed email are both suppressed.4013. **`duplicate`** — among the remaining rows, group by normalized email402 (non-empty). Within a group of >1, pick one **winner** (§4.2); the rest are403 duplicates.4044. **clean** — the winner of each unique-email group, plus any row with no email405 (phone-only rows can't be email-deduped and survive as clean if not406 suppressed/missing).407408Rows removed as duplicate or missing count toward `no_import`; rows removed as409suppressed count toward `suppress`; survivors count toward `create_account` +410`update_existing`.411412### 4.2 Duplicate winner selection413414Duplicate key format: `"email:{normalized_email}"`.415- **Primary: latest `captured_at` wins** (most recent timestamp).416- **Tie-break: higher source-trust** — `source_name` earlier in the enum417 `[badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form,418 manual_upload]` wins. (The enum is a trust ranking, highest first.)419- **Final tie-break: later `row_id`** wins.420421The winner's `row_id` becomes both `clean_contact_id` and `source_row_id`; the422winner's `captured_at`, `source_name`, `company_name`, `contact_name`,423normalized email/phone are used in the clean contact.424425`duplicate_summary`: `duplicate_removed_count` = total loser rows;426`duplicate_keys` sorted by `key` ascending, each with `key`, `winner_row_id`,427`removed_row_ids` (the loser row_ids of that group).428429### 4.3 Clean contact CRM action430431For each surviving clean contact:432- Match the contact's email domain to `accounts[].domain`.433 - Match → `crm_action = "update_existing"`, `existing_account_id` = that id.434 - No match → `crm_action = "create_account"`, `existing_account_id = null`.435- Match the contact's normalized email to `contacts[].email`.436 - Match → `existing_contact_id` = that contact id.437 - No match → `existing_contact_id = null`.438- `source_name` is the winner row's `source_name` (enum above).439- `email` / `phone` are the normalized values (empty string `""` if absent).440441`import_action_totals`: counts of clean contacts by `crm_action`442(`create_account`, `update_existing`) plus `no_import` (duplicate + missing443rows) plus `suppress` (suppressed rows). These four sum to the raw row count.444445### 4.4 Removal summary & ordering446447`removal_summary`:448- `unusable_removed_count` = number of `missing_contact` rows only (NOT449 including duplicates).450- `suppressed_removed_count` = number of `suppressed` rows.451- `removed_rows` = **all** removed rows (duplicates + missing + suppressed),452 sorted by `row_id` ascending, each `{row_id, reason}` with453 `reason ∈ {duplicate, missing_contact, suppressed}`.454455`clean_contacts` sorted by `clean_contact_id` ascending.456457---458459## 5. Common pitfalls460461- **Don't skip CRM endpoints.** Disqualified-account exclusion, contact462 create-vs-update, and CRM-overlap all need `/api/crm/accounts` and463 `/api/crm/contacts` even when the prompt doesn't list them.464- **Don't forget campaign members.** They drive `no_action` rows in the detailed465 event variant and confirm which sponsor contact is "primary".466- **Multiple invoices per sponsor exist.** Classify by aggregate open balance,467 not by the first invoice.468- **`no_show` / `canceled` orders are not active sponsors.** Exclude them from469 `sponsor_statuses`; route their attendees through the exclusion precedence.470- **Phone keeps the country code.** Just strip non-digits.471- **Empty string vs null.** Normalized email/phone use `""` when absent;472 `account_id`/`invoice_id` use `null` when absent. Follow each field's rule.473- **Per-schema enum labels differ** (e.g. `sensor_vendor_only` vs `sensor_only`).474 Always copy enums from the task's own `answer_template.json`.475- **Tier needs meeting-interest even when not output.** The simple trade-show476 schema outputs `priority_tier` but not `interest_score`; you still must fetch477 `meeting_interest` to compute the tier.478- **Sort every list.** Many points are lost to unsorted or wrongly-sorted arrays.479- **One JSON object, no prose.**