HarborCRM Front-of-Funnel Handoff Skill
Solves four recurring task families in the HarborCRM domain:
- Post-event CRM handoff / reconciliation (sponsor status + qualified non-sponsor leads).
- Trade-show prospecting (qualify exhibitors by platform fit, rank, size opportunities).
- Raw-contact import hygiene (normalize, dedupe, suppress, CRM-match).
Each test gives a prompt + an answer_template.json. The template is authoritative: emit exactly its keys, enums, ordering, and types — nothing more, nothing less. Per-task enum sets and schema shapes vary; always read the template first and copy its allowed values verbatim. Output JSON only, no prose.
0. The remote API (read-only, HTTP-only)
Base URL: supplied by runner (e.g. <remote-env-url>). Use curl or python urllib. Never WebFetch (forces HTTPS; host is HTTP-only).
Endpoints and what they are for:
GET /api/policies — controlled value enums + notes. Sparse; most rules are inferred from data + template. Always fetch it but do not expect it to give thresholds.
GET /api/events/{id} — start_date, end_date, status, campaign_code, lead_opportunity_amount, followup_days_after_end, sponsor_followup_days_after_end.
GET /api/events/{id}/orders and /sponsor_packages — sponsor orders (account_id, account_name, amount, package_level, order_status ∈ {confirmed, proposal_sent, canceled}, ticket_contacts). Orders and sponsor_packages are usually the same rows.
GET /api/events/{id}/badges — scanned attendees (badge_id, badge_type ∈ {sponsor, attendee, student, press, …}, company_name, contact_name, email, phone, job_title).
GET /api/finance/invoices?event_id={id} — invoice_id, amount, paid_amount, deferred_amount, status ∈ {paid_deferred, open, …}, payment_date.
GET /api/crm/accounts — account_id, name, domain, status ∈ {prospect, customer, disqualified}, disqualified_reason.
GET /api/crm/contacts — contact_id, account_id, name, email, phone, opted_out.
GET /api/crm/opportunities — account_id, amount, event_id, stage.
GET /api/crm/campaign_members?event_id={id} — existing members (account_id, contact_id, status ∈ {attended_sponsor, registered_sponsor, attended}).
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.
Fetch only what the task family needs (see each SOP). Match accounts to companies by email/website domain, not by display name (names vary: "HelioWare Manufacturing" vs "HelioWare Mfg.").
1. Normalization rules (used everywhere)
- Email: trim surrounding whitespace, lowercase. If the result is empty/blank → empty string
"". Domain = substring after @ (for account matching).
- Phone: strip everything except digits (
[^0-9] removed). Keep leading country-code digits if present in the source (e.g. +1 415 555 0188 → 14155550188; (415) 555-0188 → 4155550188). Do not invent or strip a country code that wasn't there. Empty/blank → "".
- A contact is contactable iff it has a non-empty normalized email or non-empty normalized phone. Email-only and phone-only are both fine.
- Output amounts as integer USD (no decimals, no currency symbol).
2. Sponsor status classification (event reconciliation)
For every account that has a sponsor order/package for the event, reconcile order + invoice:
| order_status |
invoice present? |
invoice paid fully? |
→ sponsor_status |
| confirmed |
yes, status=paid_deferred and paid_amount == amount |
yes |
paid_deferred |
| confirmed |
yes, status=open (paid_amount < amount) |
no |
open_invoice |
| proposal_sent |
no invoice (or no payment) |
— |
proposal_only |
| canceled |
— |
— |
not a sponsor → see below |
open_invoice open balance = invoice.amount − invoice.paid_amount (use amount, not deferred_amount).
- Canceled orders: how to report depends on the template's
sponsor_status enum:
- If the enum includes
not_sponsor → list the canceled/non-sponsor account with sponsor_status = not_sponsor (amount = its package/order amount, or 0 if none).
- If the enum omits
not_sponsor (only paid_deferred/open_invoice/proposal_only) → exclude the canceled account from sponsor_statuses and instead surface it in the exclusion list with reason inactive_sponsor_record.
- Revenue-by-status totals: sum package/order
amount per status bucket. open_invoice total = full package amount of open-invoice sponsors; open_invoice_balance = sum of their open balances (amount − paid_amount). proposal_only total = sum of proposal amounts.
- Sort
sponsor_statuses by account_name ascending.
Unpaid / sponsor-finance follow-up set = sponsors that are NOT paid_deferred, i.e. open_invoice + proposal_only. Their total = sum of their package amounts. (If a template clearly scopes it to "open invoices only," restrict to open_invoice. Default: include both.) paid_deferred sponsors are settled and need no finance follow-up.
3. Badge / lead classification (event reconciliation)
For each badge, classify into sponsor_attendee / qualified_non_sponsor_lead / excluded:
- sponsor_attendee — the badge's company has ANY sponsor order for the event (confirmed OR proposal_sent — proposal-stage accounts are on the sponsor track, not the lead track) or the badge_type is
sponsor. Exclusion reason sponsor_attendee. Not a sales lead.
- excluded — non_business_badge — badge_type is non-business (
student, press, guest, media, etc.). Reason non_business_badge.
- excluded — existing_disqualified — the badge's company matches a CRM account whose
status == disqualified (has a disqualified_reason). Reason existing_disqualified.
- excluded — missing_contact — badge has neither email nor phone after normalization (not contactable). Reason
missing_contact.
- Otherwise → qualified_non_sponsor_lead (business attendee, contactable, account not disqualified, company is not a sponsor).
Precedence when several apply: sponsor_attendee → non_business_badge → existing_disqualified → missing_contact. (A disqualified-account attendee that is also a canceled sponsor's contact is genuinely ambiguous; prefer the account-level existing_disqualified if the CRM account is flagged disqualified, else inactive_sponsor_record.)
CRM action for each badge (use the template's crm_action enum — names differ per task):
- Qualified lead, company not in CRM → create account + contact + campaign member (
create_account_contact_campaign_member, or in the leaner schema: crm_account_action=create_account, crm_contact_action=create_contact, campaign_member_action=add_campaign_member).
- Qualified lead, company in CRM but contact missing →
create_contact_campaign_member (account update_existing, contact create_contact).
- Qualified lead already a campaign member →
update_campaign_member / add_campaign_member per template; sponsor attendee already a correct member → no_action.
- Excluded badge →
no_import (or no_action for sponsor attendees already handled).
badge_decisions sort by badge_id ascending.
4. Campaign-member actions & target status (event reconciliation)
Compare each subject (sponsor ticket contacts + qualified leads) against existing campaign_members:
- Already a member with the correct status →
no_action.
- New subject who should be a member →
create.
- Member whose status should change →
update.
- Excluded subject →
no_import.
target_status mapping:
- Sponsor account, contact attended (has a badge scan) →
attended_sponsor.
- Sponsor account, registered only (no badge scan) →
registered_sponsor.
- Qualified non-sponsor lead who attended →
attended.
- Excluded →
excluded.
Sort by subject_key ascending (or as the template specifies).
crm_action_counts (when present) tally the implied work across qualified leads (and any member updates): accounts_create/update, contacts_create/update, campaign_members_create/update. New leads each contribute one create per dimension as applicable; an existing account contributes accounts_update.
5. Opportunity & pipeline math (event reconciliation)
- Each qualified non-sponsor lead's opportunity = the event's
lead_opportunity_amount.
lead_pipeline_total / open_opportunity_total_usd = (count of qualified non-sponsor leads) × lead_opportunity_amount. open_opportunity_count = that count.
qualified_non_sponsor_account_names = distinct company names of qualified leads, sorted ascending.
6. Follow-up due dates (event reconciliation)
- Lead follow-up due date = event
end_date + followup_days_after_end (calendar days).
- Sponsor finance follow-up due date = event
end_date + sponsor_followup_days_after_end.
- Format
YYYY-MM-DD. Use the end_date (not start_date) as the base; single-day events have start_date==end_date.
lead_task_count = number of qualified-lead handoff tasks (= qualified lead count). sponsor_finance_task_count = number of unpaid sponsor accounts (Section 2).
7. Trade-show prospecting SOP
Goal: from exhibitors, pick those that manufacture / OEM-build target underwater platforms, rank them, size opportunities, and list near-miss exclusions.
Endpoints: /api/tradeshows/{show}/exhibitors, /.../meeting_interest, /api/crm/accounts, /api/policies.
Platform enums (fixed): AUV, ROV, Underwater Camera. Always list platforms in that enum order.
Read the exhibitor description to assign platforms:
- "AUV", "autonomous underwater vehicle", "AUV scout" →
AUV.
- "ROV", "remotely operated", "inspection-class ROV", "pen-cleaning ROV" →
ROV.
- "underwater camera", "camera modules/arrays", "optics manufacturer", "camera maker" →
Underwater Camera.
- A maker can have multiple platforms (e.g. "builds AUVs and ROVs" → [AUV, ROV]).
Qualified vs excluded (the key judgment — exclude near-misses):
Qualified = the company builds/OEM-manufactures at least one target platform. Exclude companies that only sit adjacent to the platform, with a controlled reason. Use the exact exclusion enum from the template (it differs per task):
- Distributor / reseller / dealer / sales agent ("does not manufacture", "imported brands") →
distributor_only (relationship distributor).
- Service / consulting / operator ("operates rented ROVs", "analytics dashboard using partner ROV feeds", "no hardware manufacturing") →
service_only (relationship service_provider).
- Sensor-only vendor ("sensor-only DO/salinity probes for integration by platform partners") →
sensor_vendor_only or sensor_only (copy the template's spelling) (relationship sensor_vendor).
- Research / academic lab →
research_only (relationship research).
- Wrong market entirely →
not_target_market (only if the template offers it).
- All excluded exhibitors get
crm_action = no_import and stay visible in the exclusion list.
Critical: a company that makes the sensor but not the platform is NOT qualified (it's the sensor vendor we are selling FOR, not the OEM target). A company that merely uses or resells platforms is NOT qualified.
CRM overlap & action:
Match exhibitor to CRM account by crm_account_id (if exhibitor row provides it) or by website/email domain.
- Qualified + already in CRM →
crm_action = update_existing, set crm_account_id.
- Qualified + not in CRM →
crm_action = create_account, crm_account_id = null.
existing_crm_overlap_count / existing_crm_overlap_account_ids count only qualified leads that have a CRM account; ids sorted ascending.
Priority tier & opportunity sizing:
Join exhibitor to meeting_interest by company_name to get interest_score and requested_demo.
Default tiering (used when the prompt doesn't override): based on demo request + score:
- A =
requested_demo == true AND interest_score >= 90.
- B =
requested_demo == true AND interest_score >= 80 (and not A).
- C = everything else qualified.
If the prompt gives explicit tier thresholds or dollar values, use those verbatim. Typical opportunity sizing: A = 120000, B = 90000, C = 50000 (USD) — but always take the prompt's stated values if given.
total_estimated_opportunity_usd = sum of qualified leads' opportunity estimates.
Ranking (when the template has rank):
Order qualified leads by: (1) requested_demo true first, (2) interest_score descending, (3) broader platform coverage (more platforms first), (4) company_name ascending. Assign 1-based contiguous rank.
Counts:
platform_coverage_counts / platform_counts: count of qualified leads covering each platform (a multi-platform lead increments each of its platforms).
priority_counts: count of qualified leads per tier A/B/C.
qualified_total / qualified_lead_count, excluded_*_total/excluded_count.
Sort qualified_exhibitors/ranked_leads per template (company_name asc, or rank asc); excluded_* by company_name ascending.
8. Import-batch hygiene SOP
Endpoints: /api/import_batches/{batch}/raw_contacts, /.../suppression, /api/crm/accounts, /api/crm/contacts, /api/policies. Campaign code comes from the import_batch record (campaign_code).
Process raw rows in this order:
- Normalize every row (Section 1): trimmed-lowercased email, digits-only phone.
- Drop unusable rows = not contactable (no email AND no phone) → removal reason
missing_contact, action no_import.
- Dedupe remaining rows. Key = normalized email (fallback to normalized phone when email is empty). Within a duplicate group pick the winner:
- Primary: latest
captured_at (freshest record wins).
- Tie-break: lowest
row_id (ascending) when timestamps are equal.
Losers → removal reason duplicate. Record {key, winner_row_id, removed_row_ids} per group; sort duplicate_keys by key ascending.
- Suppression: a winner whose normalized email or phone matches any suppression-list entry → removal reason
suppressed, action suppress. (Suppression matches on email or phone; reasons like global_opt_out / privacy_request / role_account all suppress.)
- CRM match the survivors by email domain → account.domain:
- Domain matches an existing account →
crm_action = update_existing; set existing_account_id; existing_contact_id = that account's matching contact_id if the exact person already exists, else null.
- No account match →
crm_action = create_account; existing_account_id = null, existing_contact_id = null.
Outputs:
clean_contacts = the surviving rows only (the create_account + update_existing set). Each item uses the winner row's values: clean_contact_id = source_row_id = winning row_id; company_name, contact_name, normalized email, normalized phone, source_name, captured_at (winner's), crm_action, existing_account_id, existing_contact_id. Sort by clean_contact_id ascending.
duplicate_summary: duplicate_removed_count = total duplicate losers; duplicate_keys as above.
removal_summary: unusable_removed_count = missing_contact count; suppressed_removed_count; removed_rows = every removed row {row_id, reason} with reason ∈ {duplicate, missing_contact, suppressed}, sorted by row_id ascending.
import_action_totals: integer counts over the deduped rows (winners + suppressed + unusable, i.e. excluding duplicate losers) for create_account, update_existing, no_import (missing_contact rows), suppress.
campaign_member_import_count = number of surviving cleaned contacts (the clean_contacts list length).
9. Common misjudgments to avoid
- Don't add or rename keys/enums. Copy the template's exact enum spellings (e.g.
sensor_vendor_only vs sensor_only; presence/absence of not_sponsor).
- Canceled sponsors are not active sponsors. Either
not_sponsor or excluded (inactive_sponsor_record) depending on the enum — never proposal_only.
- Open-invoice total uses full package
amount; the balance is a separate field (amount − paid_amount). Don't put the balance in the revenue total.
- Proposal-stage accounts are on the sponsor track, so their attendees are
sponsor_attendee, not qualified leads.
- Match companies by domain, not display name (manufacturing/Mfg., punctuation, suffixes vary).
- Sensor-only and platform-using companies are NOT qualified prospects — they are near-miss exclusions.
- Use
end_date (not start_date) for all follow-up date arithmetic; add the event's own day-offset fields.
- Lead opportunity amount comes from the event (
lead_opportunity_amount), not from CRM opportunity rows; CRM opportunities are usually for sponsor/closed deals, not the new leads.
- Dedup winner = latest captured_at, tie-break lowest row_id. A duplicate with an identical timestamp is the signal to use the row_id tie-breaker.
- Suppression and missing-contact rows are removed, not in clean_contacts, but still counted in
import_action_totals and listed in removal_summary.
- Contactable = email OR phone. An empty email alone does not disqualify a row that has a phone.
- Respect every
ordering rule; ties on the primary sort key fall back to the secondary key named in the template.
- Output JSON only, integers for all amounts/counts, dates as
YYYY-MM-DD.
1---2name: harborcrm-front-of-funnel-handoff-23description: SOP for HarborCRM front-of-funnel tasks — post-event sponsor/lead handoff, trade-show prospecting, and raw-contact import hygiene — using the read-only HarborCRM data API.4---56# HarborCRM Front-of-Funnel Handoff Skill78Solves four recurring task families in the HarborCRM domain:91. **Post-event CRM handoff / reconciliation** (sponsor status + qualified non-sponsor leads).102. **Trade-show prospecting** (qualify exhibitors by platform fit, rank, size opportunities).113. **Raw-contact import hygiene** (normalize, dedupe, suppress, CRM-match).1213Each test gives a prompt + an `answer_template.json`. **The template is authoritative**: emit exactly its keys, enums, ordering, and types — nothing more, nothing less. Per-task enum sets and schema shapes vary; always read the template first and copy its allowed values verbatim. Output JSON only, no prose.1415---1617## 0. The remote API (read-only, HTTP-only)1819Base URL: supplied by runner (e.g. `<remote-env-url>`). Use `curl` or python `urllib`. **Never WebFetch** (forces HTTPS; host is HTTP-only).2021Endpoints and what they are for:22- `GET /api/policies` — controlled value enums + notes. Sparse; most rules are inferred from data + template. Always fetch it but do not expect it to give thresholds.23- `GET /api/events/{id}` — `start_date`, `end_date`, `status`, `campaign_code`, `lead_opportunity_amount`, `followup_days_after_end`, `sponsor_followup_days_after_end`.24- `GET /api/events/{id}/orders` and `/sponsor_packages` — sponsor orders (`account_id`, `account_name`, `amount`, `package_level`, `order_status` ∈ {confirmed, proposal_sent, canceled}, `ticket_contacts`). Orders and sponsor_packages are usually the same rows.25- `GET /api/events/{id}/badges` — scanned attendees (`badge_id`, `badge_type` ∈ {sponsor, attendee, student, press, …}, `company_name`, `contact_name`, `email`, `phone`, `job_title`).26- `GET /api/finance/invoices?event_id={id}` — `invoice_id`, `amount`, `paid_amount`, `deferred_amount`, `status` ∈ {paid_deferred, open, …}, `payment_date`.27- `GET /api/crm/accounts` — `account_id`, `name`, `domain`, `status` ∈ {prospect, customer, disqualified}, `disqualified_reason`.28- `GET /api/crm/contacts` — `contact_id`, `account_id`, `name`, `email`, `phone`, `opted_out`.29- `GET /api/crm/opportunities` — `account_id`, `amount`, `event_id`, `stage`.30- `GET /api/crm/campaign_members?event_id={id}` — existing members (`account_id`, `contact_id`, `status` ∈ {attended_sponsor, registered_sponsor, attended}).31- `GET /api/tradeshows`, `/api/tradeshows/{id}/exhibitors`, `/api/tradeshows/{id}/meeting_interest`.32- `GET /api/import_batches`, `/api/import_batches/{id}/raw_contacts`, `/api/import_batches/{id}/suppression`.3334Fetch only what the task family needs (see each SOP). Match accounts to companies by **email/website domain**, not by display name (names vary: "HelioWare Manufacturing" vs "HelioWare Mfg.").3536---3738## 1. Normalization rules (used everywhere)3940- **Email**: trim surrounding whitespace, lowercase. If the result is empty/blank → empty string `""`. Domain = substring after `@` (for account matching).41- **Phone**: strip everything except digits (`[^0-9]` removed). Keep leading country-code digits if present in the source (e.g. `+1 415 555 0188` → `14155550188`; `(415) 555-0188` → `4155550188`). Do **not** invent or strip a country code that wasn't there. Empty/blank → `""`.42- A contact is **contactable** iff it has a non-empty normalized email **or** non-empty normalized phone. Email-only and phone-only are both fine.43- Output amounts as **integer USD** (no decimals, no currency symbol).4445---4647## 2. Sponsor status classification (event reconciliation)4849For every account that has a sponsor **order/package** for the event, reconcile order + invoice:5051| order_status | invoice present? | invoice paid fully? | → sponsor_status |52|---|---|---|---|53| confirmed | yes, `status=paid_deferred` and `paid_amount == amount` | yes | **paid_deferred** |54| confirmed | yes, `status=open` (paid_amount < amount) | no | **open_invoice** |55| proposal_sent | no invoice (or no payment) | — | **proposal_only** |56| canceled | — | — | **not a sponsor** → see below |5758- `open_invoice` open balance = `invoice.amount − invoice.paid_amount` (use `amount`, not `deferred_amount`).59- **Canceled orders**: how to report depends on the template's `sponsor_status` enum:60 - If the enum **includes `not_sponsor`** → list the canceled/non-sponsor account with `sponsor_status = not_sponsor` (amount = its package/order amount, or 0 if none).61 - If the enum **omits `not_sponsor`** (only paid_deferred/open_invoice/proposal_only) → **exclude** the canceled account from `sponsor_statuses` and instead surface it in the exclusion list with reason `inactive_sponsor_record`.62- Revenue-by-status totals: sum **package/order `amount`** per status bucket. `open_invoice` total = full package amount of open-invoice sponsors; `open_invoice_balance` = sum of their open balances (amount − paid_amount). `proposal_only` total = sum of proposal amounts.63- Sort `sponsor_statuses` by `account_name` ascending.6465**Unpaid / sponsor-finance follow-up set** = sponsors that are NOT `paid_deferred`, i.e. `open_invoice` + `proposal_only`. Their total = sum of their package amounts. (If a template clearly scopes it to "open invoices only," restrict to `open_invoice`. Default: include both.) `paid_deferred` sponsors are settled and need no finance follow-up.6667---6869## 3. Badge / lead classification (event reconciliation)7071For each badge, classify into `sponsor_attendee` / `qualified_non_sponsor_lead` / `excluded`:72731. **sponsor_attendee** — the badge's company has ANY sponsor order for the event (confirmed OR proposal_sent — proposal-stage accounts are on the sponsor track, not the lead track) **or** the badge_type is `sponsor`. Exclusion reason `sponsor_attendee`. Not a sales lead.742. **excluded — non_business_badge** — badge_type is non-business (`student`, `press`, `guest`, `media`, etc.). Reason `non_business_badge`.753. **excluded — existing_disqualified** — the badge's company matches a CRM account whose `status == disqualified` (has a `disqualified_reason`). Reason `existing_disqualified`.764. **excluded — missing_contact** — badge has neither email nor phone after normalization (not contactable). Reason `missing_contact`.775. Otherwise → **qualified_non_sponsor_lead** (business attendee, contactable, account not disqualified, company is not a sponsor).7879Precedence when several apply: sponsor_attendee → non_business_badge → existing_disqualified → missing_contact. (A disqualified-account attendee that is also a canceled sponsor's contact is genuinely ambiguous; prefer the account-level `existing_disqualified` if the CRM account is flagged disqualified, else `inactive_sponsor_record`.)8081CRM action for each badge (use the template's `crm_action` enum — names differ per task):82- Qualified lead, company **not** in CRM → create account + contact + campaign member (`create_account_contact_campaign_member`, or in the leaner schema: `crm_account_action=create_account`, `crm_contact_action=create_contact`, `campaign_member_action=add_campaign_member`).83- Qualified lead, company **in** CRM but contact missing → `create_contact_campaign_member` (account `update_existing`, contact `create_contact`).84- Qualified lead already a campaign member → `update_campaign_member` / `add_campaign_member` per template; sponsor attendee already a correct member → `no_action`.85- Excluded badge → `no_import` (or `no_action` for sponsor attendees already handled).8687`badge_decisions` sort by `badge_id` ascending.8889---9091## 4. Campaign-member actions & target status (event reconciliation)9293Compare each subject (sponsor ticket contacts + qualified leads) against existing `campaign_members`:94- Already a member with the correct status → `no_action`.95- New subject who should be a member → `create`.96- Member whose status should change → `update`.97- Excluded subject → `no_import`.9899`target_status` mapping:100- Sponsor account, contact **attended** (has a badge scan) → `attended_sponsor`.101- Sponsor account, **registered only** (no badge scan) → `registered_sponsor`.102- Qualified non-sponsor lead who attended → `attended`.103- Excluded → `excluded`.104105Sort by `subject_key` ascending (or as the template specifies).106107`crm_action_counts` (when present) tally the implied work across qualified leads (and any member updates): accounts_create/update, contacts_create/update, campaign_members_create/update. New leads each contribute one create per dimension as applicable; an existing account contributes accounts_update.108109---110111## 5. Opportunity & pipeline math (event reconciliation)112113- Each qualified non-sponsor lead's opportunity = the event's `lead_opportunity_amount`.114- `lead_pipeline_total` / `open_opportunity_total_usd` = (count of qualified non-sponsor leads) × `lead_opportunity_amount`. `open_opportunity_count` = that count.115- `qualified_non_sponsor_account_names` = distinct company names of qualified leads, sorted ascending.116117---118119## 6. Follow-up due dates (event reconciliation)120121- **Lead follow-up due date** = event `end_date` + `followup_days_after_end` (calendar days).122- **Sponsor finance follow-up due date** = event `end_date` + `sponsor_followup_days_after_end`.123- Format `YYYY-MM-DD`. Use the **end_date** (not start_date) as the base; single-day events have start_date==end_date.124- `lead_task_count` = number of qualified-lead handoff tasks (= qualified lead count). `sponsor_finance_task_count` = number of unpaid sponsor accounts (Section 2).125126---127128## 7. Trade-show prospecting SOP129130Goal: from exhibitors, pick those that **manufacture / OEM-build** target underwater platforms, rank them, size opportunities, and list near-miss exclusions.131132Endpoints: `/api/tradeshows/{show}/exhibitors`, `/.../meeting_interest`, `/api/crm/accounts`, `/api/policies`.133134### Platform enums (fixed): `AUV`, `ROV`, `Underwater Camera`. Always list platforms in **that enum order**.135Read the exhibitor `description` to assign platforms:136- "AUV", "autonomous underwater vehicle", "AUV scout" → `AUV`.137- "ROV", "remotely operated", "inspection-class ROV", "pen-cleaning ROV" → `ROV`.138- "underwater camera", "camera modules/arrays", "optics manufacturer", "camera maker" → `Underwater Camera`.139- A maker can have multiple platforms (e.g. "builds AUVs and ROVs" → [AUV, ROV]).140141### Qualified vs excluded (the key judgment — exclude near-misses):142Qualified = the company **builds/OEM-manufactures** at least one target platform. Exclude companies that only sit *adjacent* to the platform, with a controlled reason. **Use the exact exclusion enum from the template** (it differs per task):143- **Distributor / reseller / dealer / sales agent** ("does not manufacture", "imported brands") → `distributor_only` (relationship `distributor`).144- **Service / consulting / operator** ("operates rented ROVs", "analytics dashboard using partner ROV feeds", "no hardware manufacturing") → `service_only` (relationship `service_provider`).145- **Sensor-only vendor** ("sensor-only DO/salinity probes for integration by platform partners") → `sensor_vendor_only` *or* `sensor_only` (copy the template's spelling) (relationship `sensor_vendor`).146- **Research / academic lab** → `research_only` (relationship `research`).147- **Wrong market entirely** → `not_target_market` (only if the template offers it).148- All excluded exhibitors get `crm_action = no_import` and stay visible in the exclusion list.149150Critical: a company that makes the *sensor* but not the *platform* is NOT qualified (it's the sensor vendor we are selling FOR, not the OEM target). A company that merely *uses* or *resells* platforms is NOT qualified.151152### CRM overlap & action:153Match exhibitor to CRM account by `crm_account_id` (if exhibitor row provides it) or by website/email domain.154- Qualified + already in CRM → `crm_action = update_existing`, set `crm_account_id`.155- Qualified + not in CRM → `crm_action = create_account`, `crm_account_id = null`.156- `existing_crm_overlap_count` / `existing_crm_overlap_account_ids` count only **qualified** leads that have a CRM account; ids sorted ascending.157158### Priority tier & opportunity sizing:159Join exhibitor to `meeting_interest` by company_name to get `interest_score` and `requested_demo`.160Default tiering (used when the prompt doesn't override): based on demo request + score:161- **A** = `requested_demo == true` AND `interest_score >= 90`.162- **B** = `requested_demo == true` AND `interest_score >= 80` (and not A).163- **C** = everything else qualified.164If the prompt gives explicit tier thresholds or dollar values, use those verbatim. Typical opportunity sizing: A = 120000, B = 90000, C = 50000 (USD) — but always take the prompt's stated values if given.165`total_estimated_opportunity_usd` = sum of qualified leads' opportunity estimates.166167### Ranking (when the template has `rank`):168Order qualified leads by: (1) `requested_demo` true first, (2) `interest_score` descending, (3) broader platform coverage (more platforms first), (4) `company_name` ascending. Assign 1-based contiguous `rank`.169170### Counts:171- `platform_coverage_counts` / `platform_counts`: count of qualified leads covering each platform (a multi-platform lead increments each of its platforms).172- `priority_counts`: count of qualified leads per tier A/B/C.173- `qualified_total` / `qualified_lead_count`, `excluded_*_total`/`excluded_count`.174175Sort `qualified_exhibitors`/`ranked_leads` per template (company_name asc, or rank asc); `excluded_*` by company_name ascending.176177---178179## 8. Import-batch hygiene SOP180181Endpoints: `/api/import_batches/{batch}/raw_contacts`, `/.../suppression`, `/api/crm/accounts`, `/api/crm/contacts`, `/api/policies`. Campaign code comes from the import_batch record (`campaign_code`).182183Process raw rows in this order:1841851. **Normalize** every row (Section 1): trimmed-lowercased email, digits-only phone.1862. **Drop unusable** rows = not contactable (no email AND no phone) → removal reason `missing_contact`, action `no_import`.1873. **Dedupe** remaining rows. **Key = normalized email** (fallback to normalized phone when email is empty). Within a duplicate group pick the **winner**:188 - Primary: **latest `captured_at`** (freshest record wins).189 - Tie-break: **lowest `row_id`** (ascending) when timestamps are equal.190 Losers → removal reason `duplicate`. Record `{key, winner_row_id, removed_row_ids}` per group; sort `duplicate_keys` by `key` ascending.1914. **Suppression**: a winner whose normalized email **or** phone matches any suppression-list entry → removal reason `suppressed`, action `suppress`. (Suppression matches on email or phone; reasons like global_opt_out / privacy_request / role_account all suppress.)1925. **CRM match** the survivors by **email domain → account.domain**:193 - Domain matches an existing account → `crm_action = update_existing`; set `existing_account_id`; `existing_contact_id` = that account's matching contact_id if the exact person already exists, else `null`.194 - No account match → `crm_action = create_account`; `existing_account_id = null`, `existing_contact_id = null`.195196Outputs:197- `clean_contacts` = the **surviving** rows only (the `create_account` + `update_existing` set). Each item uses the **winner row's** values: `clean_contact_id` = `source_row_id` = winning row_id; company_name, contact_name, normalized email, normalized phone, `source_name`, `captured_at` (winner's), `crm_action`, `existing_account_id`, `existing_contact_id`. Sort by `clean_contact_id` ascending.198- `duplicate_summary`: `duplicate_removed_count` = total duplicate losers; `duplicate_keys` as above.199- `removal_summary`: `unusable_removed_count` = missing_contact count; `suppressed_removed_count`; `removed_rows` = every removed row `{row_id, reason}` with reason ∈ {duplicate, missing_contact, suppressed}, sorted by `row_id` ascending.200- `import_action_totals`: integer counts over the **deduped** rows (winners + suppressed + unusable, i.e. excluding duplicate losers) for `create_account`, `update_existing`, `no_import` (missing_contact rows), `suppress`.201- `campaign_member_import_count` = number of surviving cleaned contacts (the clean_contacts list length).202203---204205## 9. Common misjudgments to avoid206207- **Don't add or rename keys/enums.** Copy the template's exact enum spellings (e.g. `sensor_vendor_only` vs `sensor_only`; presence/absence of `not_sponsor`).208- **Canceled sponsors are not active sponsors.** Either `not_sponsor` or excluded (`inactive_sponsor_record`) depending on the enum — never `proposal_only`.209- **Open-invoice total uses full package `amount`; the balance is a separate field** (amount − paid_amount). Don't put the balance in the revenue total.210- **Proposal-stage accounts are on the sponsor track**, so their attendees are `sponsor_attendee`, not qualified leads.211- **Match companies by domain, not display name** (manufacturing/Mfg., punctuation, suffixes vary).212- **Sensor-only and platform-using companies are NOT qualified prospects** — they are near-miss exclusions.213- **Use `end_date` (not start_date)** for all follow-up date arithmetic; add the event's own day-offset fields.214- **Lead opportunity amount comes from the event** (`lead_opportunity_amount`), not from CRM opportunity rows; CRM `opportunities` are usually for sponsor/closed deals, not the new leads.215- **Dedup winner = latest captured_at, tie-break lowest row_id.** A duplicate with an identical timestamp is the signal to use the row_id tie-breaker.216- **Suppression and missing-contact rows are removed, not in clean_contacts**, but still counted in `import_action_totals` and listed in `removal_summary`.217- **Contactable = email OR phone.** An empty email alone does not disqualify a row that has a phone.218- **Respect every `ordering` rule**; ties on the primary sort key fall back to the secondary key named in the template.219- Output **JSON only**, integers for all amounts/counts, dates as `YYYY-MM-DD`.