/** * Exa People Search — entity parsing, Apollo-shape projection, and match gating. * * Exa's People Search (`POST /search` with `category: "people"`) returns * structured person metadata on `results[].entities[]`, each * `{ id, type: "person", version, properties }`. The gateway passes Exa's * response through untouched (gateway/src/http.ts), so those entities arrive * here verbatim. * * Two jobs live in this file: * * 1. **Projection.** `toApolloEnvelope` rewrites a person entity into the * Apollo-shaped envelope the rest of the codebase already speaks, so the * existing extraction machinery (`extractEnrichmentValue` / * `apolloPath` / `crmFields.enrichment`) works against Exa with no new * extraction path. Exa becomes just another provider. * * 2. **Match gating.** Unlike FullEnrich and Aviato, Exa is a *search*, not a * lookup by identifier — it always returns *somebody*. Writing the top hit * into a CRM cell would silently corrupt data with a plausible-looking * stranger. `matchExaPerson` therefore refuses to return a candidate * unless identity is corroborated (exact LinkedIn URL, or name AND company * agreement), and callers are expected to surface `not_found` rather than * write a low-confidence guess. * * Everything here is pure and I/O-free so it can be unit tested directly, and * it lives in `cli/lib/` because that is where this repo keeps logic shared * between the `dench` CLI and the agent workflow steps (see * src/workflows/enrichment-steps.ts, which already imports from here). * * Exa's docs are explicit that profile sources vary in what they include, so * every field is treated as optional and every nested value defensively. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type ExaDateRange = { from?: string | null; to?: string | null; }; export type ExaCompanyRef = { id?: string | null; name?: string | null; }; export type ExaWorkHistoryItem = { title?: string; location?: string; dates?: ExaDateRange | null; company?: ExaCompanyRef | null; }; export type ExaInstitutionRef = { id?: string | null; name?: string | null; }; export type ExaEducationItem = { degree?: string; dates?: ExaDateRange | null; institution?: ExaInstitutionRef | null; }; export type ExaPerson = { /** Stable Exa person entity id, when present. */ id?: string; name?: string; firstName?: string; lastName?: string; location?: string; workHistory: ExaWorkHistoryItem[]; educationHistory: ExaEducationItem[]; }; export type ExaPersonHit = { /** Profile URL from the enclosing search result. May be "" if absent. */ url: string; title?: string; highlights?: string[]; person: ExaPerson; }; // --------------------------------------------------------------------------- // Defensive readers // --------------------------------------------------------------------------- export function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } export function asObject(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } export function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function asStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const out = value.filter( (item): item is string => typeof item === "string" && item.trim().length > 0, ); return out.length > 0 ? out : undefined; } /** * `dates` is documented as `{ from, to } | null`, but a source may omit it * entirely or hand back a bare string. Anything we cannot read as an object * becomes `null` — "unknown", which is deliberately NOT the same as * `{ to: null }` ("still there"). `currentRole` depends on that distinction. */ function readDates(value: unknown): ExaDateRange | null { const raw = asObject(value); if (!raw) return null; const from = asString(raw.from); const to = asString(raw.to); const range: ExaDateRange = {}; if (from !== undefined) range.from = from; // Preserve an explicit null `to` — it is the "current role" signal. if (to !== undefined) range.to = to; else if ("to" in raw) range.to = null; return range; } function readCompanyRef(value: unknown): ExaCompanyRef | null { const raw = asObject(value); if (!raw) return null; const name = asString(raw.name); const id = asString(raw.id); if (name === undefined && id === undefined) return null; return { id: id ?? null, name: name ?? null }; } function readWorkHistory(value: unknown): ExaWorkHistoryItem[] { const out: ExaWorkHistoryItem[] = []; for (const entry of asArray(value)) { const raw = asObject(entry); if (!raw) continue; const item: ExaWorkHistoryItem = { title: asString(raw.title), location: asString(raw.location), dates: readDates(raw.dates), company: readCompanyRef(raw.company), }; // A row with nothing usable in it is noise, not history. if (!item.title && !item.company && !item.dates) continue; out.push(item); } return out; } function readEducationHistory(value: unknown): ExaEducationItem[] { const out: ExaEducationItem[] = []; for (const entry of asArray(value)) { const raw = asObject(entry); if (!raw) continue; const item: ExaEducationItem = { degree: asString(raw.degree), dates: readDates(raw.dates), institution: readCompanyRef(raw.institution), }; if (!item.degree && !item.institution) continue; out.push(item); } return out; } // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /** * Walk an Exa `/search` response and pull out every person entity. * * Only `type: "person"` entities are kept — the docs describe `entities` as * populated "for result rows that resolve to a person", so a result without * one is a page we could not resolve, not a person with no data. */ export function parseExaPeopleResults(data: unknown): ExaPersonHit[] { const root = asObject(data); if (!root) return []; const hits: ExaPersonHit[] = []; for (const entry of asArray(root.results)) { const result = asObject(entry); if (!result) continue; const url = asString(result.url) ?? asString(result.link) ?? ""; const title = asString(result.title); const highlights = asStringArray(result.highlights); for (const entityEntry of asArray(result.entities)) { const entity = asObject(entityEntry); if (!entity) continue; if (asString(entity.type) !== "person") continue; const properties = asObject(entity.properties); if (!properties) continue; const person: ExaPerson = { id: asString(entity.id), name: asString(properties.name), firstName: asString(properties.firstName), lastName: asString(properties.lastName), location: asString(properties.location), workHistory: readWorkHistory(properties.workHistory), educationHistory: readEducationHistory(properties.educationHistory), }; hits.push({ url, title, highlights, person }); } } return hits; } /** Full name, falling back to first + last when `name` is absent. */ export function personFullName(person: ExaPerson): string | undefined { if (person.name) return person.name; const parts = [person.firstName, person.lastName].filter( (part): part is string => typeof part === "string" && part.length > 0, ); return parts.length > 0 ? parts.join(" ") : undefined; } /** * Best guess at the person's current role. * * Only an entry whose `dates.to` is explicitly null counts as current. Ended * or undated roles are historical/unknown; projecting either as current writes * stale employment data into CRM fields. */ export function currentRole(person: ExaPerson): ExaWorkHistoryItem | null { const history = person.workHistory; if (history.length === 0) return null; const open = history.filter( (item) => item.dates != null && "to" in item.dates && item.dates.to == null, ); if (open.length > 0) { return open.reduce((best, item) => compareDateStrings(item.dates?.from, best.dates?.from) > 0 ? item : best, ); } return null; } /** * Order two partial date strings. Exa mixes granularities ("2014", * "2022-01-01"), but both are zero-padded and year-first, so lexicographic * comparison orders them correctly. Missing sorts before present. */ function compareDateStrings( a: string | null | undefined, b: string | null | undefined, ): number { const left = asString(a); const right = asString(b); if (left === right) return 0; if (left === undefined) return -1; if (right === undefined) return 1; return left < right ? -1 : 1; } // --------------------------------------------------------------------------- // Apollo-shape projection // --------------------------------------------------------------------------- /** * Prefix a bare host with a scheme so `new URL` can parse it. * * The scheme test must be case-insensitive: "HTTPS://Example.com" does not * start with lowercase "http", so a naive check prepends a second scheme and * `new URL("https://HTTPS://Example.com").hostname` quietly evaluates to * "https" — a wrong domain rather than a parse error. CRM data is pasted by * humans, so mixed-case URLs really do show up. */ export function withScheme(value: string): string { return /^https?:\/\//i.test(value) ? value : `https://${value}`; } const LINKEDIN_PROFILE_PATTERN = /(^|\.)linkedin\.com$/i; /** True when the URL looks like a LinkedIn *person* profile (`/in/...`). */ export function isLinkedInProfileUrl(url: string): boolean { const trimmed = url.trim(); if (!trimmed) return false; try { const parsed = new URL(withScheme(trimmed)); return ( LINKEDIN_PROFILE_PATTERN.test(parsed.hostname) && /\/in\/[^/]+/i.test(parsed.pathname) ); } catch { return false; } } /** * Split Exa's single location string into the city/state/country fields the * `__computed.location` extractor reads (see `computeLocation` in * cli/lib/crm-enrichment.ts, which joins city + state + country). * * Exa formats these as "San Francisco, California, United States". We only * assign parts when the shape is unambiguous; the raw string is always kept * on `person.location` regardless, which is a declared extraction fallback. */ function splitLocation(location: string | undefined): { city?: string; state?: string; country?: string; } { if (!location) return {}; const parts = location .split(",") .map((part) => part.trim()) .filter((part) => part.length > 0); if (parts.length >= 3) { return { city: parts[0], state: parts[1], country: parts[parts.length - 1], }; } if (parts.length === 2) return { city: parts[0], country: parts[1] }; if (parts.length === 1) return { city: parts[0] }; return {}; } /** * Project a person hit into the Apollo-shaped envelope the existing * enrichment extractors understand. * * The keys here are load-bearing: they are the `apolloPath` targets declared * by enrichment columns. `extractApolloValue` (cli/lib/crm-enrichment.ts) is a * generic dot-path resolver, so any path listed in an Exa-backed column * definition must exist in this object. */ export function toApolloEnvelope(hit: ExaPersonHit): Record { const { person } = hit; const role = currentRole(person); const title = role?.title; const companyName = role?.company?.name ?? undefined; const name = personFullName(person); const { city, state, country } = splitLocation(person.location); const employmentHistory = person.workHistory.map((item) => ({ title: item.title ?? null, organization_name: item.company?.name ?? null, location: item.location ?? null, start_date: item.dates?.from ?? null, end_date: item.dates?.to ?? null, current: item.dates != null && "to" in item.dates && item.dates.to == null, })); const education = person.educationHistory.map((item) => ({ degree: item.degree ?? null, school_name: item.institution?.name ?? null, start_date: item.dates?.from ?? null, end_date: item.dates?.to ?? null, })); const personEnvelope: Record = { id: person.id ?? null, name: name ?? null, first_name: person.firstName ?? null, last_name: person.lastName ?? null, title: title ?? null, headline: title && companyName ? `${title} at ${companyName}` : (title ?? null), location: person.location ?? null, city: city ?? null, state: state ?? null, country: country ?? null, organization: companyName ? { name: companyName } : null, organization_name: companyName ?? null, linkedin_url: isLinkedInProfileUrl(hit.url) ? hit.url : null, employment_history: employmentHistory, education, }; return { person: personEnvelope, // Top-level organization mirror so company-category columns // (apolloPath "organization.name") resolve against the same payload. organization: companyName ? { name: companyName } : null, // Provenance — this is a search result, not an identifier lookup, so the // source URL matters when a human audits the written cell. // Provenance the model and a human auditor both want — the profile this // came from. Deliberately NOT the provider name: this envelope is // returned as the tool result, and naming the source there is exactly // what the enrichment surface is meant to keep internal. source: { url: hit.url || null }, }; } // --------------------------------------------------------------------------- // Match gating // --------------------------------------------------------------------------- export type ExaMatchCriteria = | { kind: "linkedin_url"; linkedinUrl: string } | { kind: "name_company"; fullName?: string; firstName?: string; lastName?: string; organizationName?: string; }; export type ExaMatchConfidence = "exact" | "high"; export type ExaPersonMatch = { hit: ExaPersonHit; confidence: ExaMatchConfidence; /** Human-readable reason the candidate was accepted, for observability. */ matchedOn: string; }; export type ExaMatchOutcome = | { status: "matched"; match: ExaPersonMatch } | { status: "no_match" } | { status: "insufficient_criteria"; missing: string[] }; /** * Build the natural-language query for resolving ONE known person. * * Exa People Search takes no structured filters at all — every constraint has * to live in the sentence (see exa-search-constraints.ts). Naming the company * alongside the person is also what makes the match gate able to corroborate * identity, so it is included whenever we have it. */ export function buildPersonLookupQuery(criteria: ExaMatchCriteria): string { if (criteria.kind === "linkedin_url") return criteria.linkedinUrl; const name = criteria.fullName ?? [criteria.firstName, criteria.lastName].filter(Boolean).join(" "); return criteria.organizationName ? `${name} at ${criteria.organizationName}` : name; } /** Lowercase, strip combining marks and punctuation, preserve every script. */ export function normalizeName(value: string): string { return value .normalize("NFKD") .replace(/(\p{Script=Latin})\p{M}+/gu, "$1") .toLocaleLowerCase() .replace(/[^\p{L}\p{N}\p{M}]+/gu, " ") .trim() .normalize("NFC"); } const TRAILING_LEGAL_SUFFIXES = new Set([ "inc", "incorporated", "llc", "ltd", "limited", "corp", "corporation", "co", "gmbh", "bv", "nv", "sa", "ag", "plc", "pte", "pty", ]); /** * Normalize a company name and drop only trailing legal forms. Removing these * words from the middle destroys real brands ("The Company Store", "Acme * Labs") and can collapse unrelated companies onto the same identity. */ export function normalizeCompany(value: string): string { const tokens = normalizeName(value).split(" ").filter(Boolean); while ( tokens.length > 1 && TRAILING_LEGAL_SUFFIXES.has(tokens[tokens.length - 1]) ) { tokens.pop(); } return tokens.join(" "); } /** * Reduce a LinkedIn URL to a comparable identity: no protocol, no `www.`, no * query or hash, no trailing slash, lowercased. */ export function normalizeLinkedInUrl(value: string): string { const trimmed = value.trim(); if (!trimmed) return ""; let working = trimmed; try { const parsed = new URL(withScheme(trimmed)); const profile = parsed.pathname.match(/^\/in\/([^/]+)/i); working = LINKEDIN_PROFILE_PATTERN.test(parsed.hostname) && profile ? `linkedin.com/in/${profile[1]}` : `${parsed.hostname}${parsed.pathname}`; } catch { working = trimmed.split(/[?#]/)[0]; } return working .toLowerCase() .replace(/^www\./, "") .replace(/\/+$/, ""); } /** * Company identity evidence must be exact after legal-suffix normalization. * Substring matching makes short names dangerous ("Meta" matched * "Metaverse"), which defeats the two-factor name + employer gate. */ function companiesAgree(left: string, right: string): boolean { const a = normalizeCompany(left); const b = normalizeCompany(right); if (!a || !b) return false; return a === b; } /** * First and last name must agree positionally. Matching an initial is allowed * only for the candidate's final surname token. * * One concession to how LinkedIn actually stores names: a surname abbreviated * to an initial on the CANDIDATE side ("Adarsh K.") counts as agreeing with a * full wanted token starting with that letter ("Adarsh Kumar"). Without it * these profiles never match their own CRM row — abbreviated surnames showed * up in the first handful of live results. It is safe here only because the * caller additionally requires company agreement, so a false positive needs * the same first name AND same surname initial AND the same employer. */ function namesAgree( candidate: string, first: string | undefined, last: string | undefined, ): boolean { const normalizedCandidate = normalizeName(candidate); if (!normalizedCandidate) return false; const candidateTokens = normalizedCandidate.split(" ").filter(Boolean); const wantedFirst = first ? normalizeName(first).split(" ").filter(Boolean) : []; const wantedLast = last ? normalizeName(last).split(" ").filter(Boolean) : []; if ( candidateTokens.length < 2 || wantedFirst.length === 0 || wantedLast.length === 0 ) { return false; } const candidateFirst = candidateTokens[0]; const candidateLast = candidateTokens[candidateTokens.length - 1]; const requestedFirst = wantedFirst[0]; const requestedLast = wantedLast[wantedLast.length - 1]; const firstMatches = candidateFirst === requestedFirst; const lastMatches = candidateLast === requestedLast || (candidateLast.length === 1 && requestedLast.length > 1 && requestedLast.startsWith(candidateLast)); return firstMatches && lastMatches; } function splitFullName(fullName: string): { firstName?: string; lastName?: string; } { const parts = normalizeName(fullName) .split(" ") .filter((part) => part.length > 0); if (parts.length < 2) return {}; return { firstName: parts[0], lastName: parts[parts.length - 1] }; } /** * Pick the one candidate we are willing to write into a CRM cell, or refuse. * * This is the data-integrity boundary for Exa-backed enrichment. Exa ranks by * relevance and always returns *someone*, so "the first result" is never * sufficient evidence of identity. A caller that gets `no_match` must report * `not_found` — it must not fall back to `hits[0]`. */ export function matchExaPerson( hits: ExaPersonHit[], criteria: ExaMatchCriteria, ): ExaMatchOutcome { if (criteria.kind === "linkedin_url") { const wanted = normalizeLinkedInUrl(criteria.linkedinUrl); if (!wanted || !isLinkedInProfileUrl(criteria.linkedinUrl)) { return { status: "insufficient_criteria", missing: ["linkedinUrl"] }; } for (const hit of hits) { if ( hit.url && isLinkedInProfileUrl(hit.url) && normalizeLinkedInUrl(hit.url) === wanted ) { return { status: "matched", match: { hit, confidence: "exact", matchedOn: "linkedin_url" }, }; } } return { status: "no_match" }; } // name_company: identity needs BOTH halves. A name alone cannot // disambiguate — the index has many people per common name, and picking one // is exactly how the wrong person's title lands in a cell. const derived = criteria.fullName ? splitFullName(criteria.fullName) : {}; const firstName = criteria.firstName ?? derived.firstName; const lastName = criteria.lastName ?? derived.lastName; const organizationName = criteria.organizationName?.trim(); // Structured as one guard so both names and the company narrow to `string` // for the comparisons below. if (!firstName || !lastName || !organizationName) { const missing: string[] = []; if (!firstName || !lastName) missing.push("name"); if (!organizationName) missing.push("organizationName"); return { status: "insufficient_criteria", missing }; } const currentMatches = new Map(); for (const hit of hits) { const candidateName = personFullName(hit.person); if (!candidateName) continue; if (!namesAgree(candidateName, firstName, lastName)) continue; // Check every role at the target company, not just the best-guess current // one: `currentRole` falls back to the most recently *ended* role, so // keying off it would read "left Example AI in 2018" as "works at Example // AI now" for anyone with a single, finished stint there. const rolesAtCompany = hit.person.workHistory.filter( (item) => item.company?.name && companiesAgree(item.company.name, organizationName), ); if (rolesAtCompany.length === 0) continue; if ( rolesAtCompany.some( (item) => item.dates != null && "to" in item.dates && item.dates.to == null, ) ) { const identity = hit.person.id || normalizeLinkedInUrl(hit.url) || `${normalizeName(candidateName)}:${currentMatches.size}`; currentMatches.set(identity, { hit, confidence: "high", matchedOn: "name+current_company", }); } // Ended or undated employment is not enough to claim the CRM's company is // current. False negatives are safer than writing another person's data. } if (currentMatches.size === 1) { return { status: "matched", match: [...currentMatches.values()][0], }; } return { status: "no_match" }; }