@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/README.md CHANGED
@@ -15,6 +15,13 @@ the host supplies the database client, LLM keys, and a KV-like store. The
15
15
  public platform and package manuals describe how to compose it with Workflows,
16
16
  scheduled triggers, an HTTP API, and a viewer UI.
17
17
 
18
+ > **Ask the runbooks first.** odla's operational procedures live in a database,
19
+ > not in this file: `npx @odla-ai/cli runbook ask "<question>"` returns the current
20
+ > steps, and unlike anything written here it cannot be out of date. Use it before
21
+ > searching the web or working from memory. This README and the JSDoc in the
22
+ > shipped `.d.ts` are the version-matched **API** reference; a runbook is the
23
+ > **procedure**. Most tasks need an answer from both.
24
+
18
25
  ## Architecture
19
26
 
20
27
  ```
@@ -76,6 +83,30 @@ Key seams the host supplies:
76
83
  straight to the writer) · `web-search` (LLM web-search discovery) · `gov-data`
77
84
  (template proving a new source needs no pipeline change).
78
85
 
86
+ ## Enrichment & verdicts
87
+
88
+ `enrichCompany` and `enrichPerson` run a grounded web search, then a forced-tool
89
+ extraction, then validate the result into a `GraphFragment`.
90
+
91
+ Person enrichment always returns a **verdict** — `confirmed`, `probable`,
92
+ `ambiguous`, `not_found`, or `wrong_entity` — with a confidence and evidence
93
+ tied to source URLs. The distinctions matter: a pass that confidently describes a
94
+ *different* person with the same name is a much worse failure than one that found
95
+ nothing, and collapsing the two makes it invisible. The fragment is empty unless
96
+ the verdict permits writing, and a "confirmation" whose evidence carries no
97
+ checkable source URL is downgraded to `ambiguous` rather than believed.
98
+
99
+ `validateLinks` drops profile URLs whose host contradicts their claimed kind —
100
+ a `linkedin.com/in/<name>` pattern served from some other host is the single most
101
+ common hallucination in this task. Scope is deliberately public and professional:
102
+ site, blog, Substack, LinkedIn, X/Bluesky, GitHub, employer, and business contact
103
+ points the person or their employer published. Never home addresses, personal
104
+ phones, or personal email.
105
+
106
+ Use `identityKey` (not `slugify`) to decide whether two records are the same
107
+ entity: it folds away punctuation, accents, `&`, and trailing legal suffixes, so
108
+ "Acme Corp.", "Acme, Inc." and "Acme & Co" agree.
109
+
79
110
  ## Provenance & merging
80
111
 
81
112
  `writeFragment` reads existing nodes by natural key (one batched query), merges
package/dist/index.cjs CHANGED
@@ -29,7 +29,11 @@ __export(index_exports, {
29
29
  LOCATE_SYSTEM: () => LOCATE_SYSTEM,
30
30
  LOCATE_TOOL: () => LOCATE_TOOL,
31
31
  MODEL_TIERS: () => MODEL_TIERS,
32
+ PERSON_EXTRACT_SYSTEM: () => PERSON_EXTRACT_SYSTEM,
33
+ PERSON_EXTRACT_TOOL: () => PERSON_EXTRACT_TOOL,
34
+ PERSON_SEARCH_SYSTEM: () => PERSON_SEARCH_SYSTEM,
32
35
  Store: () => Store,
36
+ WRITABLE_VERDICTS: () => WRITABLE_VERDICTS,
33
37
  addUsage: () => addUsage,
34
38
  allJobs: () => allJobs,
35
39
  asStepLike: () => asStepLike,
@@ -40,6 +44,7 @@ __export(index_exports, {
40
44
  emptyUsage: () => emptyUsage,
41
45
  enabledSources: () => enabledSources,
42
46
  enrichCompany: () => enrichCompany,
47
+ enrichPerson: () => enrichPerson,
43
48
  exportFragment: () => exportFragment,
44
49
  extractJson: () => extractJson,
45
50
  extractJsonArray: () => extractJsonArray,
@@ -52,8 +57,12 @@ __export(index_exports, {
52
57
  getProvider: () => getProvider,
53
58
  htmlToText: () => htmlToText,
54
59
  humanAttrsOf: () => humanAttrsOf,
60
+ identityKey: () => identityKey,
55
61
  ingestFresh: () => ingestFresh,
62
+ isHttpUrl: () => isHttpUrl,
56
63
  isLikelyPersonName: () => isLikelyPersonName,
64
+ isVerdict: () => isVerdict,
65
+ isWritable: () => isWritable,
57
66
  jobsForHour: () => jobsForHour,
58
67
  locatePrompt: () => locatePrompt,
59
68
  locationFragment: () => locationFragment,
@@ -63,6 +72,9 @@ __export(index_exports, {
63
72
  mergeScalar: () => mergeScalar,
64
73
  naturalKeysOf: () => naturalKeysOf,
65
74
  normalizeUrl: () => normalizeUrl,
75
+ normalizeVerification: () => normalizeVerification,
76
+ personPrompt: () => personPrompt,
77
+ personToFragment: () => personToFragment,
66
78
  resilientStep: () => resilientStep,
67
79
  resolveCounties: () => resolveCounties,
68
80
  resolveName: () => resolveName,
@@ -70,6 +82,7 @@ __export(index_exports, {
70
82
  slugify: () => slugify,
71
83
  toSerializedSchema: () => toSerializedSchema,
72
84
  urlHash: () => urlHash,
85
+ validateLinks: () => validateLinks,
73
86
  writeFragment: () => writeFragment
74
87
  });
75
88
  module.exports = __toCommonJS(index_exports);
@@ -594,6 +607,15 @@ function findConnectors(fragment, config) {
594
607
  function slugify(input) {
595
608
  return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
596
609
  }
610
+ var LEGAL_SUFFIXES = /* @__PURE__ */ new Set(["co", "company", "corp", "corporation", "inc", "incorporated", "llc", "llp", "ltd", "limited", "pllc"]);
611
+ function identityKey(value) {
612
+ const tokens = value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/&/g, " and ").match(/[a-z0-9]+/g) ?? [];
613
+ while (tokens.length > 1 && LEGAL_SUFFIXES.has(tokens.at(-1) ?? "")) {
614
+ tokens.pop();
615
+ if (tokens.at(-1) === "and") tokens.pop();
616
+ }
617
+ return tokens.join("");
618
+ }
597
619
  function normalizeUrl(raw) {
598
620
  try {
599
621
  const u = new URL(raw);
@@ -736,6 +758,160 @@ function locationFragment(slug, name, fips) {
736
758
  };
737
759
  }
738
760
 
761
+ // src/enrich/verify.ts
762
+ var WRITABLE_VERDICTS = ["confirmed", "probable"];
763
+ var ALL = ["confirmed", "probable", "ambiguous", "not_found", "wrong_entity"];
764
+ function isVerdict(value) {
765
+ return typeof value === "string" && ALL.includes(value);
766
+ }
767
+ function isWritable(verdict) {
768
+ return WRITABLE_VERDICTS.includes(verdict);
769
+ }
770
+ function normalizeVerification(draft, retrievedAt) {
771
+ const d = draft ?? {};
772
+ const verdict = isVerdict(d.verdict) ? d.verdict : "ambiguous";
773
+ const raw = typeof d.confidence === "number" && Number.isFinite(d.confidence) ? d.confidence : 0;
774
+ const confidence = Math.min(1, Math.max(0, raw));
775
+ const evidence = [];
776
+ for (const item of Array.isArray(d.evidence) ? d.evidence.slice(0, 12) : []) {
777
+ const e = item ?? {};
778
+ const sourceUrl = typeof e.sourceUrl === "string" ? e.sourceUrl.trim() : "";
779
+ const claim = typeof e.claim === "string" ? e.claim.trim() : "";
780
+ if (!claim || !isHttpUrl(sourceUrl)) continue;
781
+ evidence.push({ claim: claim.slice(0, 300), sourceUrl, ...retrievedAt ? { retrievedAt } : {} });
782
+ }
783
+ if (evidence.length === 0 && isWritable(verdict)) return { verdict: "ambiguous", confidence, evidence };
784
+ return { verdict, confidence, evidence };
785
+ }
786
+ function isHttpUrl(value) {
787
+ try {
788
+ const u = new URL(value);
789
+ return u.protocol === "https:" || u.protocol === "http:";
790
+ } catch {
791
+ return false;
792
+ }
793
+ }
794
+
795
+ // src/enrich/person.ts
796
+ var LINK_KINDS = ["website", "blog", "substack", "linkedin", "x", "bluesky", "github", "other"];
797
+ var CANONICAL_HOSTS = {
798
+ linkedin: ["linkedin.com"],
799
+ github: ["github.com"],
800
+ x: ["x.com", "twitter.com"],
801
+ bluesky: ["bsky.app"]
802
+ };
803
+ var PLACEHOLDER_HOSTS = ["example.com", "example.org", "example.net", "yourdomain.com", "domain.com"];
804
+ 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.";
805
+ 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.";
806
+ var PERSON_EXTRACT_TOOL = {
807
+ name: "person_profile",
808
+ description: "Verified identity plus public professional profile for one person",
809
+ parameters: {
810
+ type: "object",
811
+ properties: {
812
+ verdict: { type: "string", enum: ["confirmed", "probable", "ambiguous", "not_found", "wrong_entity"] },
813
+ confidence: { type: "number", description: "0..1 \u2014 how sure you are this is the right person" },
814
+ links: {
815
+ type: "array",
816
+ description: "Only URLs actually seen in the results; never a guessed profile pattern.",
817
+ items: {
818
+ type: "object",
819
+ properties: {
820
+ kind: { type: "string", enum: [...LINK_KINDS] },
821
+ url: { type: "string" }
822
+ },
823
+ required: ["kind", "url"]
824
+ }
825
+ },
826
+ employer: { type: ["string", "null"], description: "Current employer name" },
827
+ role: { type: ["string", "null"], description: "Current title at that employer" },
828
+ businessEmail: { type: ["string", "null"], description: "Publicly published work email only" },
829
+ businessPhone: { type: ["string", "null"], description: "Publicly published work phone only" },
830
+ city: { type: ["string", "null"] },
831
+ region: { type: ["string", "null"], description: "State, province, or country" },
832
+ almaMater: { type: ["string", "null"] },
833
+ otherCompanies: { type: "array", items: { type: "string" }, description: "Companies named alongside them" },
834
+ evidence: {
835
+ type: "array",
836
+ items: {
837
+ type: "object",
838
+ properties: { claim: { type: "string" }, sourceUrl: { type: "string" } },
839
+ required: ["claim", "sourceUrl"]
840
+ }
841
+ }
842
+ },
843
+ required: ["verdict", "confidence", "links", "evidence"]
844
+ }
845
+ };
846
+ function personPrompt(person) {
847
+ const at = person.companyName ? ` (${person.role ? `${person.role}, ` : ""}${person.companyName})` : "";
848
+ const where = [person.city, person.region].filter(Boolean).join(", ");
849
+ return `Person: ${person.name}${at}${where ? `
850
+ Known location: ${where}` : ""}
851
+
852
+ Confirm this is a real, identifiable person and find their public professional presence.`;
853
+ }
854
+ function validateLinks(raw, limit = 8) {
855
+ const out = [];
856
+ const seen = /* @__PURE__ */ new Set();
857
+ for (const item of (raw ?? []).slice(0, 24)) {
858
+ const kind = LINK_KINDS.includes(String(item?.kind)) ? item.kind : "other";
859
+ const url = String(item?.url ?? "").trim();
860
+ const parsed = httpsUrl(url);
861
+ if (!parsed) continue;
862
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
863
+ if (PLACEHOLDER_HOSTS.includes(host)) continue;
864
+ const required = CANONICAL_HOSTS[kind];
865
+ if (required && !required.some((h) => host === h || host.endsWith(`.${h}`))) continue;
866
+ if (seen.has(url)) continue;
867
+ seen.add(url);
868
+ out.push({ kind, url });
869
+ if (out.length >= limit) break;
870
+ }
871
+ return out;
872
+ }
873
+ function personToFragment(person, draft, retrievedAt) {
874
+ const verification = normalizeVerification(draft, retrievedAt);
875
+ const links = validateLinks(draft.links);
876
+ if (!isWritable(verification.verdict)) return { verification, links, fragment: { nodes: [], edges: [] } };
877
+ const attrs = { name: person.name };
878
+ for (const link of links) if (!attrs[link.kind]) attrs[link.kind] = link.url;
879
+ const city = str(draft.city, 60);
880
+ if (city && !person.city) attrs.city = city;
881
+ const region = str(draft.region, 60);
882
+ if (region && !person.region) attrs.region = region;
883
+ const email = str(draft.businessEmail, 120);
884
+ if (email && /^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) attrs.businessEmail = email;
885
+ const phone = str(draft.businessPhone, 40);
886
+ if (phone) attrs.businessPhone = phone;
887
+ const almaMater = str(draft.almaMater, 120);
888
+ if (almaMater) attrs.almaMater = almaMater;
889
+ const nodes = [{ type: "person", key: person.slug, attrs }];
890
+ const edges = [];
891
+ const employer = str(draft.employer, 120);
892
+ const known = person.companySlug && person.companyName && identityKey(person.companyName) === identityKey(employer);
893
+ const companySlug = employer ? known ? person.companySlug : slugify(employer) : "";
894
+ if (employer && companySlug) {
895
+ const key = `${person.slug}~${companySlug}`;
896
+ nodes.push({ type: "company", key: companySlug, attrs: { name: known ? person.companyName : employer } });
897
+ nodes.push({ type: "affiliation", key, attrs: { role: str(draft.role, 60) || person.role || "" } });
898
+ edges.push({ from: { type: "affiliation", key }, to: { type: "person", key: person.slug }, label: "of_person" });
899
+ edges.push({ from: { type: "affiliation", key }, to: { type: "company", key: companySlug }, label: "of_company" });
900
+ }
901
+ return { verification, links, fragment: { nodes, edges } };
902
+ }
903
+ function str(value, max) {
904
+ return typeof value === "string" ? value.trim().slice(0, max) : "";
905
+ }
906
+ function httpsUrl(value) {
907
+ try {
908
+ const url = new URL(value);
909
+ return url.protocol === "https:" ? url : null;
910
+ } catch {
911
+ return null;
912
+ }
913
+ }
914
+
739
915
  // src/enrich/run.ts
740
916
  async function enrichCompany(ai, models, company) {
741
917
  const where = `${company.city ? `, ${company.city}` : ""}${company.region ? ` (${company.region})` : ""}`;
@@ -759,6 +935,27 @@ ${text}`,
759
935
  const { value } = await extractOnce().catch(extractOnce);
760
936
  return factsToFragment(company, value);
761
937
  }
938
+ async function enrichPerson(ai, models, person, retrievedAt) {
939
+ const { text, citations } = await ai.search({
940
+ model: models.search,
941
+ system: PERSON_SEARCH_SYSTEM,
942
+ user: personPrompt(person),
943
+ maxTokens: 1200
944
+ });
945
+ const extractOnce = () => ai.extract({
946
+ model: models.extract,
947
+ system: PERSON_EXTRACT_SYSTEM,
948
+ user: `Person: ${person.name}
949
+
950
+ Notes:
951
+ ${text}`,
952
+ tool: PERSON_EXTRACT_TOOL,
953
+ maxTokens: 4e3
954
+ // reasoning headroom before the forced tool call
955
+ });
956
+ const { value } = await extractOnce().catch(extractOnce);
957
+ return { ...personToFragment(person, value, retrievedAt), citations, notes: text };
958
+ }
762
959
  async function resolveCounties(ai, model, rows, gazetteer) {
763
960
  const { value } = await ai.extract({
764
961
  model,