@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 +31 -0
- package/dist/index.cjs +197 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +144 -2
- package/dist/index.d.ts +144 -2
- package/dist/index.js +197 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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,
|