mirror of
https://github.com/getcargohq/cargo-skills.git
synced 2026-09-14 19:17:15 +08:00
2792b990ed
* feat(gtm): executable QA layer — fixture-tested contact-accuracy scripts
Four deterministic TypeScript scripts under cargo-gtm/scripts/ (Node >= 22.18
native type-stripping, zero deps in file mode) replace in-context row checking:
- validate-emails.ts free syntax/risk/duplicate cull before paid verifyEmail
- select-current-role.ts current role from an experiences array (job changers)
- validate-linkedin-names.ts name<->profile match (same-name decoys); P>=0.95/R>=0.85 gate
- contact-accuracy-audit.ts final per-row SEND/VERIFY/REVIEW/REMOVE stamp
Each reads --input CSV/JSON or a finished run directly (--workflow-uuid via
@cargo-ai/api, reusing the CLI's stored login) and self-tests with --fixtures
(152 synthetic cases total). New qa-script-fixtures CI job runs every suite on
push/PR. Doctrine in references/contact-accuracy.md ("run the script, don't
re-derive"); wired into the router (new section 8, spine step 8) and the
prospecting / outreach-activation verify steps. cargo-gtm -> 1.2.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(gtm): provider playbooks 6 → 20 + read-first gate (#54)
Fourteen new per-provider playbooks grounded in live `connection integration
get` schemas: sourcing/company-data (linkedin, proxycurl, apolloio, oceanio,
datagma), email/contact (hunter, prospeo, icypeas, findyMail, leadMagic,
contactOut, zeroBounce), research (firecrawl, serper). Each carries the action
table with credit costs, real config-shape examples, input quirks, cost traps,
and its recipe-spine position.
Router §11 opens with a hard read-first stop before any paid call to a covered
provider; the linter now errors when a playbook on disk is missing from the
SKILL.md catalog.
Grounding also surfaced and fixed a recipe bug: linkedin-url-lookup.md
documented findProfileUrl inputs that don't exist — the live schema is
fullName (required) + companyName. Stale cost-table rows (prospeo.verifyEmail,
serper pricing) flagged in the CHANGELOG for the next regeneration.
cargo-gtm -> 1.3.0.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(gtm): QA-chain review feedback — six wiring bugs in scripts and recipes
- contact-accuracy-audit.ts: detect a bare "status" header (raw waterfall
verifyEmail rows) as the verification column, lowest priority so specific
headers still win; two new fixture cases.
- lib/common.ts: accept raw execute-batch output ({"results": [...]} /
{"records": [...]}) in JSON inputs — no jq unwrap needed.
- validate-emails.ts + contact-accuracy-audit.ts: new --json flag emits rows
as a JSON array for jq chaining; duplicates now get recommendation "skip"
so they never enter the paid verify batch (savings count includes them).
- prospecting.md step 6/6.5: the paid verify batch is built from the culled
output (recommendation != "skip"), an explicit merge step joins statuses
back onto ALL rows before the audit, and only audit_action == "SEND" rows
proceed to write-back.
- outreach-activation.md step 4: same cull -> verify survivors -> merge ->
audit-all -> SEND-only flow; steps 5-6 now read the audited SEND set
(deliverable.json), with the step-5/6 jq paths fixed for array input.
- contact-accuracy.md: documents the never-pre-filter-to-valid rule, the
execute-batch input support, and --json.
All four fixture suites pass; the full recipe chain was exercised end-to-end
on simulated batch outputs (disposable + duplicate culled before payment,
catch-all single-source -> VERIFY, only SEND rows in the handoff set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gtm): case-insensitive status merge; strict identity coverage; dedupe verdict
- Recipes: the verification-status merge now joins on the lowercased email —
verifyEmail results may re-case the address, and a missed join left
emailStatus empty, silently degrading deliverable rows to VERIFY.
- contact-accuracy-audit.ts: identityColumnsPresent now requires BOTH
name_match and role_confidence columns — one upstream script alone no
longer masks partial identity coverage (partial-signals flag stays on).
- contact-accuracy-audit.ts: new duplicate-row REMOVE rule reading the
is_duplicate column — a later occurrence of an address never reaches the
sequencer even when all other signals are green (found while re-testing:
the case-insensitive join let the dupe inherit "valid" and SEND).
23 fixture cases pass; chain re-run with re-cased verify results yields
exactly one SEND.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gtm): merge verifyEmail via email_status, not status
waterfall.verifyEmail batch rows expose the verdict on email_status per the
connector output schema. The recipe merge jq read .status, so merged rows got
an empty emailStatus and contact-accuracy-audit stamped VERIFY instead of SEND.
Co-authored-by: Auree Aubert <aureeaubert@users.noreply.github.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Auree Aubert <aureeaubert@users.noreply.github.com>
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
// select-current-role.ts — deterministic current-role selection over the
|
||
// experience arrays returned by enrichment providers (LinkedIn scrapes,
|
||
// waterfall enrichment, peopleDataLabs).
|
||
//
|
||
// Picking the CURRENT role wrong is the #1 cause of emailing people who
|
||
// already left. This script selects one role per row, repairs titles that
|
||
// embed the company name ("VP Sales at Acme Corp"), and emits a confidence
|
||
// plus a machine-readable reason so downstream steps know when to re-verify.
|
||
//
|
||
// Usage:
|
||
// node select-current-role.ts --input rows.csv [--experiences-column experiences] [--output out.csv]
|
||
// node select-current-role.ts --workflow-uuid <uuid> [--batch-uuid <uuid>] [--output-node-slug <slug>]
|
||
// node select-current-role.ts --fixtures
|
||
//
|
||
// Runtime contract: Node >= 22.18 runs this file directly (native
|
||
// type-stripping) — erasable TypeScript only, node:* builtins only.
|
||
|
||
import { writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import {
|
||
parseArgs,
|
||
readRows,
|
||
readJson,
|
||
toCsv,
|
||
reportFixtureRun,
|
||
fail,
|
||
type Row,
|
||
} from "./lib/common.ts";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type Confidence = "high" | "medium" | "low";
|
||
|
||
export type RoleSelection = {
|
||
title: string;
|
||
company: string;
|
||
confidence: Confidence;
|
||
reason: string;
|
||
};
|
||
|
||
type YearMonth = { year: number; month: number };
|
||
|
||
type ParsedExperience = {
|
||
index: number;
|
||
title: string;
|
||
company: string;
|
||
start: YearMonth | undefined;
|
||
end: YearMonth | undefined;
|
||
/** Tri-state: true / false / not present (or not boolean-ish). */
|
||
currentFlag: boolean | undefined;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tolerant field parsing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const TITLE_KEYS = ["title", "jobTitle", "position"];
|
||
const COMPANY_KEYS = ["companyName", "company", "organization"];
|
||
const START_KEYS = ["startDate", "start_date", "dateFrom"];
|
||
const END_KEYS = ["endDate", "end_date", "dateTo"];
|
||
const CURRENT_KEYS = ["current", "isCurrent", "jobStillWorking"];
|
||
|
||
function isObject(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
/**
|
||
* Boolean-ish fields arrive as real booleans OR strings ("false", "False",
|
||
* "0", "", "true", "1", …). Never truthiness-test them — the string "false"
|
||
* is truthy, which is exactly the classic current-role bug.
|
||
*/
|
||
function parseBoolish(value: unknown): boolean | undefined {
|
||
if (typeof value === "boolean") return value;
|
||
if (typeof value === "number") return value !== 0;
|
||
if (typeof value === "string") {
|
||
const s = value.trim().toLowerCase();
|
||
if (s === "" || s === "false" || s === "0" || s === "no") return false;
|
||
if (s === "true" || s === "1" || s === "yes") return true;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Dates arrive as "YYYY", "YYYY-MM", "YYYY-MM-DD", ISO timestamps, bare year
|
||
* numbers, or objects {year, month}. Reduce them all to a comparable
|
||
* (year, month) tuple; missing month = January.
|
||
*/
|
||
function parseYearMonth(value: unknown): YearMonth | undefined {
|
||
if (value === null || value === undefined) return undefined;
|
||
if (isObject(value)) {
|
||
const year = Number(value.year);
|
||
if (!Number.isInteger(year) || year <= 0) return undefined;
|
||
const month = Number(value.month);
|
||
return { year, month: Number.isInteger(month) && month >= 1 && month <= 12 ? month : 1 };
|
||
}
|
||
if (typeof value === "number") {
|
||
return Number.isInteger(value) && value >= 1000 && value <= 9999
|
||
? { year: value, month: 1 }
|
||
: undefined;
|
||
}
|
||
if (typeof value === "string") {
|
||
const match = value.trim().match(/^(\d{4})(?:-(\d{1,2}))?/);
|
||
if (!match) return undefined;
|
||
const month = match[2] ? Number(match[2]) : 1;
|
||
return { year: Number(match[1]), month: month >= 1 && month <= 12 ? month : 1 };
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function compareYearMonth(a: YearMonth, b: YearMonth): number {
|
||
return a.year - b.year || a.month - b.month;
|
||
}
|
||
|
||
/** Compare optional tuples; a missing date sorts before any real date. */
|
||
function compareOptional(a: YearMonth | undefined, b: YearMonth | undefined): number {
|
||
if (a === undefined && b === undefined) return 0;
|
||
if (a === undefined) return -1;
|
||
if (b === undefined) return 1;
|
||
return compareYearMonth(a, b);
|
||
}
|
||
|
||
function extractString(exp: Record<string, unknown>, keys: string[]): string {
|
||
for (const key of keys) {
|
||
const value = exp[key];
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/** Company fields are strings or objects like {"name": "Acme"}. */
|
||
function extractCompany(exp: Record<string, unknown>): string {
|
||
for (const key of COMPANY_KEYS) {
|
||
const value = exp[key];
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
if (isObject(value) && typeof value.name === "string" && value.name.trim()) {
|
||
return value.name.trim();
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function extractWhen(exp: Record<string, unknown>, keys: string[]): YearMonth | undefined {
|
||
for (const key of keys) {
|
||
const parsed = parseYearMonth(exp[key]);
|
||
if (parsed) return parsed;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function extractCurrentFlag(exp: Record<string, unknown>): boolean | undefined {
|
||
for (const key of CURRENT_KEYS) {
|
||
const parsed = parseBoolish(exp[key]);
|
||
if (parsed !== undefined) return parsed;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function parseExperience(exp: Record<string, unknown>, index: number): ParsedExperience {
|
||
return {
|
||
index,
|
||
title: extractString(exp, TITLE_KEYS),
|
||
company: extractCompany(exp),
|
||
start: extractWhen(exp, START_KEYS),
|
||
end: extractWhen(exp, END_KEYS),
|
||
currentFlag: extractCurrentFlag(exp),
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Selection logic
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const NON_OPERATING_TITLE =
|
||
/\b(board member|board of directors|advisor|advisory|investor|angel|venture partner|volunteer|mentor|trustee|non[- ]executive)\b/i;
|
||
const NON_OPERATING_COMPANY = /\b(charity|charitable|volunteer(?:s|ing)?)\b/i;
|
||
|
||
function isNonOperating(exp: ParsedExperience): boolean {
|
||
return NON_OPERATING_TITLE.test(exp.title) || NON_OPERATING_COMPANY.test(exp.company);
|
||
}
|
||
|
||
/**
|
||
* A role is active when its current-flag is true, its end date is missing, or
|
||
* its end date is now-or-later. An explicit false flag with a missing end
|
||
* date means "left, end date unknown" — not active.
|
||
*/
|
||
function isActive(exp: ParsedExperience, now: YearMonth): boolean {
|
||
if (exp.currentFlag === true) return true;
|
||
if (exp.end === undefined) return exp.currentFlag !== false;
|
||
return compareYearMonth(exp.end, now) >= 0;
|
||
}
|
||
|
||
/** Latest startDate; tie → prefer current-flag true, then the one listed first. */
|
||
function pickLatestByStart(candidates: ParsedExperience[]): ParsedExperience {
|
||
let best = candidates[0];
|
||
for (const exp of candidates.slice(1)) {
|
||
const cmp = compareOptional(exp.start, best.start);
|
||
if (cmp > 0) best = exp;
|
||
else if (cmp === 0 && exp.currentFlag === true && best.currentFlag !== true) best = exp;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Title repair — scraped titles often embed the company name
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const TITLE_SEPARATOR = /\s+(?:at|@|-|\|)\s+/gi;
|
||
const LEGAL_SUFFIX = /\b(?:inc|llc|ltd|corp|gmbh)$/;
|
||
|
||
function normalizeCompanyName(name: string): string {
|
||
let s = name
|
||
.toLowerCase()
|
||
.replace(/[.,'’]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
let previous;
|
||
do {
|
||
previous = s;
|
||
s = s.replace(LEGAL_SUFFIX, "").trim();
|
||
} while (s !== previous);
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* Strip a trailing "at Acme Corp" / "@ Acme" / "- Acme" / "| Acme" segment
|
||
* when it equals the company name (ignoring case and Inc/LLC/Ltd/Corp/GmbH).
|
||
* Titles whose trailing segment does NOT match the company are left alone
|
||
* ("Head of Data at Scale" at Meridian Analytics stays intact).
|
||
*/
|
||
function repairTitle(title: string, company: string): { title: string; repaired: boolean } {
|
||
const target = normalizeCompanyName(company);
|
||
if (!title || !target) return { title, repaired: false };
|
||
const matches = [...title.matchAll(TITLE_SEPARATOR)];
|
||
for (let i = matches.length - 1; i >= 0; i--) {
|
||
const match = matches[i];
|
||
const trailing = title.slice(match.index + match[0].length);
|
||
if (normalizeCompanyName(trailing) === target) {
|
||
const head = title.slice(0, match.index).trim();
|
||
if (head) return { title: head, repaired: true };
|
||
}
|
||
}
|
||
return { title, repaired: false };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// selectCurrentRole — the pure function under fixture test
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function selectCurrentRole(experiences: unknown): RoleSelection {
|
||
let list = experiences;
|
||
if (typeof list === "string") {
|
||
try {
|
||
list = JSON.parse(list);
|
||
} catch {
|
||
list = undefined;
|
||
}
|
||
}
|
||
if (!Array.isArray(list)) {
|
||
return { title: "", company: "", confidence: "low", reason: "unparseable-experiences" };
|
||
}
|
||
const parsed = list.filter(isObject).map(parseExperience);
|
||
if (parsed.length === 0) {
|
||
return { title: "", company: "", confidence: "low", reason: "no-experiences" };
|
||
}
|
||
|
||
const now = new Date();
|
||
const nowTuple: YearMonth = { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||
const active = parsed.filter((exp) => isActive(exp, nowTuple));
|
||
const operating = active.filter((exp) => !isNonOperating(exp));
|
||
|
||
let picked: ParsedExperience;
|
||
let confidence: Confidence;
|
||
let reason: string;
|
||
|
||
if (operating.length > 0) {
|
||
picked = pickLatestByStart(operating);
|
||
const clearLatest = operating.every(
|
||
(exp) => exp === picked || compareOptional(exp.start, picked.start) < 0,
|
||
);
|
||
if (operating.length === 1) {
|
||
confidence = "high";
|
||
reason = "single-active-role";
|
||
} else if (clearLatest && picked.currentFlag === true) {
|
||
confidence = "high";
|
||
reason = "latest-active-current-flag";
|
||
} else {
|
||
confidence = "medium";
|
||
reason = "multiple-active-roles";
|
||
}
|
||
} else if (active.length > 0) {
|
||
// Only board/advisory/volunteer roles are active — usable, but weak.
|
||
picked = pickLatestByStart(active);
|
||
confidence = "low";
|
||
reason = "only-non-operating-roles";
|
||
} else {
|
||
// Everything ended: this person likely changed jobs — re-verify downstream.
|
||
picked = parsed[0];
|
||
for (const exp of parsed.slice(1)) {
|
||
if (compareOptional(exp.end ?? exp.start, picked.end ?? picked.start) > 0) picked = exp;
|
||
}
|
||
confidence = "low";
|
||
reason = "no-active-role";
|
||
}
|
||
|
||
const { title, repaired } = repairTitle(picked.title, picked.company);
|
||
if (repaired) reason += "; title-repaired";
|
||
return { title, company: picked.company, confidence, reason };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixture mode — every case must match expected exactly
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type FixtureCase = {
|
||
name: string;
|
||
experiences: unknown;
|
||
expected: { title: string; company: string; confidence: Confidence };
|
||
note?: string;
|
||
};
|
||
|
||
function runFixtures(): void {
|
||
const path = join(import.meta.dirname, "fixtures_current_role.json");
|
||
const { cases } = readJson<{ cases: FixtureCase[] }>(path);
|
||
const failures: string[] = [];
|
||
for (const testCase of cases) {
|
||
const got = selectCurrentRole(testCase.experiences);
|
||
const mismatches: string[] = [];
|
||
if (got.title !== testCase.expected.title) {
|
||
mismatches.push(`title "${got.title}" != "${testCase.expected.title}"`);
|
||
}
|
||
if (got.company !== testCase.expected.company) {
|
||
mismatches.push(`company "${got.company}" != "${testCase.expected.company}"`);
|
||
}
|
||
if (got.confidence !== testCase.expected.confidence) {
|
||
mismatches.push(`confidence "${got.confidence}" != "${testCase.expected.confidence}"`);
|
||
}
|
||
if (mismatches.length > 0) {
|
||
failures.push(`${testCase.name}: ${mismatches.join("; ")} (reason=${got.reason})`);
|
||
}
|
||
}
|
||
reportFixtureRun("select-current-role", { total: cases.length, failures });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Row mode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const COLUMN_CANDIDATES = ["experiences", "experience", "workExperience", "positions", "jobs"];
|
||
|
||
function resolveExperiencesColumn(rows: Row[], override: string | undefined): string {
|
||
const keys = Object.keys(rows[0] ?? {});
|
||
if (override) {
|
||
if (keys.includes(override)) return override;
|
||
return fail(
|
||
`--experiences-column "${override}" not found; available columns: ${keys.join(", ")}`,
|
||
);
|
||
}
|
||
const byLowercase = new Map(keys.map((key) => [key.toLowerCase(), key]));
|
||
for (const candidate of COLUMN_CANDIDATES) {
|
||
const match = byLowercase.get(candidate.toLowerCase());
|
||
if (match) return match;
|
||
}
|
||
return fail(
|
||
`no experiences column found (tried ${COLUMN_CANDIDATES.join(", ")}); ` +
|
||
"pass --experiences-column <name>",
|
||
);
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const args = parseArgs(process.argv.slice(2), {
|
||
value: [
|
||
"input",
|
||
"output",
|
||
"experiences-column",
|
||
"workflow-uuid",
|
||
"batch-uuid",
|
||
"output-node-slug",
|
||
"workspace-uuid",
|
||
],
|
||
boolean: ["fixtures"],
|
||
});
|
||
|
||
if (args.flags.has("fixtures")) {
|
||
runFixtures();
|
||
return;
|
||
}
|
||
|
||
const rows = await readRows(args);
|
||
if (rows.length === 0) fail("no input rows");
|
||
const column = resolveExperiencesColumn(rows, args.values.get("experiences-column"));
|
||
|
||
const enriched = rows.map((row) => {
|
||
const selection = selectCurrentRole(row[column]);
|
||
return {
|
||
...row,
|
||
current_title: selection.title,
|
||
current_company: selection.company,
|
||
role_confidence: selection.confidence,
|
||
role_reason: selection.reason,
|
||
};
|
||
});
|
||
|
||
const csv = toCsv(enriched);
|
||
const output = args.values.get("output");
|
||
if (output) {
|
||
writeFileSync(output, csv);
|
||
process.stderr.write(`wrote ${enriched.length} rows to ${output}\n`);
|
||
} else {
|
||
process.stdout.write(csv);
|
||
}
|
||
}
|
||
|
||
await main();
|