@odla-ai/kg 0.5.0 → 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 +143 -0
- package/dist/index.cjs +486 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +412 -1
- package/dist/index.d.ts +412 -1
- package/dist/index.js +486 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,10 +103,153 @@ site, blog, Substack, LinkedIn, X/Bluesky, GitHub, employer, and business contac
|
|
|
103
103
|
points the person or their employer published. Never home addresses, personal
|
|
104
104
|
phones, or personal email.
|
|
105
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
|
+
|
|
106
140
|
Use `identityKey` (not `slugify`) to decide whether two records are the same
|
|
107
141
|
entity: it folds away punctuation, accents, `&`, and trailing legal suffixes, so
|
|
108
142
|
"Acme Corp.", "Acme, Inc." and "Acme & Co" agree.
|
|
109
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
|
+
|
|
110
253
|
## Provenance & merging
|
|
111
254
|
|
|
112
255
|
`writeFragment` reads existing nodes by natural key (one batched query), merges
|