@dench.com/cli 2.7.4 → 2.7.6-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/crm.ts +30 -9
- package/dench.ts +1 -1
- package/host.ts +1 -1
- package/lib/api-schemas.ts +101 -5
- package/lib/command-registry.ts +103 -1
- package/lib/enrichment-gateway.ts +61 -0
- package/lib/exa-companies.ts +389 -0
- package/lib/exa-people.ts +682 -0
- package/lib/exa-search-constraints.ts +70 -0
- package/package.json +1 -1
- package/search.ts +204 -9
- package/tools.ts +108 -8
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exa People Search — entity parsing, Apollo-shape projection, and match gating.
|
|
3
|
+
*
|
|
4
|
+
* Exa's People Search (`POST /search` with `category: "people"`) returns
|
|
5
|
+
* structured person metadata on `results[].entities[]`, each
|
|
6
|
+
* `{ id, type: "person", version, properties }`. The gateway passes Exa's
|
|
7
|
+
* response through untouched (gateway/src/http.ts), so those entities arrive
|
|
8
|
+
* here verbatim.
|
|
9
|
+
*
|
|
10
|
+
* Two jobs live in this file:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Projection.** `toApolloEnvelope` rewrites a person entity into the
|
|
13
|
+
* Apollo-shaped envelope the rest of the codebase already speaks, so the
|
|
14
|
+
* existing extraction machinery (`extractEnrichmentValue` /
|
|
15
|
+
* `apolloPath` / `crmFields.enrichment`) works against Exa with no new
|
|
16
|
+
* extraction path. Exa becomes just another provider.
|
|
17
|
+
*
|
|
18
|
+
* 2. **Match gating.** Unlike FullEnrich and Aviato, Exa is a *search*, not a
|
|
19
|
+
* lookup by identifier — it always returns *somebody*. Writing the top hit
|
|
20
|
+
* into a CRM cell would silently corrupt data with a plausible-looking
|
|
21
|
+
* stranger. `matchExaPerson` therefore refuses to return a candidate
|
|
22
|
+
* unless identity is corroborated (exact LinkedIn URL, or name AND company
|
|
23
|
+
* agreement), and callers are expected to surface `not_found` rather than
|
|
24
|
+
* write a low-confidence guess.
|
|
25
|
+
*
|
|
26
|
+
* Everything here is pure and I/O-free so it can be unit tested directly, and
|
|
27
|
+
* it lives in `cli/lib/` because that is where this repo keeps logic shared
|
|
28
|
+
* between the `dench` CLI and the agent workflow steps (see
|
|
29
|
+
* src/workflows/enrichment-steps.ts, which already imports from here).
|
|
30
|
+
*
|
|
31
|
+
* Exa's docs are explicit that profile sources vary in what they include, so
|
|
32
|
+
* every field is treated as optional and every nested value defensively.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Types
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export type ExaDateRange = {
|
|
40
|
+
from?: string | null;
|
|
41
|
+
to?: string | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ExaCompanyRef = {
|
|
45
|
+
id?: string | null;
|
|
46
|
+
name?: string | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ExaWorkHistoryItem = {
|
|
50
|
+
title?: string;
|
|
51
|
+
location?: string;
|
|
52
|
+
dates?: ExaDateRange | null;
|
|
53
|
+
company?: ExaCompanyRef | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ExaInstitutionRef = {
|
|
57
|
+
id?: string | null;
|
|
58
|
+
name?: string | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type ExaEducationItem = {
|
|
62
|
+
degree?: string;
|
|
63
|
+
dates?: ExaDateRange | null;
|
|
64
|
+
institution?: ExaInstitutionRef | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ExaPerson = {
|
|
68
|
+
/** Stable Exa person entity id, when present. */
|
|
69
|
+
id?: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
firstName?: string;
|
|
72
|
+
lastName?: string;
|
|
73
|
+
location?: string;
|
|
74
|
+
workHistory: ExaWorkHistoryItem[];
|
|
75
|
+
educationHistory: ExaEducationItem[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type ExaPersonHit = {
|
|
79
|
+
/** Profile URL from the enclosing search result. May be "" if absent. */
|
|
80
|
+
url: string;
|
|
81
|
+
title?: string;
|
|
82
|
+
highlights?: string[];
|
|
83
|
+
person: ExaPerson;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Defensive readers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export function asString(value: unknown): string | undefined {
|
|
91
|
+
if (typeof value !== "string") return undefined;
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
97
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
98
|
+
? (value as Record<string, unknown>)
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function asArray(value: unknown): unknown[] {
|
|
103
|
+
return Array.isArray(value) ? value : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function asStringArray(value: unknown): string[] | undefined {
|
|
107
|
+
if (!Array.isArray(value)) return undefined;
|
|
108
|
+
const out = value.filter(
|
|
109
|
+
(item): item is string =>
|
|
110
|
+
typeof item === "string" && item.trim().length > 0,
|
|
111
|
+
);
|
|
112
|
+
return out.length > 0 ? out : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `dates` is documented as `{ from, to } | null`, but a source may omit it
|
|
117
|
+
* entirely or hand back a bare string. Anything we cannot read as an object
|
|
118
|
+
* becomes `null` — "unknown", which is deliberately NOT the same as
|
|
119
|
+
* `{ to: null }` ("still there"). `currentRole` depends on that distinction.
|
|
120
|
+
*/
|
|
121
|
+
function readDates(value: unknown): ExaDateRange | null {
|
|
122
|
+
const raw = asObject(value);
|
|
123
|
+
if (!raw) return null;
|
|
124
|
+
const from = asString(raw.from);
|
|
125
|
+
const to = asString(raw.to);
|
|
126
|
+
const range: ExaDateRange = {};
|
|
127
|
+
if (from !== undefined) range.from = from;
|
|
128
|
+
// Preserve an explicit null `to` — it is the "current role" signal.
|
|
129
|
+
if (to !== undefined) range.to = to;
|
|
130
|
+
else if ("to" in raw) range.to = null;
|
|
131
|
+
return range;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readCompanyRef(value: unknown): ExaCompanyRef | null {
|
|
135
|
+
const raw = asObject(value);
|
|
136
|
+
if (!raw) return null;
|
|
137
|
+
const name = asString(raw.name);
|
|
138
|
+
const id = asString(raw.id);
|
|
139
|
+
if (name === undefined && id === undefined) return null;
|
|
140
|
+
return { id: id ?? null, name: name ?? null };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readWorkHistory(value: unknown): ExaWorkHistoryItem[] {
|
|
144
|
+
const out: ExaWorkHistoryItem[] = [];
|
|
145
|
+
for (const entry of asArray(value)) {
|
|
146
|
+
const raw = asObject(entry);
|
|
147
|
+
if (!raw) continue;
|
|
148
|
+
const item: ExaWorkHistoryItem = {
|
|
149
|
+
title: asString(raw.title),
|
|
150
|
+
location: asString(raw.location),
|
|
151
|
+
dates: readDates(raw.dates),
|
|
152
|
+
company: readCompanyRef(raw.company),
|
|
153
|
+
};
|
|
154
|
+
// A row with nothing usable in it is noise, not history.
|
|
155
|
+
if (!item.title && !item.company && !item.dates) continue;
|
|
156
|
+
out.push(item);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readEducationHistory(value: unknown): ExaEducationItem[] {
|
|
162
|
+
const out: ExaEducationItem[] = [];
|
|
163
|
+
for (const entry of asArray(value)) {
|
|
164
|
+
const raw = asObject(entry);
|
|
165
|
+
if (!raw) continue;
|
|
166
|
+
const item: ExaEducationItem = {
|
|
167
|
+
degree: asString(raw.degree),
|
|
168
|
+
dates: readDates(raw.dates),
|
|
169
|
+
institution: readCompanyRef(raw.institution),
|
|
170
|
+
};
|
|
171
|
+
if (!item.degree && !item.institution) continue;
|
|
172
|
+
out.push(item);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Parsing
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Walk an Exa `/search` response and pull out every person entity.
|
|
183
|
+
*
|
|
184
|
+
* Only `type: "person"` entities are kept — the docs describe `entities` as
|
|
185
|
+
* populated "for result rows that resolve to a person", so a result without
|
|
186
|
+
* one is a page we could not resolve, not a person with no data.
|
|
187
|
+
*/
|
|
188
|
+
export function parseExaPeopleResults(data: unknown): ExaPersonHit[] {
|
|
189
|
+
const root = asObject(data);
|
|
190
|
+
if (!root) return [];
|
|
191
|
+
const hits: ExaPersonHit[] = [];
|
|
192
|
+
|
|
193
|
+
for (const entry of asArray(root.results)) {
|
|
194
|
+
const result = asObject(entry);
|
|
195
|
+
if (!result) continue;
|
|
196
|
+
const url = asString(result.url) ?? asString(result.link) ?? "";
|
|
197
|
+
const title = asString(result.title);
|
|
198
|
+
const highlights = asStringArray(result.highlights);
|
|
199
|
+
|
|
200
|
+
for (const entityEntry of asArray(result.entities)) {
|
|
201
|
+
const entity = asObject(entityEntry);
|
|
202
|
+
if (!entity) continue;
|
|
203
|
+
if (asString(entity.type) !== "person") continue;
|
|
204
|
+
const properties = asObject(entity.properties);
|
|
205
|
+
if (!properties) continue;
|
|
206
|
+
|
|
207
|
+
const person: ExaPerson = {
|
|
208
|
+
id: asString(entity.id),
|
|
209
|
+
name: asString(properties.name),
|
|
210
|
+
firstName: asString(properties.firstName),
|
|
211
|
+
lastName: asString(properties.lastName),
|
|
212
|
+
location: asString(properties.location),
|
|
213
|
+
workHistory: readWorkHistory(properties.workHistory),
|
|
214
|
+
educationHistory: readEducationHistory(properties.educationHistory),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
hits.push({ url, title, highlights, person });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return hits;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Full name, falling back to first + last when `name` is absent. */
|
|
225
|
+
export function personFullName(person: ExaPerson): string | undefined {
|
|
226
|
+
if (person.name) return person.name;
|
|
227
|
+
const parts = [person.firstName, person.lastName].filter(
|
|
228
|
+
(part): part is string => typeof part === "string" && part.length > 0,
|
|
229
|
+
);
|
|
230
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Best guess at the person's current role.
|
|
235
|
+
*
|
|
236
|
+
* Only an entry whose `dates.to` is explicitly null counts as current. Ended
|
|
237
|
+
* or undated roles are historical/unknown; projecting either as current writes
|
|
238
|
+
* stale employment data into CRM fields.
|
|
239
|
+
*/
|
|
240
|
+
export function currentRole(person: ExaPerson): ExaWorkHistoryItem | null {
|
|
241
|
+
const history = person.workHistory;
|
|
242
|
+
if (history.length === 0) return null;
|
|
243
|
+
|
|
244
|
+
const open = history.filter(
|
|
245
|
+
(item) => item.dates != null && "to" in item.dates && item.dates.to == null,
|
|
246
|
+
);
|
|
247
|
+
if (open.length > 0) {
|
|
248
|
+
return open.reduce((best, item) =>
|
|
249
|
+
compareDateStrings(item.dates?.from, best.dates?.from) > 0 ? item : best,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Order two partial date strings. Exa mixes granularities ("2014",
|
|
257
|
+
* "2022-01-01"), but both are zero-padded and year-first, so lexicographic
|
|
258
|
+
* comparison orders them correctly. Missing sorts before present.
|
|
259
|
+
*/
|
|
260
|
+
function compareDateStrings(
|
|
261
|
+
a: string | null | undefined,
|
|
262
|
+
b: string | null | undefined,
|
|
263
|
+
): number {
|
|
264
|
+
const left = asString(a);
|
|
265
|
+
const right = asString(b);
|
|
266
|
+
if (left === right) return 0;
|
|
267
|
+
if (left === undefined) return -1;
|
|
268
|
+
if (right === undefined) return 1;
|
|
269
|
+
return left < right ? -1 : 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Apollo-shape projection
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Prefix a bare host with a scheme so `new URL` can parse it.
|
|
278
|
+
*
|
|
279
|
+
* The scheme test must be case-insensitive: "HTTPS://Example.com" does not
|
|
280
|
+
* start with lowercase "http", so a naive check prepends a second scheme and
|
|
281
|
+
* `new URL("https://HTTPS://Example.com").hostname` quietly evaluates to
|
|
282
|
+
* "https" — a wrong domain rather than a parse error. CRM data is pasted by
|
|
283
|
+
* humans, so mixed-case URLs really do show up.
|
|
284
|
+
*/
|
|
285
|
+
export function withScheme(value: string): string {
|
|
286
|
+
return /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const LINKEDIN_PROFILE_PATTERN = /(^|\.)linkedin\.com$/i;
|
|
290
|
+
|
|
291
|
+
/** True when the URL looks like a LinkedIn *person* profile (`/in/...`). */
|
|
292
|
+
export function isLinkedInProfileUrl(url: string): boolean {
|
|
293
|
+
const trimmed = url.trim();
|
|
294
|
+
if (!trimmed) return false;
|
|
295
|
+
try {
|
|
296
|
+
const parsed = new URL(withScheme(trimmed));
|
|
297
|
+
return (
|
|
298
|
+
LINKEDIN_PROFILE_PATTERN.test(parsed.hostname) &&
|
|
299
|
+
/\/in\/[^/]+/i.test(parsed.pathname)
|
|
300
|
+
);
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Split Exa's single location string into the city/state/country fields the
|
|
308
|
+
* `__computed.location` extractor reads (see `computeLocation` in
|
|
309
|
+
* cli/lib/crm-enrichment.ts, which joins city + state + country).
|
|
310
|
+
*
|
|
311
|
+
* Exa formats these as "San Francisco, California, United States". We only
|
|
312
|
+
* assign parts when the shape is unambiguous; the raw string is always kept
|
|
313
|
+
* on `person.location` regardless, which is a declared extraction fallback.
|
|
314
|
+
*/
|
|
315
|
+
function splitLocation(location: string | undefined): {
|
|
316
|
+
city?: string;
|
|
317
|
+
state?: string;
|
|
318
|
+
country?: string;
|
|
319
|
+
} {
|
|
320
|
+
if (!location) return {};
|
|
321
|
+
const parts = location
|
|
322
|
+
.split(",")
|
|
323
|
+
.map((part) => part.trim())
|
|
324
|
+
.filter((part) => part.length > 0);
|
|
325
|
+
if (parts.length >= 3) {
|
|
326
|
+
return {
|
|
327
|
+
city: parts[0],
|
|
328
|
+
state: parts[1],
|
|
329
|
+
country: parts[parts.length - 1],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (parts.length === 2) return { city: parts[0], country: parts[1] };
|
|
333
|
+
if (parts.length === 1) return { city: parts[0] };
|
|
334
|
+
return {};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Project a person hit into the Apollo-shaped envelope the existing
|
|
339
|
+
* enrichment extractors understand.
|
|
340
|
+
*
|
|
341
|
+
* The keys here are load-bearing: they are the `apolloPath` targets declared
|
|
342
|
+
* by enrichment columns. `extractApolloValue` (cli/lib/crm-enrichment.ts) is a
|
|
343
|
+
* generic dot-path resolver, so any path listed in an Exa-backed column
|
|
344
|
+
* definition must exist in this object.
|
|
345
|
+
*/
|
|
346
|
+
export function toApolloEnvelope(hit: ExaPersonHit): Record<string, unknown> {
|
|
347
|
+
const { person } = hit;
|
|
348
|
+
const role = currentRole(person);
|
|
349
|
+
const title = role?.title;
|
|
350
|
+
const companyName = role?.company?.name ?? undefined;
|
|
351
|
+
const name = personFullName(person);
|
|
352
|
+
const { city, state, country } = splitLocation(person.location);
|
|
353
|
+
|
|
354
|
+
const employmentHistory = person.workHistory.map((item) => ({
|
|
355
|
+
title: item.title ?? null,
|
|
356
|
+
organization_name: item.company?.name ?? null,
|
|
357
|
+
location: item.location ?? null,
|
|
358
|
+
start_date: item.dates?.from ?? null,
|
|
359
|
+
end_date: item.dates?.to ?? null,
|
|
360
|
+
current: item.dates != null && "to" in item.dates && item.dates.to == null,
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
const education = person.educationHistory.map((item) => ({
|
|
364
|
+
degree: item.degree ?? null,
|
|
365
|
+
school_name: item.institution?.name ?? null,
|
|
366
|
+
start_date: item.dates?.from ?? null,
|
|
367
|
+
end_date: item.dates?.to ?? null,
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
const personEnvelope: Record<string, unknown> = {
|
|
371
|
+
id: person.id ?? null,
|
|
372
|
+
name: name ?? null,
|
|
373
|
+
first_name: person.firstName ?? null,
|
|
374
|
+
last_name: person.lastName ?? null,
|
|
375
|
+
title: title ?? null,
|
|
376
|
+
headline:
|
|
377
|
+
title && companyName ? `${title} at ${companyName}` : (title ?? null),
|
|
378
|
+
location: person.location ?? null,
|
|
379
|
+
city: city ?? null,
|
|
380
|
+
state: state ?? null,
|
|
381
|
+
country: country ?? null,
|
|
382
|
+
organization: companyName ? { name: companyName } : null,
|
|
383
|
+
organization_name: companyName ?? null,
|
|
384
|
+
linkedin_url: isLinkedInProfileUrl(hit.url) ? hit.url : null,
|
|
385
|
+
employment_history: employmentHistory,
|
|
386
|
+
education,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
person: personEnvelope,
|
|
391
|
+
// Top-level organization mirror so company-category columns
|
|
392
|
+
// (apolloPath "organization.name") resolve against the same payload.
|
|
393
|
+
organization: companyName ? { name: companyName } : null,
|
|
394
|
+
// Provenance — this is a search result, not an identifier lookup, so the
|
|
395
|
+
// source URL matters when a human audits the written cell.
|
|
396
|
+
// Provenance the model and a human auditor both want — the profile this
|
|
397
|
+
// came from. Deliberately NOT the provider name: this envelope is
|
|
398
|
+
// returned as the tool result, and naming the source there is exactly
|
|
399
|
+
// what the enrichment surface is meant to keep internal.
|
|
400
|
+
source: { url: hit.url || null },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Match gating
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
export type ExaMatchCriteria =
|
|
409
|
+
| { kind: "linkedin_url"; linkedinUrl: string }
|
|
410
|
+
| {
|
|
411
|
+
kind: "name_company";
|
|
412
|
+
fullName?: string;
|
|
413
|
+
firstName?: string;
|
|
414
|
+
lastName?: string;
|
|
415
|
+
organizationName?: string;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
export type ExaMatchConfidence = "exact" | "high";
|
|
419
|
+
|
|
420
|
+
export type ExaPersonMatch = {
|
|
421
|
+
hit: ExaPersonHit;
|
|
422
|
+
confidence: ExaMatchConfidence;
|
|
423
|
+
/** Human-readable reason the candidate was accepted, for observability. */
|
|
424
|
+
matchedOn: string;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
export type ExaMatchOutcome =
|
|
428
|
+
| { status: "matched"; match: ExaPersonMatch }
|
|
429
|
+
| { status: "no_match" }
|
|
430
|
+
| { status: "insufficient_criteria"; missing: string[] };
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Build the natural-language query for resolving ONE known person.
|
|
434
|
+
*
|
|
435
|
+
* Exa People Search takes no structured filters at all — every constraint has
|
|
436
|
+
* to live in the sentence (see exa-search-constraints.ts). Naming the company
|
|
437
|
+
* alongside the person is also what makes the match gate able to corroborate
|
|
438
|
+
* identity, so it is included whenever we have it.
|
|
439
|
+
*/
|
|
440
|
+
export function buildPersonLookupQuery(criteria: ExaMatchCriteria): string {
|
|
441
|
+
if (criteria.kind === "linkedin_url") return criteria.linkedinUrl;
|
|
442
|
+
const name =
|
|
443
|
+
criteria.fullName ??
|
|
444
|
+
[criteria.firstName, criteria.lastName].filter(Boolean).join(" ");
|
|
445
|
+
return criteria.organizationName
|
|
446
|
+
? `${name} at ${criteria.organizationName}`
|
|
447
|
+
: name;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Lowercase, strip combining marks and punctuation, preserve every script. */
|
|
451
|
+
export function normalizeName(value: string): string {
|
|
452
|
+
return value
|
|
453
|
+
.normalize("NFKD")
|
|
454
|
+
.replace(/(\p{Script=Latin})\p{M}+/gu, "$1")
|
|
455
|
+
.toLocaleLowerCase()
|
|
456
|
+
.replace(/[^\p{L}\p{N}\p{M}]+/gu, " ")
|
|
457
|
+
.trim()
|
|
458
|
+
.normalize("NFC");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const TRAILING_LEGAL_SUFFIXES = new Set([
|
|
462
|
+
"inc",
|
|
463
|
+
"incorporated",
|
|
464
|
+
"llc",
|
|
465
|
+
"ltd",
|
|
466
|
+
"limited",
|
|
467
|
+
"corp",
|
|
468
|
+
"corporation",
|
|
469
|
+
"co",
|
|
470
|
+
"gmbh",
|
|
471
|
+
"bv",
|
|
472
|
+
"nv",
|
|
473
|
+
"sa",
|
|
474
|
+
"ag",
|
|
475
|
+
"plc",
|
|
476
|
+
"pte",
|
|
477
|
+
"pty",
|
|
478
|
+
]);
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Normalize a company name and drop only trailing legal forms. Removing these
|
|
482
|
+
* words from the middle destroys real brands ("The Company Store", "Acme
|
|
483
|
+
* Labs") and can collapse unrelated companies onto the same identity.
|
|
484
|
+
*/
|
|
485
|
+
export function normalizeCompany(value: string): string {
|
|
486
|
+
const tokens = normalizeName(value).split(" ").filter(Boolean);
|
|
487
|
+
while (
|
|
488
|
+
tokens.length > 1 &&
|
|
489
|
+
TRAILING_LEGAL_SUFFIXES.has(tokens[tokens.length - 1])
|
|
490
|
+
) {
|
|
491
|
+
tokens.pop();
|
|
492
|
+
}
|
|
493
|
+
return tokens.join(" ");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Reduce a LinkedIn URL to a comparable identity: no protocol, no `www.`, no
|
|
498
|
+
* query or hash, no trailing slash, lowercased.
|
|
499
|
+
*/
|
|
500
|
+
export function normalizeLinkedInUrl(value: string): string {
|
|
501
|
+
const trimmed = value.trim();
|
|
502
|
+
if (!trimmed) return "";
|
|
503
|
+
let working = trimmed;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = new URL(withScheme(trimmed));
|
|
506
|
+
const profile = parsed.pathname.match(/^\/in\/([^/]+)/i);
|
|
507
|
+
working =
|
|
508
|
+
LINKEDIN_PROFILE_PATTERN.test(parsed.hostname) && profile
|
|
509
|
+
? `linkedin.com/in/${profile[1]}`
|
|
510
|
+
: `${parsed.hostname}${parsed.pathname}`;
|
|
511
|
+
} catch {
|
|
512
|
+
working = trimmed.split(/[?#]/)[0];
|
|
513
|
+
}
|
|
514
|
+
return working
|
|
515
|
+
.toLowerCase()
|
|
516
|
+
.replace(/^www\./, "")
|
|
517
|
+
.replace(/\/+$/, "");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Company identity evidence must be exact after legal-suffix normalization.
|
|
522
|
+
* Substring matching makes short names dangerous ("Meta" matched
|
|
523
|
+
* "Metaverse"), which defeats the two-factor name + employer gate.
|
|
524
|
+
*/
|
|
525
|
+
function companiesAgree(left: string, right: string): boolean {
|
|
526
|
+
const a = normalizeCompany(left);
|
|
527
|
+
const b = normalizeCompany(right);
|
|
528
|
+
if (!a || !b) return false;
|
|
529
|
+
return a === b;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* First and last name must agree positionally. Matching an initial is allowed
|
|
534
|
+
* only for the candidate's final surname token.
|
|
535
|
+
*
|
|
536
|
+
* One concession to how LinkedIn actually stores names: a surname abbreviated
|
|
537
|
+
* to an initial on the CANDIDATE side ("Adarsh K.") counts as agreeing with a
|
|
538
|
+
* full wanted token starting with that letter ("Adarsh Kumar"). Without it
|
|
539
|
+
* these profiles never match their own CRM row — abbreviated surnames showed
|
|
540
|
+
* up in the first handful of live results. It is safe here only because the
|
|
541
|
+
* caller additionally requires company agreement, so a false positive needs
|
|
542
|
+
* the same first name AND same surname initial AND the same employer.
|
|
543
|
+
*/
|
|
544
|
+
function namesAgree(
|
|
545
|
+
candidate: string,
|
|
546
|
+
first: string | undefined,
|
|
547
|
+
last: string | undefined,
|
|
548
|
+
): boolean {
|
|
549
|
+
const normalizedCandidate = normalizeName(candidate);
|
|
550
|
+
if (!normalizedCandidate) return false;
|
|
551
|
+
const candidateTokens = normalizedCandidate.split(" ").filter(Boolean);
|
|
552
|
+
const wantedFirst = first
|
|
553
|
+
? normalizeName(first).split(" ").filter(Boolean)
|
|
554
|
+
: [];
|
|
555
|
+
const wantedLast = last ? normalizeName(last).split(" ").filter(Boolean) : [];
|
|
556
|
+
if (
|
|
557
|
+
candidateTokens.length < 2 ||
|
|
558
|
+
wantedFirst.length === 0 ||
|
|
559
|
+
wantedLast.length === 0
|
|
560
|
+
) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const candidateFirst = candidateTokens[0];
|
|
565
|
+
const candidateLast = candidateTokens[candidateTokens.length - 1];
|
|
566
|
+
const requestedFirst = wantedFirst[0];
|
|
567
|
+
const requestedLast = wantedLast[wantedLast.length - 1];
|
|
568
|
+
const firstMatches = candidateFirst === requestedFirst;
|
|
569
|
+
const lastMatches =
|
|
570
|
+
candidateLast === requestedLast ||
|
|
571
|
+
(candidateLast.length === 1 &&
|
|
572
|
+
requestedLast.length > 1 &&
|
|
573
|
+
requestedLast.startsWith(candidateLast));
|
|
574
|
+
return firstMatches && lastMatches;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function splitFullName(fullName: string): {
|
|
578
|
+
firstName?: string;
|
|
579
|
+
lastName?: string;
|
|
580
|
+
} {
|
|
581
|
+
const parts = normalizeName(fullName)
|
|
582
|
+
.split(" ")
|
|
583
|
+
.filter((part) => part.length > 0);
|
|
584
|
+
if (parts.length < 2) return {};
|
|
585
|
+
return { firstName: parts[0], lastName: parts[parts.length - 1] };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Pick the one candidate we are willing to write into a CRM cell, or refuse.
|
|
590
|
+
*
|
|
591
|
+
* This is the data-integrity boundary for Exa-backed enrichment. Exa ranks by
|
|
592
|
+
* relevance and always returns *someone*, so "the first result" is never
|
|
593
|
+
* sufficient evidence of identity. A caller that gets `no_match` must report
|
|
594
|
+
* `not_found` — it must not fall back to `hits[0]`.
|
|
595
|
+
*/
|
|
596
|
+
export function matchExaPerson(
|
|
597
|
+
hits: ExaPersonHit[],
|
|
598
|
+
criteria: ExaMatchCriteria,
|
|
599
|
+
): ExaMatchOutcome {
|
|
600
|
+
if (criteria.kind === "linkedin_url") {
|
|
601
|
+
const wanted = normalizeLinkedInUrl(criteria.linkedinUrl);
|
|
602
|
+
if (!wanted || !isLinkedInProfileUrl(criteria.linkedinUrl)) {
|
|
603
|
+
return { status: "insufficient_criteria", missing: ["linkedinUrl"] };
|
|
604
|
+
}
|
|
605
|
+
for (const hit of hits) {
|
|
606
|
+
if (
|
|
607
|
+
hit.url &&
|
|
608
|
+
isLinkedInProfileUrl(hit.url) &&
|
|
609
|
+
normalizeLinkedInUrl(hit.url) === wanted
|
|
610
|
+
) {
|
|
611
|
+
return {
|
|
612
|
+
status: "matched",
|
|
613
|
+
match: { hit, confidence: "exact", matchedOn: "linkedin_url" },
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { status: "no_match" };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// name_company: identity needs BOTH halves. A name alone cannot
|
|
621
|
+
// disambiguate — the index has many people per common name, and picking one
|
|
622
|
+
// is exactly how the wrong person's title lands in a cell.
|
|
623
|
+
const derived = criteria.fullName ? splitFullName(criteria.fullName) : {};
|
|
624
|
+
const firstName = criteria.firstName ?? derived.firstName;
|
|
625
|
+
const lastName = criteria.lastName ?? derived.lastName;
|
|
626
|
+
const organizationName = criteria.organizationName?.trim();
|
|
627
|
+
|
|
628
|
+
// Structured as one guard so both names and the company narrow to `string`
|
|
629
|
+
// for the comparisons below.
|
|
630
|
+
if (!firstName || !lastName || !organizationName) {
|
|
631
|
+
const missing: string[] = [];
|
|
632
|
+
if (!firstName || !lastName) missing.push("name");
|
|
633
|
+
if (!organizationName) missing.push("organizationName");
|
|
634
|
+
return { status: "insufficient_criteria", missing };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const currentMatches = new Map<string, ExaPersonMatch>();
|
|
638
|
+
for (const hit of hits) {
|
|
639
|
+
const candidateName = personFullName(hit.person);
|
|
640
|
+
if (!candidateName) continue;
|
|
641
|
+
if (!namesAgree(candidateName, firstName, lastName)) continue;
|
|
642
|
+
|
|
643
|
+
// Check every role at the target company, not just the best-guess current
|
|
644
|
+
// one: `currentRole` falls back to the most recently *ended* role, so
|
|
645
|
+
// keying off it would read "left Example AI in 2018" as "works at Example
|
|
646
|
+
// AI now" for anyone with a single, finished stint there.
|
|
647
|
+
const rolesAtCompany = hit.person.workHistory.filter(
|
|
648
|
+
(item) =>
|
|
649
|
+
item.company?.name &&
|
|
650
|
+
companiesAgree(item.company.name, organizationName),
|
|
651
|
+
);
|
|
652
|
+
if (rolesAtCompany.length === 0) continue;
|
|
653
|
+
|
|
654
|
+
if (
|
|
655
|
+
rolesAtCompany.some(
|
|
656
|
+
(item) =>
|
|
657
|
+
item.dates != null && "to" in item.dates && item.dates.to == null,
|
|
658
|
+
)
|
|
659
|
+
) {
|
|
660
|
+
const identity =
|
|
661
|
+
hit.person.id ||
|
|
662
|
+
normalizeLinkedInUrl(hit.url) ||
|
|
663
|
+
`${normalizeName(candidateName)}:${currentMatches.size}`;
|
|
664
|
+
currentMatches.set(identity, {
|
|
665
|
+
hit,
|
|
666
|
+
confidence: "high",
|
|
667
|
+
matchedOn: "name+current_company",
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Ended or undated employment is not enough to claim the CRM's company is
|
|
672
|
+
// current. False negatives are safer than writing another person's data.
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (currentMatches.size === 1) {
|
|
676
|
+
return {
|
|
677
|
+
status: "matched",
|
|
678
|
+
match: [...currentMatches.values()][0],
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return { status: "no_match" };
|
|
682
|
+
}
|