Skip to content

Inputs

openodke reads any extractor's output as one triple per JSON Lines row, beside the texts the triples came from. The quickstart runs it end to end, and four adapters write it from other libraries' output.

The row

{"doc": "halden", "subject": "Halden Robotics", "predicate": "office_in", "object": "Lyon"}
Field Required Meaning
doc yes The source text: a Document.id, or a loaded file's name without its suffix (texts/halden.txt is halden)
subject yes The subject's name, as the text gives it
predicate yes Matched to the ontology's predicates, ignoring case
object yes A name, or a literal: string, number or boolean
subject_type, object_type no Matched to the ontology's types, ignoring case
start, end no Half-open character offsets of the support. Both or neither
quote no The supporting text. With offsets, it must be the text at them
polarity no asserted (default), denied or partial
qualifiers no An object, such as {"start_time": "2019"}
confidence no 0 to 1. Unset: the stage's confidence
extractor no Unset: the stage's extractor
id no The fact's id, to join it to labels or another run
schema_version no The format version the row was written to, such as "1.0". Unset: the reader's own. Another major version is refused; a later minor is read for the fields this version knows (DECISIONS #52)

Any other field, or a row that cannot be read, is an error naming its line. The JSON Schema ships as openodke/interop/triples.schema.json (SCHEMA_PATH in openodke.interop.triples; Stability).

Offsets, a quote, or neither

The row has Its evidence What the grounder does
start and end A citation The free span check confirms the offsets resolve and match any quote; the model reads that span
quote only A citation, found by exact match As above. A quote not in the text is refused as not_found, with no model call
neither The whole text, with span_origin context The model reads the whole text

A context span is not a citation (DECISIONS #25). locate=True finds a narrower one for free.

Types and keys

With an ontology, the predicate's range decides edge or property, whatever object_type says. A subject with no type takes the predicate's domain when there is exactly one. A predicate the ontology lacks is kept, for the gate to judge.

Without one, a string object is an entity unless object_type names a literal type (string, text, integer, number, float, boolean, date, datetime, quantity). Numbers and booleans are literals, and an untyped entity is a Thing.

An entity's key is its type and case-folded name. The resolver decides when two names are one entity.

From Python

TriplesExtractor is the extract stage:

Argument Default Meaning
triples required A JSON Lines path, or rows (dicts or TripleRows)
extractor "triples" Stamped on facts whose row names none
confidence 0.5 For rows that give none
documents () The pipeline's documents, so evidence carries each one's URI and tier
  • A row finds its text by Document.id, then by file name. When two documents share a file name (2023/report.txt, 2024/report.txt), the first gets the rows and the other a warning; give such texts ids.
  • A document's triples all arrive with its first chunk, so leave the chunker out.
  • read_triples(path) is an iterator of TripleRows, read a line at a time; to_fact(row, doc, ontology) makes one Fact.
  • A stage made from a file checks every row up front and holds none; feed(rows) swaps in the next micro-batch's, as the Validator streams a file.

TriplesExtractor.stats counts rows by evidence (cited, quoted, quote_not_found, context), unmatched_rows whose doc matched no document, and ambiguous_rows whose file name matched two; unmatched_docs and ambiguous_docs name up to 20.

From odke run

The triples extractor takes path (required), extractor and confidence, and the run's inputs are its texts. The smallest run config, examples/triples/odke.yaml, runs five rows, one of each kind, on recorded answers:

odke run examples/triples/odke.yaml
odke eval spans --facts examples/triples/out

odke eval spans counts three of the five as citing no span: the two rows with neither, and the quote not in the text. Without a config, odke validate and odke ground take the file, the texts and any adapter.

From other libraries

Each adapter returns (rows, texts), the rows and the Documents they cite, without importing the library it reads. The LangChain, LangExtract and neo4j-graphrag adapters are provisional. The examples share this setup:

from openodke import Ontology, Pipeline
from openodke.ground import LLMGrounder
from openodke.interop import TriplesExtractor
from openodke.llm.testing import RecordedClient

text = (
    "Halden Robotics was founded in Leeds in 2014 by Mara Quist. "
    "It opened a second office in Lyon in 2019."
)
ontology = Ontology.from_dict(
    {
        "name": "companies",
        "types": {"Company": {}, "City": {}},
        "predicates": {"office_in": {"domain": ["Company"], "range": "City"}},
    }
)
# Recorded answers, so the examples run with no key.
client = RecordedClient([{"match": "— Lyon (City)", "response": {"verdict": "supported"}}])
grounder = LLMGrounder(client=client)

LangChain GraphDocument

from_graph_documents reads LLMGraphTransformer's output: GraphDocuments, their model_dump() dicts, or a JSON file of either (an array, or one per line). It cites nothing, so every row is context.

GraphDocument Row
source.page_content The text; its id is source.id or a hash of the text, and metadata["source"] its URI
relationships[].source.id, .target.id subject, object
relationships[].source.type, .target.type subject_type, object_type; the untyped Node is left out
relationships[].type predicate
relationships[].properties qualifiers
nodes[].properties One literal row per property
from openodke.interop import from_graph_documents

company, city = {"id": "Halden Robotics", "type": "Company"}, {"id": "Lyon", "type": "City"}
office = {"source": company, "target": city, "type": "OFFICE_IN"}
graph_document = {"nodes": [], "relationships": [office], "source": {"page_content": text}}
rows, texts = from_graph_documents([graph_document])
kg = Pipeline(ontology, TriplesExtractor(rows, documents=texts), grounder=grounder).run(texts)
print(kg.facts[0].verdict.value, kg.facts[0].evidence[0].span_origin.value)  # supported context

LangExtract

from_langextract reads the file lx.io.save_annotated_documents writes, the AnnotatedDocuments lx.extract returns, or their dicts.

LangExtract Row
document_id, text The text and its id
extraction_text subject; also quote, unless alignment_status says the match was not exact
extraction_class subject_type
attributes One row per key and value (per item of a list): key as predicate, value as object
char_interval.start_pos, .end_pos start, end
char_interval: null The whole text, context
no attributes No row; a warning counts them

For another reading, pass triples= a function from one extraction, in its JSON form, to the rows it states; the extraction's offsets and quote fill in what it leaves out. A LangExtract span is usually just a name, so the example grounds against the whole document:

from openodke.interop import from_langextract

company = {"extraction_class": "company", "extraction_text": "Halden Robotics"}
company |= {"char_interval": {"start_pos": 0, "end_pos": 15}, "attributes": {"office_in": "Lyon"}}
rows, texts = from_langextract([{"document_id": "halden", "text": text, "extractions": [company]}])
whole = LLMGrounder(client=client, context="document")
kg = Pipeline(ontology, TriplesExtractor(rows, documents=texts), grounder=whole).run(texts)
print(kg.facts[0].verdict.value, kg.facts[0].evidence[0].span.quote)  # supported Halden Robotics

neo4j-graphrag

from_graphrag reads the Neo4jGraph its extractor returns, or its JSON form. read_graphrag(driver, config=...) reads a store neo4j-graphrag wrote, in one read transaction. Each relationship is grounded against the chunk the extractor read.

neo4j-graphrag Row
entity properties.name, else the node's id subject, object
entity label, other than __Entity__ and __KGBuilder__ subject_type, object_type
relationship type predicate
relationship properties qualifiers
other entity properties One literal row each
Chunk.text of the chunk both ends link to by FROM_CHUNK The text, context; its Document's path is the URI
ends that share no chunk Their document's text: document=, or its chunks in order
no chunk at all Left out, with a warning, unless document= is given
config= (a LexicalGraphConfig) The label and property names read
from openodke.interop import from_graphrag

chunk = {"id": "c0", "label": "Chunk", "properties": {"text": text, "index": 0}}
company = {"id": "c0:0", "label": "Company", "properties": {"name": "Halden Robotics"}}
city = {"id": "c0:1", "label": "City", "properties": {"name": "Lyon"}}
office = {"start_node_id": "c0:0", "end_node_id": "c0:1", "type": "OFFICE_IN"}
links = [{"start_node_id": n, "end_node_id": "c0", "type": "FROM_CHUNK"} for n in ("c0:0", "c0:1")]
rows, texts = from_graphrag({"nodes": [chunk, company, city], "relationships": [office, *links]})
kg = Pipeline(ontology, TriplesExtractor(rows, documents=texts), grounder=grounder).run(texts)
print(kg.facts[0].verdict.value, kg.facts[0].evidence[0].doc_id)  # supported c0

A Neo4j graph

read_neo4j(driver) reads a graph back to ground it where it stands. On a graph Neo4jSink wrote, each row cites what the run cited; pass documents= its texts. On any other graph, text_property= names the relationship property holding the text. Only write_verdicts writes.

Neo4j Row
relationship type predicate
each end's sink label, else name_property (name), else key or element id subject, object
node labels other than the sink's Entity subject_type, object_type
a Claim end's value object, a literal
the relationship's element id id, which write_verdicts writes back by
the sink's polarity, extractor, confidence and qualifiers The same fields
each evidence_doc_ids entry One row, on the document with that id or URI
evidence_starts, evidence_ends of a cited span start, end; other spans are the whole text again
text_property= The text, context; other properties are qualifiers
none of these Left out, with a warning

Valid times, support, verdicts and resolved keys are not read back.

from neo4j import GraphDatabase

from openodke.interop import read_neo4j, write_verdicts

driver = GraphDatabase.driver(uri, auth=auth)
rows, texts = read_neo4j(driver, documents=corpus)  # or text_property="sentence"
kg = Pipeline(ontology, TriplesExtractor(rows, documents=texts), grounder=LLMGrounder()).run(texts)
write_verdicts(driver, kg.facts)  # sets odke_verdict on each relationship read