Extract Claims
Turn a source into a structured list of the specific claims it makes: a deep research report, a curated knowledge file, or any YAML/JSON document with prose in it. Each claim is one atomic assertion, with enough provenance for a person to find it in the source again.
Extraction does not judge whether a claim is true. It is the first step of comparing sources (issue #43): claims from two sources are what alignment and verification will work on.
Two Claude Code skills, in .claude/skills/, follow this page:
extract-claims runs an extraction, and review-claim-set audits and filters
the result.
Quick start
# A deep research report (needs a model)
export OPENAI_API_KEY=...
deep-research-client claims extract report.md -o report.claims.json
# The same, through a logged-in Claude Code instead of an API key
deep-research-client claims extract report.md -o report.claims.json --llm-backend claude-code
# A curated dismech disease file (no model)
deep-research-client claims extract Marfan_Syndrome.yaml -o marfan.claims.yaml
From Python:
from openai import AsyncOpenAI
from deep_research_client.claims import extract_claims
claims = extract_claims("report.md", llm_client=AsyncOpenAI())
for claim in claims.claim_list:
print(claim.id, claim.anchor_status, claim.claim_text)
extract_claims is synchronous and runs its own event loop, so it raises
RuntimeError where a loop is already running, as in Jupyter. There, use
await aextract_claims(...).
The client can be any OpenAI-compatible async client: OpenAI, CBORG, or a local
server via AsyncOpenAI(base_url=...).
Through Claude Code, with no API key
ClaudeCodeChatClient answers the same calls through the local claude CLI,
so a machine where Claude Code is logged in can extract with Claude models and
no key. Each call is one claude --print run with no tools, no settings files
and no MCP servers, so your hooks, output style and CLAUDE.md files do not
reach the prompt. Billing is the Claude Code login's.
from deep_research_client.claims import extract_claims
from deep_research_client.claude_code_chat import ClaudeCodeChatClient
claims = extract_claims("report.md", llm_client=ClaudeCodeChatClient(), model="sonnet")
What to know about this backend:
- Temperature cannot be set. Replies are not deterministic.
- Thinking is off, so
max_tokenscounts only the reply, as it does for an OpenAI chat model. - An alias is recorded as the model it resolved to.
sonnetgoes intoextractor.modelas the full id the replies report, such asclaude-sonnet-5. (This holds for any backend: an OpenAI endpoint reports a dated id such asgpt-4o-mini-2024-07-18.)
The default is sonnet. Pass --llm-model opus, haiku, or a full model id.
Each call starts a process, so small sections cost a few seconds each; a
64 KB report of 64 sections took about four minutes at the default
concurrency of 4.
Long sections
A reply cut off at max_tokens (default 4096) is an error, never a shorter
claim list. A section that makes many claims can need more:
--llm-max-tokens 16000.
How each kind of source is read
| Source | Format | How claims are found | Needs a model |
|---|---|---|---|
| Markdown report | markdown |
Each section is decomposed into atomic claims by a model | Yes |
| dismech disease file | dismech |
Each curated pathophysiology, phenotype, treatment and inheritance record is one claim | No |
| ai-gene-review file | gene-review |
Each kept annotation and core function is one claim | No |
| Any other YAML/JSON | structured |
Each prose field is decomposed like a report section | Yes |
--format auto (the default) decides from the extension and, for YAML/JSON,
from the keys: a disease_term or pathophysiology key means dismech, and
existing_annotations or core_functions means gene-review.
Reports
A report written by deep-research-client research is read from its
## Output section. The question, the numbered ## Citations list and any
generated validation sections are not claims the report makes, so they are
skipped. So is a reference list the provider wrote inside its answer (a
References, Sources or Bibliography heading, and anything under it), and
any heading that contains such a word when the text under it is mostly list
entries naming a URL, DOI or PMID, such as Falcon's Key references (URLs in
evidence). A heading like Evidence sources over prose is kept. The same
test also skips a section such as Data sources whose lines list databases
with their URLs (- ClinVar https://...): it reads as a reference list, so
claims it makes about those databases are not extracted. Some providers
(Falcon) open their answer by repeating the whole question; when the answer
holds the question's first line and then its closing lines, everything up to
there is the question and is skipped too. Plain markdown with no ## Output is
read whole. The claim set's title is the answer's first heading at its highest
level. Each heading starts a new unit, and
long sections are split at paragraph breaks. A # line inside a code fence is
code, not a heading. A table has no paragraph breaks, so a long one is split
between its rows, and each later piece is sent with the table's header as
context: the model reads the columns from it, but it is not part of the text,
and a quote taken from it does not anchor.
For each unit the model returns, per claim:
claim: the assertion as a standalone sentence. Pronouns are resolved, and a table's headers are folded into claims made by its cells.quote: the passage of the text that states the claim, copied verbatim.- the parts of the claim, when it has them: subject, predicate, object, a negation flag and qualifiers such as "may" or "in adults"
- the entities mentioned, and the citation markers attached to the passage
- what the claim is about, and for a claim about the domain, what its basis is (see What a claim is about, and what it rests on)
The quote is what makes a claim auditable. It is located in the report and
becomes the claim's source_span: character offsets into the file, end
exclusive, so text[start:end] == span.text. anchor_status records how the
quote was found:
anchor_status |
Meaning |
|---|---|
EXACT |
The quote occurs verbatim |
NORMALIZED |
It occurs once whitespace, markdown emphasis, link targets, citation markers like [3], quote and dash styles, and case are ignored. The span still covers the report's own characters |
UNANCHORED |
The quote is not in the report. The claim is kept, with no span, so the gap is visible; don't treat it as coming from the report |
NOT_APPLICABLE |
A structured record, located by source_path instead |
claims extract warns on stderr when any claims are UNANCHORED.
An UNANCHORED claim can carry a nearest_passage: the sentence of its
section closest to the quote the model gave, with a similarity score from 0 to
100 and, when it can be located, a span. It is found by
linkml-reference-validator's fuzzy matcher, and kept only at a score of 70 or
more and when it shares at least half the quote's content words. It shows why
the quote failed: a misquote ("autosomal recessive" where the report says
"dominant"), a quote joined from two sentences, a loose paraphrase. It is a
diagnostic and never the claim's location. A quote under five words gets none.
The source text goes into the prompt as it is, so a source can carry text that
tries to steer the model, such as instructions or a fake end-of-text marker. This
matters for third-party sources. Anchoring limits what such text can do: a claim
it makes the model invent has no quote in the source, so it comes back
UNANCHORED and gets no citations. But a steered model can still skip claims
the source does make. Treat a claim set from an untrusted source as that
source's claims only where they are anchored, and don't read it as complete.
Citations are checked the same way. A marker the model reports is kept only if it is in the claim's own sentence, so a marker from elsewhere in the section is not attached on the model's say-so:
- The sentence runs from the previous sentence end to the next one after the
claim's span. A marker written after the full stop (
.[1]or. [1]) belongs to the sentence it closes. - The full stop of
et al.,e.g.,vs.,Fig.and similar doesn't end a sentence. A decimal point doesn't either. - A sentence that ends inside a bracket or quotation (
in adults.),"A is B.") ends where it closes. - A claim inside a quotation of several sentences, cited once after the
quotation closes (
*"A is B. C is D."* ([PMID: 1](...))), reads to the end of the sentence that closes it, so the citation after it counts. - A table row, a list item, or a paragraph ends it too. A line break inside a hard-wrapped paragraph doesn't.
[3]is found in[2, 3],[2-5]and the linked[3](https://...), but not in[Figure 3].- A claim with no span (
UNANCHORED) gets no citations.
A marker that names no identifier itself, such as Falcon's key
(mustillo2023clinicalpracticeguidelines pages 16-17) or an author and year
(Soster 2023), is looked up in the report's reference entries, numbered or
not: by the key, or by the first author's surname and the year. The
identifier is taken only when every matching entry that names one names the
same one, so an ambiguous surname resolves to nothing.
A numbered marker such as [2] is resolved through the report's own citation
list to a PMID or DOI where it names one. A report written with
research --separate-citations keeps that list in a file of its own: pass it
with --citations, or leave it beside the report as <report>.citations.md or
<report>.md.citations.md (as dismech's are) to have it found. When the report
has neither, or both are empty, the provider's own numbered reference list
inside its answer (References, Sources...) is used instead: the longest, if there are
several, and never a Further reading list. Entries may be written 1. ...,
[1] ..., [1]: ..., or any of them as a bulleted item, and are read only
under such a heading: Markdown reference definitions ([1]: https://...) left
at the end of a document with no heading above them are not read. Footnotes
([^1]: ...) are not read either. Nor is numbering that restarts in each section's own Sources
list: no single list serves such an answer.
Some reports list papers, one section each, headed ### [n] Title, with the
paper's metadata and abstract under it. Asta's reports do. There the source of
every claim in the section is paper n, though no sentence carries a marker.
So a section headed by [n] gives each located claim in it, subsections
included, a citation of paper n. Its identifier comes from the citation list,
or else from the section's own PMID: or DOI: lines. Every citation records
how it was attached, in scope:
scope |
Meaning |
|---|---|
SENTENCE |
A marker in the claim's own sentence |
SECTION |
The claim's section is headed by the cited work |
RECORD |
A curated record's own evidence |
Curated files
A curated record already is one claim, so no model is needed. Each claim has:
- a
source_pathsuch asphenotypes[1]. The index counts every entry in the list, so it points at the record even when others were skipped. - the file's disease or gene as its subject, grounded (for example
MONDO:0007947, orUniProtKB:P35555for a gene review's accession) - the curated ontology terms as grounded entities
- the evidence references as citations
aboutDOMAIN. A record with evidence isCITEDwith basisSECONDARY_SOURCE; one with none isUNCITEDwith no basis, since a bare curated record does not say what it rests on.
Phenotype, treatment and inheritance records also get a predicate and
object from their section ("has phenotype" HP:0001083). A record with no
description is given one built from them: "Marfan syndrome has phenotype
Ectopia lentis."
Only the sections the evaluation loaders already read are covered. Other dismech sections, such as diagnosis, prevalence and genetics, are not yet claims.
What a claim is about, and what it rests on
Three slots classify a claim. The first is checked by the code; the other two are the model's judgement.
citation_status says whether the source cites the claim. It makes the
lack of a citation explicit, since an empty citations list alone cannot tell
"cites nothing" from "not checked":
citation_status |
Meaning |
|---|---|
CITED |
At least one citation is attached, by sentence or by section, and all are in citations |
UNCITED |
The claim was located and nothing is attached to it. Its sentence may still cite a source for another claim. For a curated record: no evidence |
UNKNOWN |
The claim is UNANCHORED, so there was no sentence to check |
about is WORK for a claim about a publication as an object (its
authors, venue, date, identifiers, what it covers) and DOMAIN for a claim
about the subject matter. Reports that list papers, as Asta's do, make many
WORK claims: "The paper X was published in 2021."
basis, for DOMAIN claims only, is how the source presents the claim:
basis |
Meaning |
|---|---|
OBSERVATION |
The source document's own finding: an experiment, analysis, dataset or case it made or ran. A finding it reports from another work, such as an abstract under a [n] heading, is SECONDARY_SOURCE |
SECONDARY_SOURCE |
Attributed to another work, by a citation marker or by naming it or its authors |
BACKGROUND_KNOWLEDGE |
Stated with no citation or attribution, as known in the field |
Only a DOMAIN claim has a basis, so when the model gives a basis and no
readable about, the claim is read as DOMAIN; a basis on a claim it called
WORK is dropped.
When the model gives neither, the claim has no about and no basis, even in
a section headed by a cited work, where it is still CITED: the code cannot
tell a claim about the paper from a claim about its subject.
A deep research report observes little itself, so most of its domain claims
rest on a secondary source or on background knowledge. In a section headed by a
cited work (### [n] Title), a located domain claim's basis is set to
SECONDARY_SOURCE by the code, whatever the model said: the section's
structure already says where the claim comes from.
A claim set is refused, when built or loaded, if these contradict what the
code knows: a CITED claim with no citations, an UNCITED one with some, an
UNKNOWN one that was anchored, or a basis on a claim that is not DOMAIN.
One contradiction is only reported: a claim the model calls
BACKGROUND_KNOWLEDGE that carries a citation. Either the basis or the marker
it attached is wrong, and the code cannot tell which.
ClaimSet.cited_background_claims lists these, and claims extract warns.
The claim set
Output is one ClaimSet per source, defined in LinkML
(src/deep_research_client/claims/claims.yaml). A curated example, from the
test fixtures:
source:
id: tests/input/claims/marfan_dismech.yaml
source_type: STRUCTURED_DOCUMENT
title: Marfan syndrome
content_sha256: c6a9101b0de51804e12f3bce88372c72ecb66e97084601d1472a01cca6336fb7
extractor:
name: dismech
claims:
- id: phenotypes[2]
claim_text: Marfan syndrome has phenotype Ectopia lentis.
source_path: phenotypes[2]
anchor_status: NOT_APPLICABLE
subject:
label: Marfan syndrome
id: MONDO:0007947
predicate:
label: has phenotype
object:
label: Ectopia lentis
id: HP:0001083
entities:
- label: Ectopia lentis
id: HP:0001083
citation_status: UNCITED
about: DOMAIN
A claim from a report has the same fields, plus a span. source.content_sha256
is the hash of the exact text the offsets index into. To check a saved set
against its source, compare the hash, then:
from pathlib import Path
from deep_research_client.claims import ClaimSet
claims = ClaimSet.model_validate_json(Path("report.claims.json").read_text())
assert claims.mismatched_spans(Path("report.md").read_text(encoding="utf-8")) == []
extractor records the extractor, and for model-based extraction the model and
prompt version, so two sets made differently are never compared unknowingly.
Relation to linkml-reference-validator
Slots that mean a standard term declare it: a span's text, start and end
are the Web Annotation oa:exact, oa:start and oa:end, a citation's
reference_id is dcterms:references, and a source's title is
dcterms:title. These are the URIs linkml-reference-validator finds excerpt,
reference and title fields by. It checks an excerpt against a reference only
when one class holds both, and no class here does: a span is the source's own
words, not a quote from the work it cites, so it must not be validated as one.
Relation to OntoGPT
subject, predicate, object, qualifier, subject_qualifier,
object_qualifier and negated are named as in OntoGPT's Triple and
ScientificClaim, and EntityMention mirrors its NamedEntity, so claims
convert between the two directly. Two differences are deliberate:
- Every claim has its own span. It is established by locating the quote, not by searching for an entity label after the fact.
- Offsets are end-exclusive integers. OntoGPT uses inclusive
"start:end"strings.
What is not here yet
- Alignment and relation labels across two claim sets (
IDENTICAL,SUBSUMING,CONTRADICTORY...), rubric aggregation and verification. These are the later parts of issue #43, and they consume these sets. - Checking a claim against the work it cites.
deep_research_client.claims.verification.verify_claimsis a stub that raisesNotImplementedError; its module docstring has the plan, which reuses linkml-reference-validator's reference fetching and caching. - Ontology grounding of entities in reports beyond CURIEs written in the text. An entity is grounded when the report or the model gives an identifier; a pluggable annotator is future work.
- Publication identifier normalisation. Citations carry the marker as written, plus a PMID or DOI where one is readable; fuller normalisation is issue #41.