@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/README.md +174 -0
- package/dist/index.cjs +683 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +555 -2
- package/dist/index.d.ts +555 -2
- package/dist/index.js +683 -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,173 @@ 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
|
+
## Grading, not self-reported confidence
|
|
107
|
+
|
|
108
|
+
`gradeSources()` derives how well an identity is established from the sources
|
|
109
|
+
that were actually checked, rather than from what a model said about its own
|
|
110
|
+
certainty. Measured, that number is not evidence: across real runs every lane
|
|
111
|
+
confirmed between one and three of four people who do not exist, confidently.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const report = gradeSources([
|
|
115
|
+
{ sourceUrl: "https://acme.example/team", sourceClass: "first-party",
|
|
116
|
+
facts: ["employer", "title", "location"], verifiedOn: "2026-08-03",
|
|
117
|
+
corroboration: "confirmed" },
|
|
118
|
+
{ sourceUrl: "https://press.example/x", sourceClass: "third-party",
|
|
119
|
+
facts: ["employer"], verifiedOn: "2026-08-03", corroboration: "confirmed" },
|
|
120
|
+
]);
|
|
121
|
+
// → { grade: "high", independentSources: 2, hasAnchor: true, reason: "…" }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`high` needs independent sources — two pages of one site agree by construction —
|
|
125
|
+
with at least one **first-party or authoritative** anchor and enough agreeing
|
|
126
|
+
facts, and no source naming somebody else. `DEFAULT_STANDARD` is those
|
|
127
|
+
thresholds; pass your own `GradeStandard` to tighten them without touching code.
|
|
128
|
+
Worth knowing before you act on `medium`: two third-party sources agreeing about
|
|
129
|
+
a person who does not exist reached it in a real run, which is what
|
|
130
|
+
`anchorRequiredForMedium` exists to close.
|
|
131
|
+
|
|
132
|
+
`unverifiable` is deliberately distinct from `unresolved`. In one run 60 of 93
|
|
133
|
+
claimed URLs came back unreachable, almost all login walls. Calling those "not
|
|
134
|
+
established" blames the search for a wall it cannot walk through, and calling
|
|
135
|
+
them established would be a lie.
|
|
136
|
+
|
|
137
|
+
Sources are what you store; the grade is computed. That ordering is the point —
|
|
138
|
+
changing the standard re-scores stored evidence instead of re-running inference.
|
|
139
|
+
|
|
140
|
+
Use `identityKey` (not `slugify`) to decide whether two records are the same
|
|
141
|
+
entity: it folds away punctuation, accents, `&`, and trailing legal suffixes, so
|
|
142
|
+
"Acme Corp.", "Acme, Inc." and "Acme & Co" agree.
|
|
143
|
+
|
|
144
|
+
## Strategies: gather evidence several ways, grade it once
|
|
145
|
+
|
|
146
|
+
A single research call is the wrong unit. Measured over real runs, a grounded
|
|
147
|
+
research pass found 14 of 18 real people — and also "confirmed" one of four
|
|
148
|
+
people who do not exist. Walking the employer's own website found only 5 of 18,
|
|
149
|
+
and confirmed none of the four, because a company's site does not contain
|
|
150
|
+
someone who does not work there.
|
|
151
|
+
|
|
152
|
+
They fail in opposite directions, so `investigatePerson` runs several and lets
|
|
153
|
+
`gradeSources` settle it. Composed, the two above reached `high` on 16 of 18,
|
|
154
|
+
with none of the four fabricated people getting there — a bar neither clears
|
|
155
|
+
alone.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { investigatePerson, researchStrategy, orgSiteStrategy, targetedProfileStrategy } from "@odla-ai/kg";
|
|
159
|
+
|
|
160
|
+
const result = await investigatePerson(
|
|
161
|
+
{
|
|
162
|
+
person: { slug: "abbey-chrystal", name: "Abbey Chrystal",
|
|
163
|
+
companyName: "Rhizos", companyWebsite: "https://rhizos.example" },
|
|
164
|
+
retrievedAt: "2026-08-03",
|
|
165
|
+
ai, models: { search: "…", extract: "…" },
|
|
166
|
+
readPage, // see below
|
|
167
|
+
},
|
|
168
|
+
[researchStrategy, orgSiteStrategy, targetedProfileStrategy],
|
|
169
|
+
);
|
|
170
|
+
// → { grade: "high", sources: [...], links: [...], findings: [...], skipped: [] }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Strategies run concurrently and are isolated: one that throws becomes a
|
|
174
|
+
`Finding` carrying its error, so a search provider being down costs you that
|
|
175
|
+
strategy's evidence rather than the first-party page another already read. One
|
|
176
|
+
that cannot run — `orgSiteStrategy` with no `companyWebsite` — reports itself
|
|
177
|
+
inapplicable instead of pretending to have checked, and is named in `skipped`.
|
|
178
|
+
|
|
179
|
+
A strategy never decides whether the person was found; it reports what it saw
|
|
180
|
+
and how strong the source was. That split is why adding a strategy cannot
|
|
181
|
+
inflate a verdict, and why `researchStrategy` enters its sources at `name-only`
|
|
182
|
+
at best however confident the model sounded.
|
|
183
|
+
|
|
184
|
+
`readPage` is yours to supply, deliberately. Fetched pages are attacker-
|
|
185
|
+
influenced, so kg never reaches for the network itself and cannot quietly bypass
|
|
186
|
+
whatever boundary you chose. In odla that is `@odla-ai/camel`'s artifact
|
|
187
|
+
ingress, which caps bytes, screens redirects for SSRF, and labels the body
|
|
188
|
+
`Unsafe`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const web = createWebArtifactIngress({ ingress, readers, limits: { maximumBytes: 512 * 1024 } });
|
|
192
|
+
const readPage = async (url: string) => {
|
|
193
|
+
try { return (await web.fetchArtifact(url)).body.value; } catch { return null; }
|
|
194
|
+
};
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## A person's key should not be their name
|
|
198
|
+
|
|
199
|
+
kg has been writing person nodes keyed on `slugify(name)`, and that fails in
|
|
200
|
+
both directions. Two people called John Smith at two companies collapse into one
|
|
201
|
+
node, silently merging their employers, links, and evidence. And a person whose
|
|
202
|
+
name is written differently by the next source — a married name, an accent
|
|
203
|
+
restored, "Bob" where the last pass saw "Robert" — mints a second node that
|
|
204
|
+
nothing will reconcile.
|
|
205
|
+
|
|
206
|
+
`identifyPerson` mints an opaque key once and indexes the ways that person is
|
|
207
|
+
recognized alongside it:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const { personKey, minted, fragment } = await identifyPerson(
|
|
211
|
+
db,
|
|
212
|
+
{ aliasType: "person_alias" },
|
|
213
|
+
person,
|
|
214
|
+
result.links,
|
|
215
|
+
result.fragment, // keyed on person.slug, as personToFragment produces
|
|
216
|
+
);
|
|
217
|
+
await writeFragment(db, fragment, ontology, mutationId);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The fragment comes back rekeyed onto `personKey`, with every edge that touched
|
|
221
|
+
the old key moved with it, plus one alias row per alias. Those rows are what
|
|
222
|
+
make the next pass idempotent — without them a minted key would be re-minted
|
|
223
|
+
every run, which is strictly worse than the name key it replaces.
|
|
224
|
+
|
|
225
|
+
Alias order is the contract. A public profile belongs to the person and survives
|
|
226
|
+
them changing jobs, so `linkedin:/in/abbeychrystal` is tried before
|
|
227
|
+
`name:abbeychrystal~rhizosviticulture`. The name alias includes the employer
|
|
228
|
+
because that is what tells namesakes apart; it is the bootstrap for someone with
|
|
229
|
+
no profile found yet, and they graduate off it as soon as any pass finds one —
|
|
230
|
+
the new profile alias attaches to the key they already have.
|
|
231
|
+
|
|
232
|
+
A person with no employer *and* no profile gets no alias at all, so every pass
|
|
233
|
+
mints them a fresh key. That is deliberate: duplicates are recoverable, a merged
|
|
234
|
+
pair of strangers is not.
|
|
235
|
+
|
|
236
|
+
**Adopting it.** The alias entity is yours to declare — kg reads and writes it
|
|
237
|
+
through the names in `PersonIdentityConfig` — so add one to your ontology with a
|
|
238
|
+
unique natural key and an attribute holding the person key:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
person_alias: {
|
|
242
|
+
naturalKey: "alias",
|
|
243
|
+
attrs: { alias: { type: "string", unique: true }, personKey: { type: "string", indexed: true } },
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Existing graphs keep working: `personToFragment` still keys on `person.slug`,
|
|
248
|
+
and nothing changes until you call `identifyPerson`. To migrate, backfill one
|
|
249
|
+
`name:` alias row per existing person pointing at their current key, then start
|
|
250
|
+
identifying — already-keyed people resolve to themselves and pick up profile
|
|
251
|
+
aliases as passes find them.
|
|
252
|
+
|
|
79
253
|
## Provenance & merging
|
|
80
254
|
|
|
81
255
|
`writeFragment` reads existing nodes by natural key (one batched query), merges
|