HarborCRM CRM-Marketing Task Skill
Solve HarborCRM post-event / prospecting / import-batch tasks against the shared
read-only HarborCRM API. Produce ONE JSON object matching the task's
answer_template.json — exactly its declared fields, nothing extra, no prose.
0. Environment
- Base URL:
<remote-env-url> (use as the ONLY data source).
- Call with
curl -s <BASE><endpoint> | python3 -m json.tool.
- Dataset is deterministic (seed 41001). All endpoints return JSON.
Public GET endpoints
GET /api/policies — authoritative rule constants (platform enums, sponsor status enums).
GET /api/events and GET /api/events/{event_id}
GET /api/events/{event_id}/orders (== sponsor packages)
GET /api/events/{event_id}/badges
GET /api/events/{event_id}/sponsor_packages
GET /api/finance/invoices?event_id={event_id}
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
Authoritative policies (/api/policies)
prospecting.platform_enums = ["AUV", "ROV", "Underwater Camera"]
(always emit platform lists in THIS order).
sponsor_handoff.status_enums = ["paid_deferred", "open_invoice", "proposal_only", "not_sponsor"].
contact_hygiene.note: normalize/contactable records before CRM import.
1. Universal output rules
- Return one JSON object only. No prose. No fields beyond the template.
- Money: integer USD. Counts: integers. Dates:
YYYY-MM-DD.
- Sort every list exactly as the template states (usually by name/id ascending).
null for absent ids (e.g. new account has account_id: null); empty string ""
for absent email/phone when the field allows it.
- Normalization: email → lowercase + trimmed; phone → digits only (keep all digits
present in the source, including a leading country code if given).
2. Event reconciliation tasks (post-event sponsor + lead handoff)
Applies to tasks that reconcile one event_id: sponsor statuses, qualified lead
accounts, excluded records, follow-up, CRM action counts (and the badge-level
variant: badge decisions, campaign-member actions, opportunity summary).
Event facts (from /api/events/{event_id})
lead_followup_due_date = end_date + followup_days_after_end days.
sponsor_followup_due_date = end_date + sponsor_followup_days_after_end days.
lead_opportunity_amount = per-qualified-lead opportunity amount for this event.
Sponsor statuses
- Active sponsors = sponsor packages whose
order_status is confirmed OR
proposal_sent. Exclude canceled sponsors entirely from sponsor_statuses
(they go to excluded records as inactive_sponsor_record).
- Derive each active sponsor's status from its finance invoice:
- invoice
status=="paid_deferred" → paid_deferred
- invoice
status=="open" → open_invoice
- NO invoice (proposal_sent only) →
proposal_only
- Per-sponsor fields:
package_amount = sponsor package amount;
invoice_id (null if none); paid_amount (0 if none);
open_balance = invoice amount − paid_amount (0 if none).
- Revenue totals: sum the FULL deal amount per status
(
paid_deferred, open_invoice, proposal_only). open_invoice_balance =
sum of open balances across open-invoice sponsors (reported separately).
- Sort
sponsor_statuses by account_name ascending.
Qualified non-sponsor lead accounts
A badge qualifies as a lead when ALL hold:
badge_type is a business attendee type (NOT sponsor, NOT student, NOT press).
student/press → excluded (non_business_badge).
- The badge's company is NOT an active sponsor (confirmed or proposal_sent).
- A
badge_type:"sponsor" badge → excluded (sponsor_attendee).
- A badge whose company is an active sponsor (even proposal_only) and whose
contact matches a sponsor
ticket_contact → sponsor_attendee (NOT a lead),
even when the badge_type itself is attendee.
- The badge's company is NOT a canceled sponsor → else
inactive_sponsor_record.
- The CRM account for the company is NOT disqualified → else
existing_disqualified.
- Contactability: a lead with a phone but NO email is STILL qualified (use empty
email).
missing_contact only when there is neither email nor phone.
- Qualified leads get
opportunity_amount = event lead_opportunity_amount.
- CRM actions per qualified lead:
crm_account_action: create_account if no matching CRM account (by email
domain), else update_existing.
crm_contact_action: create_contact if the badge contact is a NEW person
(not an existing CRM contact), else update_existing. A new person on an
existing account is STILL create_contact (do not mirror account action).
campaign_member_action: add_campaign_member (qualified leads get a new
member; excluded leads get NO campaign-member action).
lead_pipeline_total / open_opportunity_total_usd =
lead_opportunity_amount × number of qualified leads.
- Sort qualified leads by
account_name ascending.
Excluded records
Reasons (controlled): sponsor_attendee, inactive_sponsor_record,
non_business_badge, existing_disqualified, missing_contact.
sponsor_attendee: badge_type sponsor, OR attendee whose company is an
active sponsor and contact is a sponsor ticket_contact.
inactive_sponsor_record: company had a canceled sponsor order.
non_business_badge: student or press badge types.
existing_disqualified: CRM account status=="disqualified".
missing_contact: no email AND no phone.
- When a record matches more than one rule,
inactive_sponsor_record is preferred
for canceled-sponsor companies; existing_disqualified for non-sponsor
disqualified accounts. (Both reason values were accepted for a record that was
both canceled-sponsor and disqualified — prefer inactive_sponsor_record.)
- Sort excluded by
company_name, then contact_name.
Follow-up
lead_task_count = number of qualified lead accounts.
sponsor_finance_accounts (or unpaid_sponsor_account_names) =
active sponsors that are NOT fully paid = open_invoice + proposal_only
(exclude paid_deferred, which is fully collected; exclude canceled).
sponsor_finance_task_count / count = number of those accounts.
unpaid_sponsor_total_usd = sum of unpaid amounts (open balance for invoiced
sponsors; full package amount for proposal_only sponsors with no payment).
CRM action counts (lead handoff only)
Tally only the work implied for QUALIFIED leads (excluded records imply no action):
accounts_create / accounts_update, contacts_create / contacts_update,
campaign_members_create / campaign_members_update (update is 0 unless an
excluded lead's existing member is being re-statused — generally 0).
3. Badge-level reconciliation variant (per-badge decisions + campaign members)
Same sponsor/lead/exclusion logic as §2, plus:
badge_decisions (sorted by badge_id)
classification: sponsor_attendee | qualified_non_sponsor_lead | excluded.
crm_action:
- new account+contact+member →
create_account_contact_campaign_member
- existing account, new contact+member →
create_contact_campaign_member
- existing account+contact, new member →
add_campaign_member
- existing member needing change →
update_campaign_member
- sponsor attendee / already-correct →
no_action
- excluded (non-business / disqualified / missing) →
no_import
exclusion_reason: null for qualified; the controlled reason otherwise.
campaign_member_actions (sorted by subject_key ascending)
action: create | update | no_action | no_import.
target_status: attended_sponsor | registered_sponsor | attended | excluded.
- Qualified lead (no member) who scanned a badge →
create, attended.
- Existing sponsor member already correct (e.g.
attended_sponsor) → no_action,
same status. Sponsor who registered but did not scan → no_action,
registered_sponsor.
- Excluded badge →
no_import, excluded.
- Include existing sponsor campaign members (with
no_action) even if they have no
badge scan, so their disposition is represented. Use the CRM contact_id as
subject_key for existing contacts; use the normalized email (or badge id when
no email) for new contacts.
exclusion_counts: tally of sponsor_attendee, non_business_badge,
existing_disqualified, missing_contact.
opportunity_summary
qualified_non_sponsor_account_names: sorted ascending.
lead_opportunity_amount_usd: event lead_opportunity_amount.
open_opportunity_total_usd = lead_opportunity_amount × #qualified.
open_opportunity_count = #qualified.
badge_only_contacts (sorted by company_name)
Normalized contact facts for qualified non-sponsor leads:
company_name, contact_name, normalized_email (lowercase, "" allowed),
normalized_phone (digits only, "" allowed).
4. Prospecting tasks (tradeshow exhibitor qualification)
Applies to /api/tradeshows/{show_id}/exhibitors + meeting_interest tasks.
Qualification
- Qualified = exhibitor that BUILDS / MANUFACTURES / DESIGNS / OEM-builds a target
platform (
AUV, ROV, Underwater Camera). Read the description.
- "Builds AUVs and ROVs" →
[AUV, ROV].
- "Manufactures ROVs with camera arrays" →
[ROV, Underwater Camera] (camera
arrays count as building underwater cameras).
- "Designs underwater camera modules" →
[Underwater Camera].
- Excluded (adjacent, not a platform builder):
- distributor / reseller →
distributor_only (relationship_type distributor)
- service / consulting / analytics-only →
service_only (service_provider)
- sensor-only vendor →
sensor_vendor_only / sensor_only (sensor_vendor)
- research-only →
research_only (research)
- (some templates also allow
not_target_market)
platforms list always in enum order AUV, ROV, Underwater Camera.
crm_action: update_existing if crm_account_id present, else
create_account; excluded → no_import.
Priority tier & opportunity sizing (when the template requires it)
A: requested_demo AND interest_score ≥ 90 → USD 120000
B: requested_demo AND interest_score ≥ 80 → USD 90000
C: all other qualified leads → USD 50000
(Use the exact tier-to-USD mapping stated in the task prompt if it differs.)
Ranking (when the template requires ranked_leads)
Order: requested_demo (true first) → interest_score descending → broader platform
coverage (more platforms first) → company_name ascending. Rank is 1-based contiguous.
Summary aggregates
qualified_lead_count / excluded_count.
existing_crm_overlap_count + existing_crm_overlap_account_ids (CRM account
ids of qualified leads already in CRM, sorted ascending).
total_estimated_opportunity_usd = sum of tier opportunity amounts.
platform_coverage_counts: how many qualified leads cover each platform
(AUV/ROV/Underwater Camera).
priority_counts (A/B/C) and platform_counts where the template asks.
- Note: in some prospecting templates the
qualified_exhibitors item schema does
NOT include crm_action/opportunity — output ONLY the declared fields.
5. Import-batch cleaning tasks
Applies to /api/import_batches/{batch_id}/raw_contacts + suppression.
Deduplication
- Duplicate key = normalized email (lowercase, trimmed). Rows sharing the same
normalized email are one group.
- Winner selection: prefer the HIGHER-priority
source_name by the enum order
badge_scan > sponsor_form > partner_upload > webinar_form > exhibitor_form > manual_upload (earlier = higher). partner_upload outranks webinar_form.
Break remaining ties by latest captured_at, then lowest row_id.
clean_contact_id and source_row_id = the winning row's row_id.
duplicate_summary: duplicate_removed_count + duplicate_keys (sorted by key
ascending), each with winner_row_id and removed_row_ids.
Removals
suppressed: row whose normalized email OR phone matches the batch suppression
list → removal reason suppressed, final disposition suppress.
missing_contact: row with no email AND no phone → reason missing_contact,
disposition no_import.
duplicate: losing duplicate rows → reason duplicate, disposition no_import.
removal_summary: unusable_removed_count (= missing_contact count),
suppressed_removed_count (= suppressed count), removed_rows sorted by
row_id ascending.
clean_contacts (survivors = dedup winners minus suppressed/missing)
Fields: clean_contact_id, source_row_id, company_name, contact_name,
email (normalized or ""), phone (digits or ""), source_name, captured_at
(from winning row), crm_action, existing_account_id, existing_contact_id.
crm_action: create_account if no CRM account matches the email domain,
else update_existing. (Suppressed/missing never reach clean_contacts.)
existing_account_id: CRM account id whose domain matches the email domain,
else null. existing_contact_id: matching CRM contact id, else null.
- Sort by
clean_contact_id ascending.
import_action_totals
Tally the FINAL disposition of EVERY raw row (not just survivors):
create_account: survivors creating a new account
update_existing: survivors updating an existing account
no_import: duplicate losers + missing_contact rows
suppress: suppressed rows
(These sum to the total raw row count.)
campaign_member_import_count = number of surviving cleaned contacts.
6. Solve procedure per task
- Read the prompt +
answer_template.json; identify task type
(event reconciliation / prospecting / import batch). The TEMPLATE defines the
exact output shape — emit only its declared fields.
- Fetch
/api/policies for enum constants, then the task-specific endpoints.
- Apply the rules above; normalize email/phone; sort as specified.
- Do not call any judge endpoint at solve time. The train-only feedback has
already been distilled into the rules below.
- Key pitfalls confirmed by the judge:
- proposal_only sponsors ARE active (in sponsor_statuses AND in unpaid/finance
follow-up) — do not drop them.
- A lead with phone but no email is still qualified (only no-email-AND-no-phone
is
missing_contact).
- An attendee badge from an active sponsor company (incl. proposal_only) whose
contact is a sponsor ticket_contact is
sponsor_attendee, NOT a lead.
- New contact on an existing account →
create_contact (not update_existing).
- Dedup winner:
partner_upload beats webinar_form.
- Excluded records get NO campaign-member action; CRM action counts cover only
qualified leads.
- "camera arrays" / integrated cameras count as building
Underwater Camera.
1---2name: reflect-3-attempt-02-83description: HarborCRM CRM-Marketing Task Skill4---5# HarborCRM CRM-Marketing Task Skill67Solve HarborCRM post-event / prospecting / import-batch tasks against the shared8read-only HarborCRM API. Produce ONE JSON object matching the task's9`answer_template.json` — exactly its declared fields, nothing extra, no prose.1011## 0. Environment1213- Base URL: `<remote-env-url>` (use as the ONLY data source).14- Call with `curl -s <BASE><endpoint> | python3 -m json.tool`.15- Dataset is deterministic (seed 41001). All endpoints return JSON.1617### Public GET endpoints18- `GET /api/policies` — authoritative rule constants (platform enums, sponsor status enums).19- `GET /api/events` and `GET /api/events/{event_id}`20- `GET /api/events/{event_id}/orders` (== sponsor packages)21- `GET /api/events/{event_id}/badges`22- `GET /api/events/{event_id}/sponsor_packages`23- `GET /api/finance/invoices?event_id={event_id}`24- `GET /api/crm/accounts`, `GET /api/crm/contacts`, `GET /api/crm/opportunities`25- `GET /api/crm/campaign_members?event_id={event_id}`26- `GET /api/tradeshows`, `GET /api/tradeshows/{show_id}/exhibitors`,27 `GET /api/tradeshows/{show_id}/meeting_interest`28- `GET /api/import_batches`, `GET /api/import_batches/{batch_id}/raw_contacts`,29 `GET /api/import_batches/{batch_id}/suppression`3031### Authoritative policies (`/api/policies`)32- `prospecting.platform_enums` = `["AUV", "ROV", "Underwater Camera"]`33 (always emit platform lists in THIS order).34- `sponsor_handoff.status_enums` = `["paid_deferred", "open_invoice", "proposal_only", "not_sponsor"]`.35- `contact_hygiene.note`: normalize/contactable records before CRM import.3637## 1. Universal output rules38- Return one JSON object only. No prose. No fields beyond the template.39- Money: integer USD. Counts: integers. Dates: `YYYY-MM-DD`.40- Sort every list exactly as the template states (usually by name/id ascending).41- `null` for absent ids (e.g. new account has `account_id: null`); empty string `""`42 for absent email/phone when the field allows it.43- Normalization: email → lowercase + trimmed; phone → digits only (keep all digits44 present in the source, including a leading country code if given).4546## 2. Event reconciliation tasks (post-event sponsor + lead handoff)4748Applies to tasks that reconcile one `event_id`: sponsor statuses, qualified lead49accounts, excluded records, follow-up, CRM action counts (and the badge-level50variant: badge decisions, campaign-member actions, opportunity summary).5152### Event facts (from `/api/events/{event_id}`)53- `lead_followup_due_date` = `end_date` + `followup_days_after_end` days.54- `sponsor_followup_due_date` = `end_date` + `sponsor_followup_days_after_end` days.55- `lead_opportunity_amount` = per-qualified-lead opportunity amount for this event.5657### Sponsor statuses58- Active sponsors = sponsor packages whose `order_status` is `confirmed` OR59 `proposal_sent`. **Exclude `canceled`** sponsors entirely from `sponsor_statuses`60 (they go to excluded records as `inactive_sponsor_record`).61- Derive each active sponsor's status from its finance invoice:62 - invoice `status=="paid_deferred"` → `paid_deferred`63 - invoice `status=="open"` → `open_invoice`64 - NO invoice (proposal_sent only) → `proposal_only`65- Per-sponsor fields: `package_amount` = sponsor package amount;66 `invoice_id` (null if none); `paid_amount` (0 if none);67 `open_balance` = invoice `amount` − `paid_amount` (0 if none).68- Revenue totals: sum the FULL deal amount per status69 (`paid_deferred`, `open_invoice`, `proposal_only`). `open_invoice_balance` =70 sum of open balances across open-invoice sponsors (reported separately).71- Sort `sponsor_statuses` by `account_name` ascending.7273### Qualified non-sponsor lead accounts74A badge qualifies as a lead when ALL hold:75- `badge_type` is a business attendee type (NOT `sponsor`, NOT `student`, NOT `press`).76 `student`/`press` → excluded (`non_business_badge`).77- The badge's company is NOT an active sponsor (confirmed or proposal_sent).78 - A `badge_type:"sponsor"` badge → excluded (`sponsor_attendee`).79 - A badge whose company is an active sponsor (even proposal_only) and whose80 contact matches a sponsor `ticket_contact` → `sponsor_attendee` (NOT a lead),81 even when the badge_type itself is `attendee`.82- The badge's company is NOT a canceled sponsor → else `inactive_sponsor_record`.83- The CRM account for the company is NOT disqualified → else84 `existing_disqualified`.85- Contactability: a lead with a phone but NO email is STILL qualified (use empty86 email). `missing_contact` only when there is neither email nor phone.87- Qualified leads get `opportunity_amount` = event `lead_opportunity_amount`.88- CRM actions per qualified lead:89 - `crm_account_action`: `create_account` if no matching CRM account (by email90 domain), else `update_existing`.91 - `crm_contact_action`: `create_contact` if the badge contact is a NEW person92 (not an existing CRM contact), else `update_existing`. A new person on an93 existing account is STILL `create_contact` (do not mirror account action).94 - `campaign_member_action`: `add_campaign_member` (qualified leads get a new95 member; excluded leads get NO campaign-member action).96- `lead_pipeline_total` / `open_opportunity_total_usd` =97 `lead_opportunity_amount` × number of qualified leads.98- Sort qualified leads by `account_name` ascending.99100### Excluded records101Reasons (controlled): `sponsor_attendee`, `inactive_sponsor_record`,102`non_business_badge`, `existing_disqualified`, `missing_contact`.103- `sponsor_attendee`: badge_type `sponsor`, OR attendee whose company is an104 active sponsor and contact is a sponsor ticket_contact.105- `inactive_sponsor_record`: company had a `canceled` sponsor order.106- `non_business_badge`: `student` or `press` badge types.107- `existing_disqualified`: CRM account `status=="disqualified"`.108- `missing_contact`: no email AND no phone.109- When a record matches more than one rule, `inactive_sponsor_record` is preferred110 for canceled-sponsor companies; `existing_disqualified` for non-sponsor111 disqualified accounts. (Both reason values were accepted for a record that was112 both canceled-sponsor and disqualified — prefer `inactive_sponsor_record`.)113- Sort excluded by `company_name`, then `contact_name`.114115### Follow-up116- `lead_task_count` = number of qualified lead accounts.117- `sponsor_finance_accounts` (or `unpaid_sponsor_account_names`) =118 active sponsors that are NOT fully paid = `open_invoice` + `proposal_only`119 (exclude `paid_deferred`, which is fully collected; exclude canceled).120 `sponsor_finance_task_count` / count = number of those accounts.121- `unpaid_sponsor_total_usd` = sum of unpaid amounts (open balance for invoiced122 sponsors; full package amount for proposal_only sponsors with no payment).123124### CRM action counts (lead handoff only)125Tally only the work implied for QUALIFIED leads (excluded records imply no action):126- `accounts_create` / `accounts_update`, `contacts_create` / `contacts_update`,127 `campaign_members_create` / `campaign_members_update` (update is 0 unless an128 excluded lead's existing member is being re-statused — generally 0).129130## 3. Badge-level reconciliation variant (per-badge decisions + campaign members)131132Same sponsor/lead/exclusion logic as §2, plus:133134### badge_decisions (sorted by `badge_id`)135- `classification`: `sponsor_attendee` | `qualified_non_sponsor_lead` | `excluded`.136- `crm_action`:137 - new account+contact+member → `create_account_contact_campaign_member`138 - existing account, new contact+member → `create_contact_campaign_member`139 - existing account+contact, new member → `add_campaign_member`140 - existing member needing change → `update_campaign_member`141 - sponsor attendee / already-correct → `no_action`142 - excluded (non-business / disqualified / missing) → `no_import`143- `exclusion_reason`: null for qualified; the controlled reason otherwise.144145### campaign_member_actions (sorted by `subject_key` ascending)146- `action`: `create` | `update` | `no_action` | `no_import`.147- `target_status`: `attended_sponsor` | `registered_sponsor` | `attended` | `excluded`.148- Qualified lead (no member) who scanned a badge → `create`, `attended`.149- Existing sponsor member already correct (e.g. `attended_sponsor`) → `no_action`,150 same status. Sponsor who registered but did not scan → `no_action`,151 `registered_sponsor`.152- Excluded badge → `no_import`, `excluded`.153- Include existing sponsor campaign members (with `no_action`) even if they have no154 badge scan, so their disposition is represented. Use the CRM `contact_id` as155 `subject_key` for existing contacts; use the normalized email (or badge id when156 no email) for new contacts.157- `exclusion_counts`: tally of `sponsor_attendee`, `non_business_badge`,158 `existing_disqualified`, `missing_contact`.159160### opportunity_summary161- `qualified_non_sponsor_account_names`: sorted ascending.162- `lead_opportunity_amount_usd`: event `lead_opportunity_amount`.163- `open_opportunity_total_usd` = lead_opportunity_amount × #qualified.164- `open_opportunity_count` = #qualified.165166### badge_only_contacts (sorted by `company_name`)167Normalized contact facts for qualified non-sponsor leads:168`company_name`, `contact_name`, `normalized_email` (lowercase, "" allowed),169`normalized_phone` (digits only, "" allowed).170171## 4. Prospecting tasks (tradeshow exhibitor qualification)172173Applies to `/api/tradeshows/{show_id}/exhibitors` + `meeting_interest` tasks.174175### Qualification176- Qualified = exhibitor that BUILDS / MANUFACTURES / DESIGNS / OEM-builds a target177 platform (`AUV`, `ROV`, `Underwater Camera`). Read the `description`.178 - "Builds AUVs and ROVs" → `[AUV, ROV]`.179 - "Manufactures ROVs with camera arrays" → `[ROV, Underwater Camera]` (camera180 arrays count as building underwater cameras).181 - "Designs underwater camera modules" → `[Underwater Camera]`.182- Excluded (adjacent, not a platform builder):183 - distributor / reseller → `distributor_only` (relationship_type `distributor`)184 - service / consulting / analytics-only → `service_only` (`service_provider`)185 - sensor-only vendor → `sensor_vendor_only` / `sensor_only` (`sensor_vendor`)186 - research-only → `research_only` (`research`)187 - (some templates also allow `not_target_market`)188- `platforms` list always in enum order `AUV, ROV, Underwater Camera`.189- `crm_action`: `update_existing` if `crm_account_id` present, else190 `create_account`; excluded → `no_import`.191192### Priority tier & opportunity sizing (when the template requires it)193- `A`: requested_demo AND interest_score ≥ 90 → USD 120000194- `B`: requested_demo AND interest_score ≥ 80 → USD 90000195- `C`: all other qualified leads → USD 50000196(Use the exact tier-to-USD mapping stated in the task prompt if it differs.)197198### Ranking (when the template requires ranked_leads)199Order: requested_demo (true first) → interest_score descending → broader platform200coverage (more platforms first) → company_name ascending. Rank is 1-based contiguous.201202### Summary aggregates203- `qualified_lead_count` / `excluded_count`.204- `existing_crm_overlap_count` + `existing_crm_overlap_account_ids` (CRM account205 ids of qualified leads already in CRM, sorted ascending).206- `total_estimated_opportunity_usd` = sum of tier opportunity amounts.207- `platform_coverage_counts`: how many qualified leads cover each platform208 (`AUV`/`ROV`/`Underwater Camera`).209- `priority_counts` (A/B/C) and `platform_counts` where the template asks.210- Note: in some prospecting templates the `qualified_exhibitors` item schema does211 NOT include `crm_action`/`opportunity` — output ONLY the declared fields.212213## 5. Import-batch cleaning tasks214215Applies to `/api/import_batches/{batch_id}/raw_contacts` + `suppression`.216217### Deduplication218- Duplicate key = normalized email (lowercase, trimmed). Rows sharing the same219 normalized email are one group.220- Winner selection: prefer the HIGHER-priority `source_name` by the enum order221 `badge_scan > sponsor_form > partner_upload > webinar_form > exhibitor_form >222 manual_upload` (earlier = higher). `partner_upload` outranks `webinar_form`.223 Break remaining ties by latest `captured_at`, then lowest `row_id`.224- `clean_contact_id` and `source_row_id` = the winning row's `row_id`.225- `duplicate_summary`: `duplicate_removed_count` + `duplicate_keys` (sorted by key226 ascending), each with `winner_row_id` and `removed_row_ids`.227228### Removals229- `suppressed`: row whose normalized email OR phone matches the batch suppression230 list → removal reason `suppressed`, final disposition `suppress`.231- `missing_contact`: row with no email AND no phone → reason `missing_contact`,232 disposition `no_import`.233- `duplicate`: losing duplicate rows → reason `duplicate`, disposition `no_import`.234- `removal_summary`: `unusable_removed_count` (= missing_contact count),235 `suppressed_removed_count` (= suppressed count), `removed_rows` sorted by236 `row_id` ascending.237238### clean_contacts (survivors = dedup winners minus suppressed/missing)239Fields: `clean_contact_id`, `source_row_id`, `company_name`, `contact_name`,240`email` (normalized or ""), `phone` (digits or ""), `source_name`, `captured_at`241(from winning row), `crm_action`, `existing_account_id`, `existing_contact_id`.242- `crm_action`: `create_account` if no CRM account matches the email domain,243 else `update_existing`. (Suppressed/missing never reach clean_contacts.)244- `existing_account_id`: CRM account id whose `domain` matches the email domain,245 else null. `existing_contact_id`: matching CRM contact id, else null.246- Sort by `clean_contact_id` ascending.247248### import_action_totals249Tally the FINAL disposition of EVERY raw row (not just survivors):250- `create_account`: survivors creating a new account251- `update_existing`: survivors updating an existing account252- `no_import`: duplicate losers + missing_contact rows253- `suppress`: suppressed rows254(These sum to the total raw row count.)255`campaign_member_import_count` = number of surviving cleaned contacts.256257## 6. Solve procedure per task2581. Read the prompt + `answer_template.json`; identify task type259 (event reconciliation / prospecting / import batch). The TEMPLATE defines the260 exact output shape — emit only its declared fields.2612. Fetch `/api/policies` for enum constants, then the task-specific endpoints.2623. Apply the rules above; normalize email/phone; sort as specified.2634. Do not call any judge endpoint at solve time. The train-only feedback has264 already been distilled into the rules below.2655. Key pitfalls confirmed by the judge:266 - proposal_only sponsors ARE active (in sponsor_statuses AND in unpaid/finance267 follow-up) — do not drop them.268 - A lead with phone but no email is still qualified (only no-email-AND-no-phone269 is `missing_contact`).270 - An attendee badge from an active sponsor company (incl. proposal_only) whose271 contact is a sponsor ticket_contact is `sponsor_attendee`, NOT a lead.272 - New contact on an existing account → `create_contact` (not `update_existing`).273 - Dedup winner: `partner_upload` beats `webinar_form`.274 - Excluded records get NO campaign-member action; CRM action counts cover only275 qualified leads.276 - "camera arrays" / integrated cameras count as building `Underwater Camera`.