@odla-ai/kg 0.4.1 → 0.6.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.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,212 @@ 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
+ /** The employer's website. What makes first-party evidence reachable at all. */
291
+ companyWebsite?: string;
292
+ role?: string;
293
+ city?: string;
294
+ region?: string;
295
+ }
296
+ /** Where a person publishes. Kinds with one canonical host are validated against it. */
297
+ type PersonLinkKind = "website" | "blog" | "substack" | "linkedin" | "x" | "bluesky" | "github" | "other";
298
+ /** System prompt for the grounded person research pass. */
299
+ declare const PERSON_SEARCH_SYSTEM: string;
300
+ /** System prompt for structuring the research notes into {@link PersonDraft}. */
301
+ declare const PERSON_EXTRACT_SYSTEM: string;
302
+ /** Forced-tool schema for a person's verified public profile. */
303
+ declare const PERSON_EXTRACT_TOOL: ExtractTool;
304
+ /** The model's structured person profile, before validation into a fragment. */
305
+ interface PersonDraft {
306
+ verdict?: string;
307
+ confidence?: number;
308
+ links?: Array<{
309
+ kind?: string;
310
+ url?: string;
311
+ }>;
312
+ employer?: string | null;
313
+ role?: string | null;
314
+ businessEmail?: string | null;
315
+ businessPhone?: string | null;
316
+ city?: string | null;
317
+ region?: string | null;
318
+ almaMater?: string | null;
319
+ otherCompanies?: string[];
320
+ evidence?: Array<{
321
+ claim?: string;
322
+ sourceUrl?: string;
323
+ }>;
324
+ }
325
+ /** A validated person link. */
326
+ interface PersonLink {
327
+ kind: PersonLinkKind;
328
+ url: string;
329
+ }
330
+ /** The validated result of one person enrichment pass. */
331
+ interface PersonResult {
332
+ verification: Verification;
333
+ links: PersonLink[];
334
+ fragment: GraphFragment;
335
+ }
336
+ /** The user prompt for a person research pass. */
337
+ declare function personPrompt(person: PersonRow): string;
338
+ /**
339
+ * Keep only links that are plausibly real: absolute https URLs, on the canonical
340
+ * host when the kind has one, and never a placeholder domain. Models invent
341
+ * `linkedin.com/in/first-last` from a name constantly; a kind/host mismatch is
342
+ * the cheapest way to catch it before it becomes a stored "fact".
343
+ */
344
+ declare function validateLinks(raw: PersonDraft["links"], limit?: number): PersonLink[];
345
+ /**
346
+ * Turn one person's structured draft into a verdict plus an idempotent fragment.
347
+ *
348
+ * The fragment is empty unless the verdict is writable — an `ambiguous`,
349
+ * `not_found`, or `wrong_entity` pass contributes no graph, however confident
350
+ * the model sounded. The verdict and its evidence are still returned, because
351
+ * "this lane confidently found the wrong person" is exactly what a benchmark
352
+ * needs to record.
353
+ */
354
+ declare function personToFragment(person: PersonRow, draft: PersonDraft, retrievedAt?: string): PersonResult;
355
+
356
+ /** Where a source sits on the scale that decides how much it counts. */
357
+ type SourceClass =
358
+ /** The organization's own site: team page, staff directory, press release.
359
+ * The subject cannot assert this about themselves, which is what makes it
360
+ * the strongest ordinary evidence. */
361
+ "first-party"
362
+ /** A registry that assigns identifiers: ORCID, SEC EDGAR, a licensing board. */
363
+ | "authoritative"
364
+ /** The person's own profile or site. Real, but self-asserted. */
365
+ | "self-asserted"
366
+ /** Press, directories, anything else. */
367
+ | "third-party";
368
+ /** What reading a source established about the subject. */
369
+ type Corroboration =
370
+ /** Named the subject and tied them to their organization. */
371
+ "confirmed"
372
+ /** Named the subject, but nothing tied it to their work. */
373
+ | "name-only"
374
+ /** Loaded, and mentioned neither. */
375
+ | "absent"
376
+ /** Could not be read: blocked, gone, or refused. */
377
+ | "unreachable"
378
+ /** Not checked yet. */
379
+ | "unchecked";
380
+ /** One dated, classified source and what it corroborated. */
381
+ interface VerifiedSource {
382
+ sourceUrl: string;
383
+ sourceClass: SourceClass;
384
+ /** Which facts this source supported: "employer", "title", "location", … */
385
+ facts: string[];
386
+ /** When it was read. Every fact is a fact *as of* a date. */
387
+ verifiedOn: string;
388
+ corroboration: Corroboration;
389
+ }
390
+ /** How well an identity is established. */
391
+ type Grade =
392
+ /** Enough independent sources, anchored, agreeing, with no contradiction. */
393
+ "high"
394
+ /** Corroborated, but thinly or only by sources the subject controls. */
395
+ | "medium"
396
+ /** One source, or a contradiction, or nothing that settles it. */
397
+ | "unresolved"
398
+ /**
399
+ * Nothing could be read at all.
400
+ *
401
+ * Deliberately distinct from `unresolved`. In a real run 60 of 93 claimed
402
+ * URLs came back unreachable, almost all login walls. Scoring those as "not
403
+ * established" blames a lane for a wall it cannot walk through; scoring them
404
+ * as established would be a lie. Neither is true, so this says so.
405
+ */
406
+ | "unverifiable";
407
+ /** The bar a `high` grade has to clear. */
408
+ interface GradeStandard {
409
+ /** Distinct hosts that must corroborate. */
410
+ minimumSources: number;
411
+ /** Distinct facts that must be corroborated across them. */
412
+ minimumFacts: number;
413
+ /** Classes that can anchor a `high` grade. */
414
+ anchors: readonly SourceClass[];
415
+ /** Whether `medium` also requires an anchor. Measured: two third-party
416
+ * sources agreeing about someone who does not exist is a thing that
417
+ * happens, so anything acted on should usually require one. */
418
+ anchorRequiredForMedium: boolean;
419
+ }
420
+ /** Two independent sources, one first-party or authoritative, three facts. */
421
+ declare const DEFAULT_STANDARD: GradeStandard;
422
+ /** What the grade was based on, so a reviewer can disagree with it. */
423
+ interface GradeReport {
424
+ grade: Grade;
425
+ independentSources: number;
426
+ factsCorroborated: string[];
427
+ hasAnchor: boolean;
428
+ contradictions: string[];
429
+ reason: string;
430
+ }
431
+ /** Two sources are independent when they are not the same site. */
432
+ declare function independentHosts(sources: readonly VerifiedSource[]): string[];
433
+ /**
434
+ * Grade an identity from the sources that were checked.
435
+ *
436
+ * Only sources that corroborated something count toward the grade. One that was
437
+ * read and did NOT name the subject is a contradiction; one that could not be
438
+ * read is neither, which is the whole reason `unverifiable` exists.
439
+ */
440
+ declare function gradeSources(sources: readonly VerifiedSource[], standard?: GradeStandard): GradeReport;
441
+ /** Grades that justify acting on the identity. */
442
+ declare const ACTIONABLE: readonly Grade[];
443
+ /** Whether a grade is good enough to act on. */
444
+ declare function isActionable(value: Grade): boolean;
445
+
240
446
  /** Which model serves each verb (search must be a web-grounded model). */
241
447
  interface EnrichModels {
242
448
  /** Web-search-grounded research model. */
@@ -251,6 +457,23 @@ interface EnrichModels {
251
457
  * fragment (possibly empty — nothing established); the caller writes it.
252
458
  */
253
459
  declare function enrichCompany(ai: Ai, models: EnrichModels, company: CompanyRow): Promise<GraphFragment>;
460
+ /** One person enrichment pass, with the sources the provider actually cited. */
461
+ interface PersonEnrichment extends PersonResult {
462
+ /** Sources the search model attributed. Empty when the provider grounded nothing. */
463
+ citations: SearchCitation[];
464
+ /** The research prose the extraction was taken from — useful for adjudication. */
465
+ notes: string;
466
+ }
467
+ /**
468
+ * Enrich one person: a grounded web search for their public professional
469
+ * presence, then a structured extraction into a validated verdict + fragment.
470
+ *
471
+ * Unlike {@link enrichCompany}, this always returns a verdict — including
472
+ * `not_found` and `wrong_entity` — because "the lane confidently identified the
473
+ * wrong person" is a result worth recording, not an error. The fragment is empty
474
+ * unless the verdict permits writing.
475
+ */
476
+ declare function enrichPerson(ai: Ai, models: EnrichModels, person: PersonRow, retrievedAt?: string): Promise<PersonEnrichment>;
254
477
  /**
255
478
  * Resolve a batch of companies' free-text locations to county FIPS codes via a
256
479
  * single structured extraction, joined against a caller-supplied gazetteer
@@ -262,6 +485,325 @@ declare function resolveCounties(ai: Ai, model: string, rows: CompanyRow[], gaze
262
485
  fips: string;
263
486
  }>>;
264
487
 
488
+ /** Normalize for containment testing: fold case, accents, and punctuation. */
489
+ declare function fold(value: string): string;
490
+ /**
491
+ * Whether a folded haystack names a person.
492
+ *
493
+ * Requires every name token, and requires them to sit within a couple of words
494
+ * of each other, which is how names are actually written: "Abbey Chrystal",
495
+ * "Chrystal, Abbey", and "Abbey R. Chrystal" all qualify.
496
+ *
497
+ * Proximity is the load-bearing part, and it was added because of a measured
498
+ * failure rather than in anticipation of one. Requiring only that the tokens
499
+ * appear somewhere on the page matched "Abbey Chrystal" against a staff
500
+ * directory that listed "Abbey Fornier" and "Dale Chrystal" — on a team page
501
+ * most name tokens appear somewhere, so a document-wide match corroborates
502
+ * almost anybody.
503
+ *
504
+ * Single-token names are rejected outright: matching "Deere" against arbitrary
505
+ * page text would corroborate almost anything.
506
+ *
507
+ * @param haystack Page text, already passed through {@link fold}.
508
+ * @param name The person's name, in any casing or accenting.
509
+ */
510
+ declare function mentionsName(haystack: string, name: string): boolean;
511
+ /**
512
+ * Pull a window of text around the person's name, as evidence a reviewer can
513
+ * read without re-fetching the page.
514
+ */
515
+ declare function snippetAround(text: string, name: string, width?: number): string;
516
+ /**
517
+ * Reduce HTML to readable text.
518
+ *
519
+ * Script and style bodies are dropped first: a page's inline JSON often repeats
520
+ * unrelated names, and matching against it corroborates the wrong person.
521
+ */
522
+ declare function stripMarkup(html: string): string;
523
+
524
+ /**
525
+ * Read a URL and return its body, or null if it could not be read.
526
+ *
527
+ * Supplied by the caller rather than implemented here, and deliberately so.
528
+ * Fetched pages are attacker-influenced: a strategy reads whatever a third
529
+ * party chose to serve. Making the reader an explicit dependency means the
530
+ * caller decides what boundary it crosses — in odla that is
531
+ * `@odla-ai/camel`'s artifact ingress, which caps bytes, screens redirects for
532
+ * SSRF, and labels the body Unsafe. kg does not reach for the network itself,
533
+ * so it can never quietly bypass that.
534
+ */
535
+ type PageReader = (url: string) => Promise<string | null>;
536
+ /** What a strategy is given to work with. */
537
+ interface StrategyContext {
538
+ person: PersonRow;
539
+ /** The date this evidence is being gathered on. Every fact is a fact *as of* a date. */
540
+ retrievedAt: string;
541
+ /** Present for strategies that call a model. */
542
+ ai?: Ai;
543
+ models?: EnrichModels;
544
+ /** Present for strategies that read pages. */
545
+ readPage?: PageReader;
546
+ }
547
+ /** What one strategy established. */
548
+ interface Finding {
549
+ strategy: string;
550
+ /** Dated, classified sources — the input the grade is computed from. */
551
+ sources: VerifiedSource[];
552
+ /** Profile and site URLs this strategy proposes. */
553
+ links: PersonLink[];
554
+ /** Human-readable account of what happened, for adjudication. */
555
+ notes: string;
556
+ /** Set when the strategy failed outright, which is not the same as finding nothing. */
557
+ error?: string;
558
+ }
559
+ /**
560
+ * One way of gathering evidence about a person.
561
+ *
562
+ * A strategy never decides whether the person was found. It reports what it
563
+ * saw and how strong the sources were; {@link investigatePerson} grades. That
564
+ * split is the point — it means adding a strategy cannot inflate a verdict, and
565
+ * it means the standard can be raised later and re-applied to stored evidence
566
+ * without paying for the inference again.
567
+ */
568
+ interface Strategy {
569
+ /** Stable identifier, recorded on findings so results stay attributable. */
570
+ name: string;
571
+ /** Whether this strategy can run at all for this subject and context. */
572
+ applicable(context: StrategyContext): boolean;
573
+ run(context: StrategyContext): Promise<Finding>;
574
+ }
575
+ /** The result of investigating one person with a set of strategies. */
576
+ interface Investigation {
577
+ person: PersonRow;
578
+ /** How well the identity is established, per the standard used. */
579
+ grade: Grade;
580
+ /** Why, in enough detail to disagree with. */
581
+ report: GradeReport;
582
+ /** Every source gathered, deduplicated by URL. */
583
+ sources: VerifiedSource[];
584
+ /** Every link proposed, deduplicated by URL. */
585
+ links: PersonLink[];
586
+ /** Per-strategy detail, including strategies that ran and found nothing. */
587
+ findings: Finding[];
588
+ /** Strategies skipped because they were not applicable. */
589
+ skipped: string[];
590
+ }
591
+ /**
592
+ * Run every applicable strategy and grade what they found together.
593
+ *
594
+ * Strategies run concurrently and are isolated from each other: one that throws
595
+ * becomes a `Finding` carrying its error rather than failing the investigation.
596
+ * A search provider being down should cost you that strategy's evidence, not
597
+ * the first-party page another strategy already read.
598
+ *
599
+ * @param context The person, the date, and whichever capabilities are available.
600
+ * @param strategies Which strategies to attempt, in any order.
601
+ * @param standard The bar a `high` grade must clear. Defaults to
602
+ * {@link DEFAULT_STANDARD}: two independent sources, one anchoring, three facts.
603
+ */
604
+ declare function investigatePerson(context: StrategyContext, strategies: readonly Strategy[], standard?: GradeStandard): Promise<Investigation>;
605
+
606
+ /** Paths an organization is most likely to describe its people on. */
607
+ declare const TEAM_PATHS: readonly string[];
608
+ /** The site root, which is the only URL on a domain guaranteed to exist. */
609
+ declare function rootOf(website: string): string | null;
610
+ /**
611
+ * Guessed people-page paths. A fallback only.
612
+ *
613
+ * Guessing produced 67 refusals against 23 successful reads in a real run: most
614
+ * sites do not use these paths, and a 404 teaches nothing about whether the
615
+ * person works there. Following the site's own links is the primary route; this
616
+ * is what remains when a homepage offers no usable navigation.
617
+ */
618
+ declare function candidatePages(website: string, limit?: number): string[];
619
+ /**
620
+ * Extract the links a homepage itself offers toward its people pages.
621
+ *
622
+ * Reading the site's own navigation beats guessing at it on both counts: it is
623
+ * far more likely to resolve, and it lands on the page the organization
624
+ * actually maintains. Same-origin only — an off-site link is not first-party
625
+ * evidence, which is the entire reason this strategy carries weight.
626
+ */
627
+ declare function peopleLinks(html: string, origin: string, limit?: number): string[];
628
+ /** What one organization page said about the person. */
629
+ interface OrgHit {
630
+ url: string;
631
+ named: boolean;
632
+ /** Text around the name, for the reviewer. */
633
+ snippet: string;
634
+ }
635
+ /**
636
+ * Decide whether an organization page establishes the person.
637
+ *
638
+ * Deliberately strict about the name and silent about the title: a team page
639
+ * naming the person is the finding, and inferring their exact title from
640
+ * surrounding prose would manufacture a fact the page never stated plainly.
641
+ */
642
+ declare function judgeOrgPage(name: string, url: string, text: string): OrgHit;
643
+ /**
644
+ * Walk the employer's site looking for the person.
645
+ *
646
+ * Requires `companyWebsite` on the person and a `readPage`; without either it
647
+ * reports itself inapplicable rather than pretending to have checked.
648
+ */
649
+ declare const orgSiteStrategy: Strategy;
650
+
651
+ /**
652
+ * Class a cited source.
653
+ *
654
+ * The employer's own domain is first-party even when a search found it rather
655
+ * than a crawl — what makes evidence first-party is who published it, not how
656
+ * it was reached.
657
+ */
658
+ declare function classifySource(url: string, companyWebsite?: string): SourceClass;
659
+ /**
660
+ * Whether a provider actually grounded a claim in this source, as opposed to
661
+ * merely consulting it.
662
+ *
663
+ * A citation carrying cited text or a text span is attributed: the provider is
664
+ * saying *this sentence came from here*. One carrying only a URL means the
665
+ * source was visited. The difference matters enough to grade differently —
666
+ * consulted-but-unquoted sources are the ones that pad a bibliography without
667
+ * supporting anything in it.
668
+ */
669
+ declare function isAttributed(citation: SearchCitation): boolean;
670
+ /**
671
+ * Research a person with a grounded search, and report the sources rather than
672
+ * the conclusion.
673
+ */
674
+ declare const researchStrategy: Strategy;
675
+
676
+ /** Build the narrow query. Quoting both names is what makes the count meaningful. */
677
+ declare function targetedQuery(person: PersonRow, platform?: string): string;
678
+ /** One candidate profile the search turned up. */
679
+ interface Candidate {
680
+ url: string;
681
+ host: string;
682
+ /** The profile handle, which tells two results apart from one result cited twice. */
683
+ handle: string;
684
+ }
685
+ /**
686
+ * Reduce a citation set to distinct candidate profiles.
687
+ *
688
+ * Deduplicates by (host, handle) rather than by URL. The same profile arrives
689
+ * as `linkedin.com/in/x`, `www.linkedin.com/in/x/`, and again with tracking
690
+ * suffixes; counting those as three candidates turns every confident answer
691
+ * into a false ambiguity.
692
+ */
693
+ declare function candidatesFrom(citations: readonly SearchCitation[]): Candidate[];
694
+ /**
695
+ * Look up one person by a single narrow query.
696
+ *
697
+ * Emits a source only when exactly one distinct profile came back. Emitting one
698
+ * per candidate when the name is ambiguous would manufacture precisely the
699
+ * confidence this strategy exists to withhold — and because the profile is the
700
+ * person's own, it is classed `self-asserted`, which under the default standard
701
+ * cannot anchor a `high` grade by itself.
702
+ */
703
+ declare const targetedProfileStrategy: Strategy;
704
+
705
+ /**
706
+ * How an ontology stores the alias index.
707
+ *
708
+ * kg does not own your ontology — you declare the entity, kg reads and writes
709
+ * it through these names. The alias entity needs a unique natural key and one
710
+ * attribute holding the person's key.
711
+ */
712
+ interface PersonIdentityConfig {
713
+ /** Entity type indexing aliases, e.g. `"person_alias"`. */
714
+ aliasType: string;
715
+ /** Unique natural-key attr on the alias entity. Default `"alias"`. */
716
+ aliasKeyAttr?: string;
717
+ /** Attr on the alias entity holding the person's key. Default `"personKey"`. */
718
+ personKeyAttr?: string;
719
+ /** Entity type of the person the alias points at. Default `"person"`. */
720
+ personType?: string;
721
+ /** Edge label from alias to person. Default `"of_person"`. */
722
+ edgeLabel?: string;
723
+ }
724
+ /**
725
+ * Mint an opaque person key.
726
+ *
727
+ * Random rather than derived, and that is the entire point. Any key computed
728
+ * from the facts — a hash of name plus employer — collides two namesakes at one
729
+ * company and mints a second identity the moment someone changes job. A key
730
+ * that means nothing cannot be wrong about anything.
731
+ *
732
+ * @param random Injectable for tests; defaults to `crypto.randomUUID`.
733
+ */
734
+ declare function mintPersonKey(random?: () => string): string;
735
+ /** Whether a key was minted by {@link mintPersonKey} rather than derived from a name. */
736
+ declare function isMintedKey(key: string): boolean;
737
+ /**
738
+ * The alias for a public profile: durable, because it belongs to the person
739
+ * rather than to their current job.
740
+ */
741
+ declare function profileAlias(kind: PersonLink["kind"], url: string): string | null;
742
+ /**
743
+ * The bootstrap alias for someone with no profile found yet.
744
+ *
745
+ * Employer is part of the key on purpose: without it every namesake resolves to
746
+ * one person, which is the bug this module exists to close. With it, a job
747
+ * change produces a *new* alias rather than a wrong match — the person keeps
748
+ * their key, and the old alias keeps pointing at them.
749
+ *
750
+ * Returns null without an employer. A bare name is not an identity, and
751
+ * indexing one would reintroduce the collision by the back door.
752
+ */
753
+ declare function nameAlias(name: string, companyName?: string): string | null;
754
+ /**
755
+ * Every alias a person is recognizable by, strongest first.
756
+ *
757
+ * Order is what makes resolution correct rather than merely convenient: a
758
+ * profile match is trusted over a name-plus-employer match, so someone who
759
+ * changed jobs still resolves to themselves.
760
+ */
761
+ declare function aliasesFor(person: PersonRow, links?: readonly PersonLink[]): string[];
762
+ /**
763
+ * Resolve aliases to an existing person key.
764
+ *
765
+ * One batched read, then the *caller's* order decides — the first alias with a
766
+ * hit wins, so a profile match beats a name match even when the name row was
767
+ * written first. Returns null when nothing matches, which means mint.
768
+ */
769
+ declare function resolvePersonKey(db: AdminDb, config: PersonIdentityConfig, aliases: readonly string[]): Promise<string | null>;
770
+ /**
771
+ * Rewrite a fragment onto a minted person key and index the aliases that lead
772
+ * back to it.
773
+ *
774
+ * Everything referencing the old key moves with it — edges included — so an
775
+ * affiliation written against the name-derived key does not end up orphaned.
776
+ * The alias rows are what make the *next* pass idempotent: without them a
777
+ * minted key would be re-minted every run, which is worse than the name key it
778
+ * replaced.
779
+ */
780
+ declare function applyPersonIdentity(fragment: GraphFragment, from: string, to: string, aliases: readonly string[], config: PersonIdentityConfig): GraphFragment;
781
+
782
+ /** What identifying a person settled on. */
783
+ interface PersonIdentity {
784
+ /** The opaque key the person is stored under. */
785
+ personKey: string;
786
+ /** True when nobody matched and a fresh key was minted. */
787
+ minted: boolean;
788
+ /** The aliases now indexed against that key, strongest first. */
789
+ aliases: string[];
790
+ /** The fragment, rekeyed onto `personKey` with the alias rows appended. */
791
+ fragment: GraphFragment;
792
+ }
793
+ /**
794
+ * Give a person a stable opaque key, reusing the one they already have.
795
+ *
796
+ * Aliases found by this pass are indexed too, not just the one that resolved.
797
+ * That is how someone graduates off the fragile name-plus-employer alias: the
798
+ * first pass that finds their LinkedIn attaches it to the key they already
799
+ * have, and every later pass — including after they change jobs — resolves on
800
+ * the profile instead.
801
+ *
802
+ * @param fragment A fragment keyed on `person.slug`, as `personToFragment`
803
+ * produces. Its person node and every edge touching it are rekeyed.
804
+ */
805
+ declare function identifyPerson(db: AdminDb, config: PersonIdentityConfig, person: PersonRow, links: readonly PersonLink[], fragment: GraphFragment): Promise<PersonIdentity>;
806
+
265
807
  /**
266
808
  * The LLM seam, now a thin adapter over @odla-ai/ai (which was itself
267
809
  * generalized from this package's original Provider). Three verbs stay:
@@ -541,6 +1083,17 @@ declare function ingestFresh(step: StepLike, deps: PipelineDeps, fresh: RawItem[
541
1083
  */
542
1084
  /** NFKD-normalize, strip accents, lowercase, non-alphanumerics → "-", cap 60. */
543
1085
  declare function slugify(input: string): string;
1086
+ /**
1087
+ * Collapse a name to a comparison key: accents stripped, `&` spelled out,
1088
+ * punctuation and spacing removed, and trailing legal suffixes dropped.
1089
+ *
1090
+ * Stronger than comparing {@link slugify} output, because it matches
1091
+ * "Acme Corp.", "Acme, Inc." and "Acme & Co" onto the same key — the shapes the
1092
+ * same organization is actually written in across sources. Use it to decide
1093
+ * whether two records are the same entity; use `slugify` for the natural key
1094
+ * that gets stored.
1095
+ */
1096
+ declare function identityKey(value: string): string;
544
1097
  /** Drop tracking params, hash, and trailing slash so the same page maps to one key. */
545
1098
  declare function normalizeUrl(raw: string): string;
546
1099
  /** Stable short key for a URL (FNV-1a 32-bit, base36). Used as the `signal` natural key. */
@@ -561,4 +1114,4 @@ declare function htmlToText(html: string, maxChars?: number): string;
561
1114
  /** Fetch a URL and return readable text. Best-effort: returns "" on any failure. */
562
1115
  declare function fetchText(url: string, maxChars?: number): Promise<string>;
563
1116
 
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 };
1117
+ export { ACTIONABLE, type AttrSpec, type AttrType, COMMIT_STEP_CONFIG, type Candidate, type CompanyRow, type Connector, type ConnectorConfig, type ConnectorContext, type ConnectorJob, type ConnectorLens, type ConnectorReport, type ContextConfig, type Corroboration, DEFAULT_STANDARD, 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, type Finding, type Grade, type GradeReport, type GradeStandard, GraphFragment, IO_STEP_CONFIG, type Investigation, type KVLike, LLM_STEP_CONFIG, LOCATE_SYSTEM, LOCATE_TOOL, type LlmConfig, type LlmEnv, MODEL_TIERS, type MergeRule, type NaturalKeys, type Ontology, type OrgHit, PERSON_EXTRACT_SYSTEM, PERSON_EXTRACT_TOOL, PERSON_SEARCH_SYSTEM, type PageReader, type PersonDraft, type PersonEnrichment, type PersonIdentity, type PersonIdentityConfig, type PersonLink, type PersonLinkKind, type PersonResult, type PersonRow, type PipelineDeps, type Provider, type ProviderFamily, RawItem, type SearchCall, type SourceClass, type SourceConnector, type SourceEntry, type StepConfig, type StepLike, Store, type Strategy, type StrategyContext, TEAM_PATHS, Usage, type Verdict, type Verification, type VerifiedSource, WRITABLE_VERDICTS, type WriteCall, aliasesFor, allJobs, applyPersonIdentity, asStepLike, candidatePages, candidatesFrom, canonicalizeFragment, classifySource, connectorIds, countyKey, createProvider, enabledSources, enrichCompany, enrichPerson, exportFragment, extractJson, extractJsonArray, factsToFragment, fetchText, fetchUrl, findConnectors, fold, fragmentToOps, getConnector, getProvider, gradeSources, htmlToText, humanAttrsOf, identifyPerson, identityKey, independentHosts, ingestFresh, investigatePerson, isActionable, isAttributed, isHttpUrl, isLikelyPersonName, isMintedKey, isVerdict, isWritable, jobsForHour, judgeOrgPage, locatePrompt, locationFragment, makeContext, mentionsName, mergeAttrs, mergeRulesOf, mergeScalar, mintPersonKey, nameAlias, naturalKeysOf, normalizeUrl, normalizeVerification, orgSiteStrategy, peopleLinks, personPrompt, personToFragment, profileAlias, researchStrategy, resilientStep, resolveCounties, resolveName, resolvePersonKey, rootOf, searchEntities, slugify, snippetAround, stripMarkup, targetedProfileStrategy, targetedQuery, toSerializedSchema, urlHash, validateLinks, writeFragment };