@odla-ai/kg
v0.6.0
Published
Ontology-driven knowledge-graph builder: pluggable source connectors, LLM extraction against a config-as-data ontology, and a provenance-preserving graph writer persisting into odla-db.
Maintainers
Readme
@odla-ai/kg
⚠️ Early access — pre-1.0. Agents work from bounded runbooks; humans approve credentials, production changes, releases, and merges. APIs and exact package availability can change. Review the documented guarantees and limitations; this software is MIT-licensed and provided without warranty.
An ontology-driven knowledge-graph builder. Pluggable source connectors pull from the web and data feeds, an LLM extracts entities + relationships against a config-as-data ontology, and a provenance-preserving graph writer persists the result into odla-db.
The library is host-agnostic: it runs in Cloudflare Workers, Node, or tests — the host supplies the database client, LLM keys, and a KV-like store. The public platform and package manuals describe how to compose it with Workflows, scheduled triggers, an HTTP API, and a viewer UI.
Ask the runbooks first. odla's operational procedures live in a database, not in this file:
npx @odla-ai/cli runbook ask "<question>"returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped.d.tsare the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.
Architecture
Connector ──RawItem[]──▶ Extraction ──GraphFragment──▶ writeFragment ──Op[]──▶ odla-db
(how data (LLM forced-tool (ontology-typed (Lookup upserts +
was acquired) OR pure mapper) nodes + edges) links, idempotent)RawItem— a connector emits eithertext(→ LLM extraction) or a ready-madefragment(structured feeds bypass the LLM).GraphFragment— ontology-typednodes[]+edges[]; the single shape the writer consumes, whatever produced it.Ontology— data, not code. It compiles to the odla-db schema (toSerializedSchema), the writer's natural-key + merge-rule maps (naturalKeysOf,mergeRulesOf,humanAttrsOf), and by convention the extractor's tool schema.
Add a source → implement SourceConnector + one registry line. Add an
entity/edge type → edit the ontology data. Neither touches the pipeline.
Usage
import { init } from "@odla-ai/db";
import {
Store, getProvider, makeContext, ingestFresh, writeFragment,
toSerializedSchema, type Ontology, type PipelineDeps,
} from "@odla-ai/kg";
// 1. Your ontology is ordinary application-owned data.
const ontology: Ontology = { entities: { /* … */ }, edges: { /* … */ } };
// 2. Wire the host services once. Env-var keys work standalone…
const db = init({ appId, adminToken, endpoint });
const provider = getProvider({ LLM_PROVIDER: "claude", ANTHROPIC_API_KEY: key });
// …but platform apps should bring a configured Ai instead (no LLM keys in
// worker env): createProvider({ provider: "claude", ai }) with the ai from
// @odla-ai/ai's initFromPlatform. The
// platform's default model is ignored here; kg's MODEL_TIERS govern verbs.
const deps: PipelineDeps = { db, provider, ontology, extract: myExtractor, store: new Store(kv) };
// 3. Run items through the shared pipeline (or call writeFragment directly).
await ingestFresh(step, deps, freshItems, "ingest");Key seams the host supplies:
PipelineDeps— db (AdminDb), LLMProvider, the ontology, the extractor (your domain craft), and aStoreover anyKVLike.ContextConfig(→makeContext) — what connectors see: ontology, lazy provider, named secrets.StepLike— a structural view of Cloudflare'sWorkflowStep(asStepLike(step)); anydo(name, cfg?, fn)runner works, including a plain inline runner in tests.
Built-in connectors
rss, url, gdelt (text → LLM extraction) · fred, edgar (structured →
straight to the writer) · web-search (LLM web-search discovery) · gov-data
(template proving a new source needs no pipeline change).
Enrichment & verdicts
enrichCompany and enrichPerson run a grounded web search, then a forced-tool
extraction, then validate the result into a GraphFragment.
Person enrichment always returns a verdict — confirmed, probable,
ambiguous, not_found, or wrong_entity — with a confidence and evidence
tied to source URLs. The distinctions matter: a pass that confidently describes a
different person with the same name is a much worse failure than one that found
nothing, and collapsing the two makes it invisible. The fragment is empty unless
the verdict permits writing, and a "confirmation" whose evidence carries no
checkable source URL is downgraded to ambiguous rather than believed.
validateLinks drops profile URLs whose host contradicts their claimed kind —
a linkedin.com/in/<name> pattern served from some other host is the single most
common hallucination in this task. Scope is deliberately public and professional:
site, blog, Substack, LinkedIn, X/Bluesky, GitHub, employer, and business contact
points the person or their employer published. Never home addresses, personal
phones, or personal email.
Grading, not self-reported confidence
gradeSources() derives how well an identity is established from the sources
that were actually checked, rather than from what a model said about its own
certainty. Measured, that number is not evidence: across real runs every lane
confirmed between one and three of four people who do not exist, confidently.
const report = gradeSources([
{ sourceUrl: "https://acme.example/team", sourceClass: "first-party",
facts: ["employer", "title", "location"], verifiedOn: "2026-08-03",
corroboration: "confirmed" },
{ sourceUrl: "https://press.example/x", sourceClass: "third-party",
facts: ["employer"], verifiedOn: "2026-08-03", corroboration: "confirmed" },
]);
// → { grade: "high", independentSources: 2, hasAnchor: true, reason: "…" }high needs independent sources — two pages of one site agree by construction —
with at least one first-party or authoritative anchor and enough agreeing
facts, and no source naming somebody else. DEFAULT_STANDARD is those
thresholds; pass your own GradeStandard to tighten them without touching code.
Worth knowing before you act on medium: two third-party sources agreeing about
a person who does not exist reached it in a real run, which is what
anchorRequiredForMedium exists to close.
unverifiable is deliberately distinct from unresolved. In one run 60 of 93
claimed URLs came back unreachable, almost all login walls. Calling those "not
established" blames the search for a wall it cannot walk through, and calling
them established would be a lie.
Sources are what you store; the grade is computed. That ordering is the point — changing the standard re-scores stored evidence instead of re-running inference.
Use identityKey (not slugify) to decide whether two records are the same
entity: it folds away punctuation, accents, &, and trailing legal suffixes, so
"Acme Corp.", "Acme, Inc." and "Acme & Co" agree.
Strategies: gather evidence several ways, grade it once
A single research call is the wrong unit. Measured over real runs, a grounded research pass found 14 of 18 real people — and also "confirmed" one of four people who do not exist. Walking the employer's own website found only 5 of 18, and confirmed none of the four, because a company's site does not contain someone who does not work there.
They fail in opposite directions, so investigatePerson runs several and lets
gradeSources settle it. Composed, the two above reached high on 16 of 18,
with none of the four fabricated people getting there — a bar neither clears
alone.
import { investigatePerson, researchStrategy, orgSiteStrategy, targetedProfileStrategy } from "@odla-ai/kg";
const result = await investigatePerson(
{
person: { slug: "abbey-chrystal", name: "Abbey Chrystal",
companyName: "Rhizos", companyWebsite: "https://rhizos.example" },
retrievedAt: "2026-08-03",
ai, models: { search: "…", extract: "…" },
readPage, // see below
},
[researchStrategy, orgSiteStrategy, targetedProfileStrategy],
);
// → { grade: "high", sources: [...], links: [...], findings: [...], skipped: [] }Strategies run concurrently and are isolated: one that throws becomes a
Finding carrying its error, so a search provider being down costs you that
strategy's evidence rather than the first-party page another already read. One
that cannot run — orgSiteStrategy with no companyWebsite — reports itself
inapplicable instead of pretending to have checked, and is named in skipped.
A strategy never decides whether the person was found; it reports what it saw
and how strong the source was. That split is why adding a strategy cannot
inflate a verdict, and why researchStrategy enters its sources at name-only
at best however confident the model sounded.
readPage is yours to supply, deliberately. Fetched pages are attacker-
influenced, so kg never reaches for the network itself and cannot quietly bypass
whatever boundary you chose. In odla that is @odla-ai/camel's artifact
ingress, which caps bytes, screens redirects for SSRF, and labels the body
Unsafe:
const web = createWebArtifactIngress({ ingress, readers, limits: { maximumBytes: 512 * 1024 } });
const readPage = async (url: string) => {
try { return (await web.fetchArtifact(url)).body.value; } catch { return null; }
};A person's key should not be their name
kg has been writing person nodes keyed on slugify(name), and that fails in
both directions. Two people called John Smith at two companies collapse into one
node, silently merging their employers, links, and evidence. And a person whose
name is written differently by the next source — a married name, an accent
restored, "Bob" where the last pass saw "Robert" — mints a second node that
nothing will reconcile.
identifyPerson mints an opaque key once and indexes the ways that person is
recognized alongside it:
const { personKey, minted, fragment } = await identifyPerson(
db,
{ aliasType: "person_alias" },
person,
result.links,
result.fragment, // keyed on person.slug, as personToFragment produces
);
await writeFragment(db, fragment, ontology, mutationId);The fragment comes back rekeyed onto personKey, with every edge that touched
the old key moved with it, plus one alias row per alias. Those rows are what
make the next pass idempotent — without them a minted key would be re-minted
every run, which is strictly worse than the name key it replaces.
Alias order is the contract. A public profile belongs to the person and survives
them changing jobs, so linkedin:/in/abbeychrystal is tried before
name:abbeychrystal~rhizosviticulture. The name alias includes the employer
because that is what tells namesakes apart; it is the bootstrap for someone with
no profile found yet, and they graduate off it as soon as any pass finds one —
the new profile alias attaches to the key they already have.
A person with no employer and no profile gets no alias at all, so every pass mints them a fresh key. That is deliberate: duplicates are recoverable, a merged pair of strangers is not.
Adopting it. The alias entity is yours to declare — kg reads and writes it
through the names in PersonIdentityConfig — so add one to your ontology with a
unique natural key and an attribute holding the person key:
person_alias: {
naturalKey: "alias",
attrs: { alias: { type: "string", unique: true }, personKey: { type: "string", indexed: true } },
}Existing graphs keep working: personToFragment still keys on person.slug,
and nothing changes until you call identifyPerson. To migrate, backfill one
name: alias row per existing person pointing at their current key, then start
identifying — already-keyed people resolve to themselves and pick up profile
aliases as passes find them.
Provenance & merging
writeFragment reads existing nodes by natural key (one batched query), merges
scalar attrs per the ontology's rules (replace / prefer / earliest /
latest), never writes human-owned attrs, and commits nodes-then-links in a
single idempotent transaction (deterministic mutationId).
