// The single extractor "projection" interpreter. // // "Projection" is the step that turns a provider response into the value a // target (email, email_status, phone, ...) resolves to. Historically this was // reimplemented in three runtimes (the V2 tool-result runtime, the playground // waterfall runtime, and the emitted V1-enrich play) with divergent precedence // — a latent drift bug class. This module is the one authoritative // implementation: callers supply a `ProjectionLookup` that knows how to walk // THEIR payload shape, and `project` applies the one fixed precedence. // // Leaf module: depends only on email-status.ts + extractor-targets.ts, no other // runtime code, no Zod — safe inside the size-capped workers-lite bundle. // // See docs/extractor-projection-unification.md and CONTEXT.md ("Provider Email // Status Contract"). import { buildEmailStatus } from './email-status'; import type { EmailStatusExtractorConfig } from './email-status'; import { JOB_CHANGE_STATUS_VALUES, type JobChangeGetterValue, type JobChangeStatus, } from './extractor-targets'; /** A located value (value + the concrete path it came from), or null on miss. */ export type ProjectionHit = { value: unknown; path: string } | null; /** * The only seam-crossing dependency. Each runtime supplies an adapter that * resolves a list of candidate paths against its own payload shape and returns * the first meaningful hit (or null). This absorbs the input-shape difference * (V2 `{toolResponse:{raw}}` envelope vs playground raw payload vs the enrich * play's pre-projected getters); the interpreter itself is payload-agnostic. */ export type ProjectionLookup = (paths: readonly string[]) => ProjectionHit; export type ProjectionOverrideRule = { paths: readonly string[]; equals?: string | number | boolean | null; value: string | number | boolean | null; }; /** The serializable descriptor shape (a structural subset of * ToolResultExtractorDescriptor — descriptors stay plain JSON on the wire). */ export type ProjectionDescriptor = { paths: readonly string[]; transforms?: readonly string[]; enum?: readonly string[]; overrides?: readonly ProjectionOverrideRule[]; emailStatus?: EmailStatusExtractorConfig; }; // --- small value helpers (kept local so this module stays a dependency-free leaf) --- function normalizeString(value: unknown): string | null { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed ? trimmed : null; } if (typeof value === 'number' && Number.isFinite(value)) { return String(value); } return null; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } // --- typed-signal coercions (one home for every status normalizer) --- function normalizePhoneStatus(value: unknown): unknown { if (typeof value === 'boolean') return value ? 'valid' : 'invalid'; const normalized = normalizeString(value) ?.toLowerCase() .replace(/[\s-]+/g, '_'); if (!normalized) return 'unknown'; if (['verified', 'ok', 'true', 'active'].includes(normalized)) return 'valid'; if ( ['bad', 'false', 'failed', 'inactive', 'disconnected'].includes(normalized) ) { return 'invalid'; } return normalized; } function normalizeJobChangeStatus(value: unknown): unknown { if (typeof value === 'boolean') return value ? 'moved' : 'no_change'; const normalized = normalizeString(value) ?.toLowerCase() .replace(/[\s-]+/g, '_'); if (!normalized) return 'unknown'; if (['true', 'yes', 'moved', 'changed', 'new_company'].includes(normalized)) { return 'moved'; } if (['false', 'no', 'same', 'no_change'].includes(normalized)) return 'no_change'; if (['left', 'left_company'].includes(normalized)) return 'left_company'; if ((JOB_CHANGE_STATUS_VALUES as readonly string[]).includes(normalized)) { return normalized; } return 'unknown'; } function firstExperienceDate(value: unknown): string | null { if (!Array.isArray(value)) return null; for (const entry of value) { if (!isRecord(entry)) continue; const date = normalizeString( entry.start_date ?? entry.started_at ?? entry.startDate, ); if (date) return date; } return null; } function normalizeJobChange(value: unknown): JobChangeGetterValue { const record = isRecord(value) ? value : {}; const nested = isRecord(record.job_change) ? record.job_change : record; const output = isRecord(nested.output) ? nested.output : nested; const person = isRecord(output.person) ? output.person : {}; const status = normalizeJobChangeStatus( output.status ?? output.job_change_status ?? output.job_changed ?? output.changed, ) as JobChangeStatus; const moved = status === 'moved'; return { status, date: moved ? normalizeString( output.date ?? output.job_change_date ?? output.change_date ?? output.changed_at, ) ?? firstExperienceDate(person.experiences) : null, new_company: moved ? normalizeString( output.new_company ?? output.current_company ?? person.company_name ?? person.current_company, ) : null, new_title: moved ? normalizeString( output.new_title ?? output.current_title ?? person.title ?? person.headline, ) : null, }; } function applyTransforms( value: unknown, transforms: readonly string[] | undefined, ): unknown { return (transforms ?? []).reduce((current, transform) => { // email_status is materialized by emailStatus (the Provider Email Status // Contract), never by a string transform — so an `emailStatus` transform is // a no-op and falls through to `return current`. if (transform.endsWith('phoneStatus')) return normalizePhoneStatus(current); if (transform === 'jobChange') return normalizeJobChange(current); if (transform === 'jobChangeStatus') return normalizeJobChangeStatus(current); return current; }, value); } function coerceToEnum( value: unknown, enumValues: readonly string[] | undefined, ): unknown { if (!enumValues?.length) return value; const normalized = normalizeString(value); if (!normalized) return value; return enumValues.includes(normalized) ? normalized : value; } function findOverride( overrides: readonly ProjectionOverrideRule[] | undefined, lookup: ProjectionLookup, ): { value: string | number | boolean | null } | null { for (const override of overrides ?? []) { const expected = Object.prototype.hasOwnProperty.call(override, 'equals') ? override.equals : true; for (const path of override.paths) { const match = lookup([path]); if (!match) continue; if (match.value === expected) return { value: override.value }; } } return null; } function projectEmailStatus( config: EmailStatusExtractorConfig, lookup: ProjectionLookup, ): ProjectionHit { const values: Record = {}; const pathSets: Record = { rawStatus: config.rawStatus, rawScore: config.rawScore, valid: config.valid, deliverability: config.deliverability, catchAll: config.catchAll, mxProvider: config.mxProvider, mxRecord: config.mxRecord, fraudScore: config.fraudScore, disposable: config.disposable, roleBased: config.roleBased, freeEmail: config.freeEmail, abuse: config.abuse, spamtrap: config.spamtrap, suspect: config.suspect, }; let firstPath: string | null = null; for (const [name, paths] of Object.entries(pathSets)) { if (!paths) continue; const match = lookup(paths); if (!match) continue; values[name] = match.value; firstPath ??= match.path; } if (!firstPath) return null; return { path: firstPath, value: buildEmailStatus({ config, values }) }; } /** * Project one extractor descriptor against a payload (via `lookup`). * * Fixed precedence (identical for every runtime): * 1. emailStatus → buildEmailStatus(...) (terminal; never transformed/enum'd) * 2. paths → first meaningful hit * 3. transforms → phoneStatus | jobChange | jobChangeStatus * 4. enum → keep value if not in the set * 5. overrides → a matching override wins over the resolved value * * Returns null when nothing meaningful resolves (caller omits the target). */ export function project( descriptor: ProjectionDescriptor, lookup: ProjectionLookup, ): ProjectionHit { if (descriptor.emailStatus) { return projectEmailStatus(descriptor.emailStatus, lookup); } const base = lookup(descriptor.paths); if (!base) return null; const transformed = coerceToEnum( applyTransforms(base.value, descriptor.transforms), descriptor.enum, ); const override = findOverride(descriptor.overrides, lookup); return { path: base.path, value: override?.value ?? transformed }; }