@odla-ai/kg 0.4.1 → 0.5.0

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/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { G as GraphFragment, W as WeightedPair, U as Usage, R as RawItem, E as ExtractResult } from './lenses-C7Q7Gvnx.cjs';
2
2
  export { a as GraphEdge, b as GraphNode, c as addUsage, e as emptyUsage } from './lenses-C7Q7Gvnx.cjs';
3
3
  import { Op, AdminDb, SerializedAttr, Cardinality, SerializedSchema, Value, Entity } from '@odla-ai/db';
4
- import { ExtractTool, Ai } from '@odla-ai/ai';
4
+ import { ExtractTool, SearchCitation, Ai } from '@odla-ai/ai';
5
5
 
6
6
  /** node type → unique natural-key attribute name. */
7
7
  type NaturalKeys = Record<string, string>;
@@ -237,6 +237,120 @@ declare function factsToFragment(company: CompanyRow, draft: FactsDraft): GraphF
237
237
  /** The `located_in` write linking a company to its resolved county. */
238
238
  declare function locationFragment(slug: string, name: string, fips: string): GraphFragment;
239
239
 
240
+ /**
241
+ * What a research pass concluded about the entity it was asked to find.
242
+ *
243
+ * `wrong_entity` is deliberately distinct from `not_found`: a pass that
244
+ * confidently describes a *different* person with the same name is a much worse
245
+ * failure than one that admits it found nothing, and a benchmark that collapses
246
+ * the two cannot see it.
247
+ */
248
+ type Verdict = "confirmed" | "probable" | "ambiguous" | "not_found" | "wrong_entity";
249
+ /** Verdicts that permit writing enriched facts to the graph. */
250
+ declare const WRITABLE_VERDICTS: readonly Verdict[];
251
+ /** One supporting claim tied to the source that backs it. */
252
+ interface Evidence {
253
+ /** What the source establishes, in the model's words. */
254
+ claim: string;
255
+ /** The URL the claim came from. */
256
+ sourceUrl: string;
257
+ /** When the source was read, as an ISO date. */
258
+ retrievedAt?: string;
259
+ }
260
+ /** A verdict plus the confidence and evidence offered for it. */
261
+ interface Verification {
262
+ verdict: Verdict;
263
+ /** The model's stated confidence, clamped to 0..1. Scored for calibration, never trusted. */
264
+ confidence: number;
265
+ evidence: Evidence[];
266
+ }
267
+ /** Whether a value is one of the closed verdict members. */
268
+ declare function isVerdict(value: unknown): value is Verdict;
269
+ /** Whether a verdict permits writing the enriched facts it came with. */
270
+ declare function isWritable(verdict: Verdict): boolean;
271
+ /**
272
+ * Normalize a model's self-reported verification into the closed shape.
273
+ *
274
+ * Fails safe in both directions: an unrecognized verdict becomes `ambiguous`
275
+ * (never `confirmed`), a missing or out-of-range confidence is clamped, and
276
+ * evidence without a usable http(s) source URL is dropped — an unsourced claim
277
+ * is exactly the thing this package exists to stop treating as a fact.
278
+ */
279
+ declare function normalizeVerification(draft: unknown, retrievedAt?: string): Verification;
280
+ /** Whether a string is an absolute http(s) URL — the only kind evidence may cite. */
281
+ declare function isHttpUrl(value: string): boolean;
282
+
283
+ /** The minimal person row enrichment reads. */
284
+ interface PersonRow {
285
+ slug: string;
286
+ name: string;
287
+ /** Natural key of a company the person is already linked to — the strongest disambiguator. */
288
+ companySlug?: string;
289
+ companyName?: string;
290
+ role?: string;
291
+ city?: string;
292
+ region?: string;
293
+ }
294
+ /** Where a person publishes. Kinds with one canonical host are validated against it. */
295
+ type PersonLinkKind = "website" | "blog" | "substack" | "linkedin" | "x" | "bluesky" | "github" | "other";
296
+ /** System prompt for the grounded person research pass. */
297
+ declare const PERSON_SEARCH_SYSTEM: string;
298
+ /** System prompt for structuring the research notes into {@link PersonDraft}. */
299
+ declare const PERSON_EXTRACT_SYSTEM: string;
300
+ /** Forced-tool schema for a person's verified public profile. */
301
+ declare const PERSON_EXTRACT_TOOL: ExtractTool;
302
+ /** The model's structured person profile, before validation into a fragment. */
303
+ interface PersonDraft {
304
+ verdict?: string;
305
+ confidence?: number;
306
+ links?: Array<{
307
+ kind?: string;
308
+ url?: string;
309
+ }>;
310
+ employer?: string | null;
311
+ role?: string | null;
312
+ businessEmail?: string | null;
313
+ businessPhone?: string | null;
314
+ city?: string | null;
315
+ region?: string | null;
316
+ almaMater?: string | null;
317
+ otherCompanies?: string[];
318
+ evidence?: Array<{
319
+ claim?: string;
320
+ sourceUrl?: string;
321
+ }>;
322
+ }
323
+ /** A validated person link. */
324
+ interface PersonLink {
325
+ kind: PersonLinkKind;
326
+ url: string;
327
+ }
328
+ /** The validated result of one person enrichment pass. */
329
+ interface PersonResult {
330
+ verification: Verification;
331
+ links: PersonLink[];
332
+ fragment: GraphFragment;
333
+ }
334
+ /** The user prompt for a person research pass. */
335
+ declare function personPrompt(person: PersonRow): string;
336
+ /**
337
+ * Keep only links that are plausibly real: absolute https URLs, on the canonical
338
+ * host when the kind has one, and never a placeholder domain. Models invent
339
+ * `linkedin.com/in/first-last` from a name constantly; a kind/host mismatch is
340
+ * the cheapest way to catch it before it becomes a stored "fact".
341
+ */
342
+ declare function validateLinks(raw: PersonDraft["links"], limit?: number): PersonLink[];
343
+ /**
344
+ * Turn one person's structured draft into a verdict plus an idempotent fragment.
345
+ *
346
+ * The fragment is empty unless the verdict is writable — an `ambiguous`,
347
+ * `not_found`, or `wrong_entity` pass contributes no graph, however confident
348
+ * the model sounded. The verdict and its evidence are still returned, because
349
+ * "this lane confidently found the wrong person" is exactly what a benchmark
350
+ * needs to record.
351
+ */
352
+ declare function personToFragment(person: PersonRow, draft: PersonDraft, retrievedAt?: string): PersonResult;
353
+
240
354
  /** Which model serves each verb (search must be a web-grounded model). */
241
355
  interface EnrichModels {
242
356
  /** Web-search-grounded research model. */
@@ -251,6 +365,23 @@ interface EnrichModels {
251
365
  * fragment (possibly empty — nothing established); the caller writes it.
252
366
  */
253
367
  declare function enrichCompany(ai: Ai, models: EnrichModels, company: CompanyRow): Promise<GraphFragment>;
368
+ /** One person enrichment pass, with the sources the provider actually cited. */
369
+ interface PersonEnrichment extends PersonResult {
370
+ /** Sources the search model attributed. Empty when the provider grounded nothing. */
371
+ citations: SearchCitation[];
372
+ /** The research prose the extraction was taken from — useful for adjudication. */
373
+ notes: string;
374
+ }
375
+ /**
376
+ * Enrich one person: a grounded web search for their public professional
377
+ * presence, then a structured extraction into a validated verdict + fragment.
378
+ *
379
+ * Unlike {@link enrichCompany}, this always returns a verdict — including
380
+ * `not_found` and `wrong_entity` — because "the lane confidently identified the
381
+ * wrong person" is a result worth recording, not an error. The fragment is empty
382
+ * unless the verdict permits writing.
383
+ */
384
+ declare function enrichPerson(ai: Ai, models: EnrichModels, person: PersonRow, retrievedAt?: string): Promise<PersonEnrichment>;
254
385
  /**
255
386
  * Resolve a batch of companies' free-text locations to county FIPS codes via a
256
387
  * single structured extraction, joined against a caller-supplied gazetteer
@@ -541,6 +672,17 @@ declare function ingestFresh(step: StepLike, deps: PipelineDeps, fresh: RawItem[
541
672
  */
542
673
  /** NFKD-normalize, strip accents, lowercase, non-alphanumerics → "-", cap 60. */
543
674
  declare function slugify(input: string): string;
675
+ /**
676
+ * Collapse a name to a comparison key: accents stripped, `&` spelled out,
677
+ * punctuation and spacing removed, and trailing legal suffixes dropped.
678
+ *
679
+ * Stronger than comparing {@link slugify} output, because it matches
680
+ * "Acme Corp.", "Acme, Inc." and "Acme & Co" onto the same key — the shapes the
681
+ * same organization is actually written in across sources. Use it to decide
682
+ * whether two records are the same entity; use `slugify` for the natural key
683
+ * that gets stored.
684
+ */
685
+ declare function identityKey(value: string): string;
544
686
  /** Drop tracking params, hash, and trailing slash so the same page maps to one key. */
545
687
  declare function normalizeUrl(raw: string): string;
546
688
  /** Stable short key for a URL (FNV-1a 32-bit, base36). Used as the `signal` natural key. */
@@ -561,4 +703,4 @@ declare function htmlToText(html: string, maxChars?: number): string;
561
703
  /** Fetch a URL and return readable text. Best-effort: returns "" on any failure. */
562
704
  declare function fetchText(url: string, maxChars?: number): Promise<string>;
563
705
 
564
- export { type AttrSpec, type AttrType, COMMIT_STEP_CONFIG, type CompanyRow, type Connector, type ConnectorConfig, type ConnectorContext, type ConnectorJob, type ConnectorLens, type ConnectorReport, type ContextConfig, type EdgeSpec, type EnrichModels, type EntitySpec, type ExportEntitySpec, type ExportSpec, type ExtractCall, ExtractResult, FACTS_EXTRACT_SYSTEM, FACTS_EXTRACT_TOOL, FACTS_SEARCH_SYSTEM, type FactsDraft, GraphFragment, IO_STEP_CONFIG, type KVLike, LLM_STEP_CONFIG, LOCATE_SYSTEM, LOCATE_TOOL, type LlmConfig, type LlmEnv, MODEL_TIERS, type MergeRule, type NaturalKeys, type Ontology, type PipelineDeps, type Provider, type ProviderFamily, RawItem, type SearchCall, type SourceConnector, type SourceEntry, type StepConfig, type StepLike, Store, Usage, type WriteCall, allJobs, asStepLike, canonicalizeFragment, connectorIds, countyKey, createProvider, enabledSources, enrichCompany, exportFragment, extractJson, extractJsonArray, factsToFragment, fetchText, fetchUrl, findConnectors, fragmentToOps, getConnector, getProvider, htmlToText, humanAttrsOf, ingestFresh, isLikelyPersonName, jobsForHour, locatePrompt, locationFragment, makeContext, mergeAttrs, mergeRulesOf, mergeScalar, naturalKeysOf, normalizeUrl, resilientStep, resolveCounties, resolveName, searchEntities, slugify, toSerializedSchema, urlHash, writeFragment };
706
+ export { type AttrSpec, type AttrType, COMMIT_STEP_CONFIG, type CompanyRow, type Connector, type ConnectorConfig, type ConnectorContext, type ConnectorJob, type ConnectorLens, type ConnectorReport, type ContextConfig, type EdgeSpec, type EnrichModels, type EntitySpec, type Evidence, type ExportEntitySpec, type ExportSpec, type ExtractCall, ExtractResult, FACTS_EXTRACT_SYSTEM, FACTS_EXTRACT_TOOL, FACTS_SEARCH_SYSTEM, type FactsDraft, GraphFragment, IO_STEP_CONFIG, type KVLike, LLM_STEP_CONFIG, LOCATE_SYSTEM, LOCATE_TOOL, type LlmConfig, type LlmEnv, MODEL_TIERS, type MergeRule, type NaturalKeys, type Ontology, PERSON_EXTRACT_SYSTEM, PERSON_EXTRACT_TOOL, PERSON_SEARCH_SYSTEM, type PersonDraft, type PersonEnrichment, type PersonLink, type PersonLinkKind, type PersonResult, type PersonRow, type PipelineDeps, type Provider, type ProviderFamily, RawItem, type SearchCall, type SourceConnector, type SourceEntry, type StepConfig, type StepLike, Store, Usage, type Verdict, type Verification, WRITABLE_VERDICTS, type WriteCall, allJobs, asStepLike, canonicalizeFragment, connectorIds, countyKey, createProvider, enabledSources, enrichCompany, enrichPerson, exportFragment, extractJson, extractJsonArray, factsToFragment, fetchText, fetchUrl, findConnectors, fragmentToOps, getConnector, getProvider, htmlToText, humanAttrsOf, identityKey, ingestFresh, isHttpUrl, isLikelyPersonName, isVerdict, isWritable, jobsForHour, locatePrompt, locationFragment, makeContext, mergeAttrs, mergeRulesOf, mergeScalar, naturalKeysOf, normalizeUrl, normalizeVerification, personPrompt, personToFragment, resilientStep, resolveCounties, resolveName, searchEntities, slugify, toSerializedSchema, urlHash, validateLinks, writeFragment };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { G as GraphFragment, W as WeightedPair, U as Usage, R as RawItem, E as ExtractResult } from './lenses-C7Q7Gvnx.js';
2
2
  export { a as GraphEdge, b as GraphNode, c as addUsage, e as emptyUsage } from './lenses-C7Q7Gvnx.js';
3
3
  import { Op, AdminDb, SerializedAttr, Cardinality, SerializedSchema, Value, Entity } from '@odla-ai/db';
4
- import { ExtractTool, Ai } from '@odla-ai/ai';
4
+ import { ExtractTool, SearchCitation, Ai } from '@odla-ai/ai';
5
5
 
6
6
  /** node type → unique natural-key attribute name. */
7
7
  type NaturalKeys = Record<string, string>;
@@ -237,6 +237,120 @@ declare function factsToFragment(company: CompanyRow, draft: FactsDraft): GraphF
237
237
  /** The `located_in` write linking a company to its resolved county. */
238
238
  declare function locationFragment(slug: string, name: string, fips: string): GraphFragment;
239
239
 
240
+ /**
241
+ * What a research pass concluded about the entity it was asked to find.
242
+ *
243
+ * `wrong_entity` is deliberately distinct from `not_found`: a pass that
244
+ * confidently describes a *different* person with the same name is a much worse
245
+ * failure than one that admits it found nothing, and a benchmark that collapses
246
+ * the two cannot see it.
247
+ */
248
+ type Verdict = "confirmed" | "probable" | "ambiguous" | "not_found" | "wrong_entity";
249
+ /** Verdicts that permit writing enriched facts to the graph. */
250
+ declare const WRITABLE_VERDICTS: readonly Verdict[];
251
+ /** One supporting claim tied to the source that backs it. */
252
+ interface Evidence {
253
+ /** What the source establishes, in the model's words. */
254
+ claim: string;
255
+ /** The URL the claim came from. */
256
+ sourceUrl: string;
257
+ /** When the source was read, as an ISO date. */
258
+ retrievedAt?: string;
259
+ }
260
+ /** A verdict plus the confidence and evidence offered for it. */
261
+ interface Verification {
262
+ verdict: Verdict;
263
+ /** The model's stated confidence, clamped to 0..1. Scored for calibration, never trusted. */
264
+ confidence: number;
265
+ evidence: Evidence[];
266
+ }
267
+ /** Whether a value is one of the closed verdict members. */
268
+ declare function isVerdict(value: unknown): value is Verdict;
269
+ /** Whether a verdict permits writing the enriched facts it came with. */
270
+ declare function isWritable(verdict: Verdict): boolean;
271
+ /**
272
+ * Normalize a model's self-reported verification into the closed shape.
273
+ *
274
+ * Fails safe in both directions: an unrecognized verdict becomes `ambiguous`
275
+ * (never `confirmed`), a missing or out-of-range confidence is clamped, and
276
+ * evidence without a usable http(s) source URL is dropped — an unsourced claim
277
+ * is exactly the thing this package exists to stop treating as a fact.
278
+ */
279
+ declare function normalizeVerification(draft: unknown, retrievedAt?: string): Verification;
280
+ /** Whether a string is an absolute http(s) URL — the only kind evidence may cite. */
281
+ declare function isHttpUrl(value: string): boolean;
282
+
283
+ /** The minimal person row enrichment reads. */
284
+ interface PersonRow {
285
+ slug: string;
286
+ name: string;
287
+ /** Natural key of a company the person is already linked to — the strongest disambiguator. */
288
+ companySlug?: string;
289
+ companyName?: string;
290
+ role?: string;
291
+ city?: string;
292
+ region?: string;
293
+ }
294
+ /** Where a person publishes. Kinds with one canonical host are validated against it. */
295
+ type PersonLinkKind = "website" | "blog" | "substack" | "linkedin" | "x" | "bluesky" | "github" | "other";
296
+ /** System prompt for the grounded person research pass. */
297
+ declare const PERSON_SEARCH_SYSTEM: string;
298
+ /** System prompt for structuring the research notes into {@link PersonDraft}. */
299
+ declare const PERSON_EXTRACT_SYSTEM: string;
300
+ /** Forced-tool schema for a person's verified public profile. */
301
+ declare const PERSON_EXTRACT_TOOL: ExtractTool;
302
+ /** The model's structured person profile, before validation into a fragment. */
303
+ interface PersonDraft {
304
+ verdict?: string;
305
+ confidence?: number;
306
+ links?: Array<{
307
+ kind?: string;
308
+ url?: string;
309
+ }>;
310
+ employer?: string | null;
311
+ role?: string | null;
312
+ businessEmail?: string | null;
313
+ businessPhone?: string | null;
314
+ city?: string | null;
315
+ region?: string | null;
316
+ almaMater?: string | null;
317
+ otherCompanies?: string[];
318
+ evidence?: Array<{
319
+ claim?: string;
320
+ sourceUrl?: string;
321
+ }>;
322
+ }
323
+ /** A validated person link. */
324
+ interface PersonLink {
325
+ kind: PersonLinkKind;
326
+ url: string;
327
+ }
328
+ /** The validated result of one person enrichment pass. */
329
+ interface PersonResult {
330
+ verification: Verification;
331
+ links: PersonLink[];
332
+ fragment: GraphFragment;
333
+ }
334
+ /** The user prompt for a person research pass. */
335
+ declare function personPrompt(person: PersonRow): string;
336
+ /**
337
+ * Keep only links that are plausibly real: absolute https URLs, on the canonical
338
+ * host when the kind has one, and never a placeholder domain. Models invent
339
+ * `linkedin.com/in/first-last` from a name constantly; a kind/host mismatch is
340
+ * the cheapest way to catch it before it becomes a stored "fact".
341
+ */
342
+ declare function validateLinks(raw: PersonDraft["links"], limit?: number): PersonLink[];
343
+ /**
344
+ * Turn one person's structured draft into a verdict plus an idempotent fragment.
345
+ *
346
+ * The fragment is empty unless the verdict is writable — an `ambiguous`,
347
+ * `not_found`, or `wrong_entity` pass contributes no graph, however confident
348
+ * the model sounded. The verdict and its evidence are still returned, because
349
+ * "this lane confidently found the wrong person" is exactly what a benchmark
350
+ * needs to record.
351
+ */
352
+ declare function personToFragment(person: PersonRow, draft: PersonDraft, retrievedAt?: string): PersonResult;
353
+
240
354
  /** Which model serves each verb (search must be a web-grounded model). */
241
355
  interface EnrichModels {
242
356
  /** Web-search-grounded research model. */
@@ -251,6 +365,23 @@ interface EnrichModels {
251
365
  * fragment (possibly empty — nothing established); the caller writes it.
252
366
  */
253
367
  declare function enrichCompany(ai: Ai, models: EnrichModels, company: CompanyRow): Promise<GraphFragment>;
368
+ /** One person enrichment pass, with the sources the provider actually cited. */
369
+ interface PersonEnrichment extends PersonResult {
370
+ /** Sources the search model attributed. Empty when the provider grounded nothing. */
371
+ citations: SearchCitation[];
372
+ /** The research prose the extraction was taken from — useful for adjudication. */
373
+ notes: string;
374
+ }
375
+ /**
376
+ * Enrich one person: a grounded web search for their public professional
377
+ * presence, then a structured extraction into a validated verdict + fragment.
378
+ *
379
+ * Unlike {@link enrichCompany}, this always returns a verdict — including
380
+ * `not_found` and `wrong_entity` — because "the lane confidently identified the
381
+ * wrong person" is a result worth recording, not an error. The fragment is empty
382
+ * unless the verdict permits writing.
383
+ */
384
+ declare function enrichPerson(ai: Ai, models: EnrichModels, person: PersonRow, retrievedAt?: string): Promise<PersonEnrichment>;
254
385
  /**
255
386
  * Resolve a batch of companies' free-text locations to county FIPS codes via a
256
387
  * single structured extraction, joined against a caller-supplied gazetteer
@@ -541,6 +672,17 @@ declare function ingestFresh(step: StepLike, deps: PipelineDeps, fresh: RawItem[
541
672
  */
542
673
  /** NFKD-normalize, strip accents, lowercase, non-alphanumerics → "-", cap 60. */
543
674
  declare function slugify(input: string): string;
675
+ /**
676
+ * Collapse a name to a comparison key: accents stripped, `&` spelled out,
677
+ * punctuation and spacing removed, and trailing legal suffixes dropped.
678
+ *
679
+ * Stronger than comparing {@link slugify} output, because it matches
680
+ * "Acme Corp.", "Acme, Inc." and "Acme & Co" onto the same key — the shapes the
681
+ * same organization is actually written in across sources. Use it to decide
682
+ * whether two records are the same entity; use `slugify` for the natural key
683
+ * that gets stored.
684
+ */
685
+ declare function identityKey(value: string): string;
544
686
  /** Drop tracking params, hash, and trailing slash so the same page maps to one key. */
545
687
  declare function normalizeUrl(raw: string): string;
546
688
  /** Stable short key for a URL (FNV-1a 32-bit, base36). Used as the `signal` natural key. */
@@ -561,4 +703,4 @@ declare function htmlToText(html: string, maxChars?: number): string;
561
703
  /** Fetch a URL and return readable text. Best-effort: returns "" on any failure. */
562
704
  declare function fetchText(url: string, maxChars?: number): Promise<string>;
563
705
 
564
- export { type AttrSpec, type AttrType, COMMIT_STEP_CONFIG, type CompanyRow, type Connector, type ConnectorConfig, type ConnectorContext, type ConnectorJob, type ConnectorLens, type ConnectorReport, type ContextConfig, type EdgeSpec, type EnrichModels, type EntitySpec, type ExportEntitySpec, type ExportSpec, type ExtractCall, ExtractResult, FACTS_EXTRACT_SYSTEM, FACTS_EXTRACT_TOOL, FACTS_SEARCH_SYSTEM, type FactsDraft, GraphFragment, IO_STEP_CONFIG, type KVLike, LLM_STEP_CONFIG, LOCATE_SYSTEM, LOCATE_TOOL, type LlmConfig, type LlmEnv, MODEL_TIERS, type MergeRule, type NaturalKeys, type Ontology, type PipelineDeps, type Provider, type ProviderFamily, RawItem, type SearchCall, type SourceConnector, type SourceEntry, type StepConfig, type StepLike, Store, Usage, type WriteCall, allJobs, asStepLike, canonicalizeFragment, connectorIds, countyKey, createProvider, enabledSources, enrichCompany, exportFragment, extractJson, extractJsonArray, factsToFragment, fetchText, fetchUrl, findConnectors, fragmentToOps, getConnector, getProvider, htmlToText, humanAttrsOf, ingestFresh, isLikelyPersonName, jobsForHour, locatePrompt, locationFragment, makeContext, mergeAttrs, mergeRulesOf, mergeScalar, naturalKeysOf, normalizeUrl, resilientStep, resolveCounties, resolveName, searchEntities, slugify, toSerializedSchema, urlHash, writeFragment };
706
+ export { type AttrSpec, type AttrType, COMMIT_STEP_CONFIG, type CompanyRow, type Connector, type ConnectorConfig, type ConnectorContext, type ConnectorJob, type ConnectorLens, type ConnectorReport, type ContextConfig, type EdgeSpec, type EnrichModels, type EntitySpec, type Evidence, type ExportEntitySpec, type ExportSpec, type ExtractCall, ExtractResult, FACTS_EXTRACT_SYSTEM, FACTS_EXTRACT_TOOL, FACTS_SEARCH_SYSTEM, type FactsDraft, GraphFragment, IO_STEP_CONFIG, type KVLike, LLM_STEP_CONFIG, LOCATE_SYSTEM, LOCATE_TOOL, type LlmConfig, type LlmEnv, MODEL_TIERS, type MergeRule, type NaturalKeys, type Ontology, PERSON_EXTRACT_SYSTEM, PERSON_EXTRACT_TOOL, PERSON_SEARCH_SYSTEM, type PersonDraft, type PersonEnrichment, type PersonLink, type PersonLinkKind, type PersonResult, type PersonRow, type PipelineDeps, type Provider, type ProviderFamily, RawItem, type SearchCall, type SourceConnector, type SourceEntry, type StepConfig, type StepLike, Store, Usage, type Verdict, type Verification, WRITABLE_VERDICTS, type WriteCall, allJobs, asStepLike, canonicalizeFragment, connectorIds, countyKey, createProvider, enabledSources, enrichCompany, enrichPerson, exportFragment, extractJson, extractJsonArray, factsToFragment, fetchText, fetchUrl, findConnectors, fragmentToOps, getConnector, getProvider, htmlToText, humanAttrsOf, identityKey, ingestFresh, isHttpUrl, isLikelyPersonName, isVerdict, isWritable, jobsForHour, locatePrompt, locationFragment, makeContext, mergeAttrs, mergeRulesOf, mergeScalar, naturalKeysOf, normalizeUrl, normalizeVerification, personPrompt, personToFragment, resilientStep, resolveCounties, resolveName, searchEntities, slugify, toSerializedSchema, urlHash, validateLinks, writeFragment };
package/dist/index.js CHANGED
@@ -345,6 +345,15 @@ function findConnectors(fragment, config) {
345
345
  function slugify(input) {
346
346
  return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
347
347
  }
348
+ var LEGAL_SUFFIXES = /* @__PURE__ */ new Set(["co", "company", "corp", "corporation", "inc", "incorporated", "llc", "llp", "ltd", "limited", "pllc"]);
349
+ function identityKey(value) {
350
+ const tokens = value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/&/g, " and ").match(/[a-z0-9]+/g) ?? [];
351
+ while (tokens.length > 1 && LEGAL_SUFFIXES.has(tokens.at(-1) ?? "")) {
352
+ tokens.pop();
353
+ if (tokens.at(-1) === "and") tokens.pop();
354
+ }
355
+ return tokens.join("");
356
+ }
348
357
  function normalizeUrl(raw) {
349
358
  try {
350
359
  const u = new URL(raw);
@@ -487,6 +496,160 @@ function locationFragment(slug, name, fips) {
487
496
  };
488
497
  }
489
498
 
499
+ // src/enrich/verify.ts
500
+ var WRITABLE_VERDICTS = ["confirmed", "probable"];
501
+ var ALL = ["confirmed", "probable", "ambiguous", "not_found", "wrong_entity"];
502
+ function isVerdict(value) {
503
+ return typeof value === "string" && ALL.includes(value);
504
+ }
505
+ function isWritable(verdict) {
506
+ return WRITABLE_VERDICTS.includes(verdict);
507
+ }
508
+ function normalizeVerification(draft, retrievedAt) {
509
+ const d = draft ?? {};
510
+ const verdict = isVerdict(d.verdict) ? d.verdict : "ambiguous";
511
+ const raw = typeof d.confidence === "number" && Number.isFinite(d.confidence) ? d.confidence : 0;
512
+ const confidence = Math.min(1, Math.max(0, raw));
513
+ const evidence = [];
514
+ for (const item of Array.isArray(d.evidence) ? d.evidence.slice(0, 12) : []) {
515
+ const e = item ?? {};
516
+ const sourceUrl = typeof e.sourceUrl === "string" ? e.sourceUrl.trim() : "";
517
+ const claim = typeof e.claim === "string" ? e.claim.trim() : "";
518
+ if (!claim || !isHttpUrl(sourceUrl)) continue;
519
+ evidence.push({ claim: claim.slice(0, 300), sourceUrl, ...retrievedAt ? { retrievedAt } : {} });
520
+ }
521
+ if (evidence.length === 0 && isWritable(verdict)) return { verdict: "ambiguous", confidence, evidence };
522
+ return { verdict, confidence, evidence };
523
+ }
524
+ function isHttpUrl(value) {
525
+ try {
526
+ const u = new URL(value);
527
+ return u.protocol === "https:" || u.protocol === "http:";
528
+ } catch {
529
+ return false;
530
+ }
531
+ }
532
+
533
+ // src/enrich/person.ts
534
+ var LINK_KINDS = ["website", "blog", "substack", "linkedin", "x", "bluesky", "github", "other"];
535
+ var CANONICAL_HOSTS = {
536
+ linkedin: ["linkedin.com"],
537
+ github: ["github.com"],
538
+ x: ["x.com", "twitter.com"],
539
+ bluesky: ["bsky.app"]
540
+ };
541
+ var PLACEHOLDER_HOSTS = ["example.com", "example.org", "example.net", "yourdomain.com", "domain.com"];
542
+ var PERSON_SEARCH_SYSTEM = "You are a research assistant with web search, confirming the identity of a specific named person and finding their PUBLIC PROFESSIONAL presence. Look for: a personal website, blog, or Substack; LinkedIn, X/Bluesky, or GitHub profiles; their current employer and role; and a business email or phone that they or their employer have published openly (a company contact page, a conference bio). Do NOT look for or report home addresses, personal phone numbers, or personal email addresses. Also report disambiguating facts: city or region, alma mater, and other companies named alongside them. Names are not unique. If the results describe more than one person with this name and you cannot tell which is meant, say so explicitly. If you find a different person with the same name, say that too. Cite the URL for every claim. Never guess a profile URL from a name pattern \u2014 report only URLs you actually saw. If you found nothing, say so plainly.";
543
+ var PERSON_EXTRACT_SYSTEM = "Extract the structured profile from the research notes. Use null for anything the notes do not establish. Set verdict to 'confirmed' only when the notes identify this exact person beyond doubt; 'probable' when the evidence is strong but circumstantial; 'ambiguous' when several same-named people cannot be told apart; 'not_found' when nothing was established; 'wrong_entity' when the notes describe a different person with the same name. Every claim in evidence must carry the source URL it came from.";
544
+ var PERSON_EXTRACT_TOOL = {
545
+ name: "person_profile",
546
+ description: "Verified identity plus public professional profile for one person",
547
+ parameters: {
548
+ type: "object",
549
+ properties: {
550
+ verdict: { type: "string", enum: ["confirmed", "probable", "ambiguous", "not_found", "wrong_entity"] },
551
+ confidence: { type: "number", description: "0..1 \u2014 how sure you are this is the right person" },
552
+ links: {
553
+ type: "array",
554
+ description: "Only URLs actually seen in the results; never a guessed profile pattern.",
555
+ items: {
556
+ type: "object",
557
+ properties: {
558
+ kind: { type: "string", enum: [...LINK_KINDS] },
559
+ url: { type: "string" }
560
+ },
561
+ required: ["kind", "url"]
562
+ }
563
+ },
564
+ employer: { type: ["string", "null"], description: "Current employer name" },
565
+ role: { type: ["string", "null"], description: "Current title at that employer" },
566
+ businessEmail: { type: ["string", "null"], description: "Publicly published work email only" },
567
+ businessPhone: { type: ["string", "null"], description: "Publicly published work phone only" },
568
+ city: { type: ["string", "null"] },
569
+ region: { type: ["string", "null"], description: "State, province, or country" },
570
+ almaMater: { type: ["string", "null"] },
571
+ otherCompanies: { type: "array", items: { type: "string" }, description: "Companies named alongside them" },
572
+ evidence: {
573
+ type: "array",
574
+ items: {
575
+ type: "object",
576
+ properties: { claim: { type: "string" }, sourceUrl: { type: "string" } },
577
+ required: ["claim", "sourceUrl"]
578
+ }
579
+ }
580
+ },
581
+ required: ["verdict", "confidence", "links", "evidence"]
582
+ }
583
+ };
584
+ function personPrompt(person) {
585
+ const at = person.companyName ? ` (${person.role ? `${person.role}, ` : ""}${person.companyName})` : "";
586
+ const where = [person.city, person.region].filter(Boolean).join(", ");
587
+ return `Person: ${person.name}${at}${where ? `
588
+ Known location: ${where}` : ""}
589
+
590
+ Confirm this is a real, identifiable person and find their public professional presence.`;
591
+ }
592
+ function validateLinks(raw, limit = 8) {
593
+ const out = [];
594
+ const seen = /* @__PURE__ */ new Set();
595
+ for (const item of (raw ?? []).slice(0, 24)) {
596
+ const kind = LINK_KINDS.includes(String(item?.kind)) ? item.kind : "other";
597
+ const url = String(item?.url ?? "").trim();
598
+ const parsed = httpsUrl(url);
599
+ if (!parsed) continue;
600
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
601
+ if (PLACEHOLDER_HOSTS.includes(host)) continue;
602
+ const required = CANONICAL_HOSTS[kind];
603
+ if (required && !required.some((h) => host === h || host.endsWith(`.${h}`))) continue;
604
+ if (seen.has(url)) continue;
605
+ seen.add(url);
606
+ out.push({ kind, url });
607
+ if (out.length >= limit) break;
608
+ }
609
+ return out;
610
+ }
611
+ function personToFragment(person, draft, retrievedAt) {
612
+ const verification = normalizeVerification(draft, retrievedAt);
613
+ const links = validateLinks(draft.links);
614
+ if (!isWritable(verification.verdict)) return { verification, links, fragment: { nodes: [], edges: [] } };
615
+ const attrs = { name: person.name };
616
+ for (const link of links) if (!attrs[link.kind]) attrs[link.kind] = link.url;
617
+ const city = str(draft.city, 60);
618
+ if (city && !person.city) attrs.city = city;
619
+ const region = str(draft.region, 60);
620
+ if (region && !person.region) attrs.region = region;
621
+ const email = str(draft.businessEmail, 120);
622
+ if (email && /^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) attrs.businessEmail = email;
623
+ const phone = str(draft.businessPhone, 40);
624
+ if (phone) attrs.businessPhone = phone;
625
+ const almaMater = str(draft.almaMater, 120);
626
+ if (almaMater) attrs.almaMater = almaMater;
627
+ const nodes = [{ type: "person", key: person.slug, attrs }];
628
+ const edges = [];
629
+ const employer = str(draft.employer, 120);
630
+ const known = person.companySlug && person.companyName && identityKey(person.companyName) === identityKey(employer);
631
+ const companySlug = employer ? known ? person.companySlug : slugify(employer) : "";
632
+ if (employer && companySlug) {
633
+ const key = `${person.slug}~${companySlug}`;
634
+ nodes.push({ type: "company", key: companySlug, attrs: { name: known ? person.companyName : employer } });
635
+ nodes.push({ type: "affiliation", key, attrs: { role: str(draft.role, 60) || person.role || "" } });
636
+ edges.push({ from: { type: "affiliation", key }, to: { type: "person", key: person.slug }, label: "of_person" });
637
+ edges.push({ from: { type: "affiliation", key }, to: { type: "company", key: companySlug }, label: "of_company" });
638
+ }
639
+ return { verification, links, fragment: { nodes, edges } };
640
+ }
641
+ function str(value, max) {
642
+ return typeof value === "string" ? value.trim().slice(0, max) : "";
643
+ }
644
+ function httpsUrl(value) {
645
+ try {
646
+ const url = new URL(value);
647
+ return url.protocol === "https:" ? url : null;
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+
490
653
  // src/enrich/run.ts
491
654
  async function enrichCompany(ai, models, company) {
492
655
  const where = `${company.city ? `, ${company.city}` : ""}${company.region ? ` (${company.region})` : ""}`;
@@ -510,6 +673,27 @@ ${text}`,
510
673
  const { value } = await extractOnce().catch(extractOnce);
511
674
  return factsToFragment(company, value);
512
675
  }
676
+ async function enrichPerson(ai, models, person, retrievedAt) {
677
+ const { text, citations } = await ai.search({
678
+ model: models.search,
679
+ system: PERSON_SEARCH_SYSTEM,
680
+ user: personPrompt(person),
681
+ maxTokens: 1200
682
+ });
683
+ const extractOnce = () => ai.extract({
684
+ model: models.extract,
685
+ system: PERSON_EXTRACT_SYSTEM,
686
+ user: `Person: ${person.name}
687
+
688
+ Notes:
689
+ ${text}`,
690
+ tool: PERSON_EXTRACT_TOOL,
691
+ maxTokens: 4e3
692
+ // reasoning headroom before the forced tool call
693
+ });
694
+ const { value } = await extractOnce().catch(extractOnce);
695
+ return { ...personToFragment(person, value, retrievedAt), citations, notes: text };
696
+ }
513
697
  async function resolveCounties(ai, model, rows, gazetteer) {
514
698
  const { value } = await ai.extract({
515
699
  model,
@@ -1215,7 +1399,11 @@ export {
1215
1399
  LOCATE_SYSTEM,
1216
1400
  LOCATE_TOOL,
1217
1401
  MODEL_TIERS,
1402
+ PERSON_EXTRACT_SYSTEM,
1403
+ PERSON_EXTRACT_TOOL,
1404
+ PERSON_SEARCH_SYSTEM,
1218
1405
  Store,
1406
+ WRITABLE_VERDICTS,
1219
1407
  addUsage,
1220
1408
  allJobs,
1221
1409
  asStepLike,
@@ -1226,6 +1414,7 @@ export {
1226
1414
  emptyUsage,
1227
1415
  enabledSources,
1228
1416
  enrichCompany,
1417
+ enrichPerson,
1229
1418
  exportFragment,
1230
1419
  extractJson,
1231
1420
  extractJsonArray,
@@ -1238,8 +1427,12 @@ export {
1238
1427
  getProvider,
1239
1428
  htmlToText,
1240
1429
  humanAttrsOf,
1430
+ identityKey,
1241
1431
  ingestFresh,
1432
+ isHttpUrl,
1242
1433
  isLikelyPersonName,
1434
+ isVerdict,
1435
+ isWritable,
1243
1436
  jobsForHour,
1244
1437
  locatePrompt,
1245
1438
  locationFragment,
@@ -1249,6 +1442,9 @@ export {
1249
1442
  mergeScalar,
1250
1443
  naturalKeysOf,
1251
1444
  normalizeUrl,
1445
+ normalizeVerification,
1446
+ personPrompt,
1447
+ personToFragment,
1252
1448
  resilientStep,
1253
1449
  resolveCounties,
1254
1450
  resolveName,
@@ -1256,6 +1452,7 @@ export {
1256
1452
  slugify,
1257
1453
  toSerializedSchema,
1258
1454
  urlHash,
1455
+ validateLinks,
1259
1456
  writeFragment
1260
1457
  };
1261
1458
  //# sourceMappingURL=index.js.map