Changelog¶
All notable changes to kglite-docs are recorded here. Format loosely follows Keep a Changelog; versions follow SemVer, but pre-1.0 minor releases may include breaking changes (called out below).
Unreleased¶
[0.0.15] — 2026-05-31¶
OCR legibility & portability, from a full-corpus run on 0.0.14 (745 pages OCR'd). Makes "OCR'd" mean "readable" (illegibility surfaced, not silently counted), lets you re-OCR + escalate bad pages, right-sizes/tiles the image so fine print survives, makes OCR portable (export/import sidecar) and OCR'd text first-class (embedded + searchable), pins model guidance (Sonnet default; small models fabricate), and adds a structured batch submit. Plus versioned study reports on the graph so analysis stops cluttering the folder. Also folds in the Read the Docs build fix (static docs, no runtime stack).
Added — versioned study reports (on the graph, not .md litter)¶
study("report", name=…, text=…)saves a markdown report on the study node — named (several distinct reports per study coexist) and append-only versioned (each save is a new version; full history, latest-wins on read).study("reports")lists names + versions;study("get_report")reads (latest or a specificversion);study("export_report")writes one to disk on demand — so reports live in the.kgl(portable, diffable, queryable) and agents stop cluttering the working folder with one-off files. A report can record thecites(finding/assessment ids) it rests on; reports cascade-delete with the study.
Added — library-owned batch OCR submit¶
ocr("submit_many", rows=[{page_id, markdown}|{page_id, tiles}])submits many pages in one call.rowsis a structured tool argument (the SDK escapes it), so agents never hand-serialize multi-line, quote-heavy verbatim transcriptions into a JSON file — the failure mode that silently corrupted ~half a batch in the field run. A failing row is reported, not fatal.
Added — OCR model guidance¶
- The
request_ocrtask now carries arecommended_model(claude-sonnet-4-6) model_guidance, and_INSTRUCTIONSdocuments the tier: Sonnet default, Opus for the hardest/decisive pages, and avoid small models for legal/forensic OCR — A/B testing found Haiku corrupted names and fabricated an entire false document (contamination, not low quality). kglite-docs bundles no model; this is guidance only.
Added — OCR export/import sidecar + OCR'd chunks are first-class¶
ocr("export", doc_id)writes a document's OCR to a sidecar JSON (<source>.ocr.json) —{source_file, doc_id, generated_at, by_model, pages:[{page_number, ocr_model, ocr_status, legible_chars, text, …}]}— so OCR is portable, auditable, diffable, hand-correctable, and re-importable;ocr("import", path)round-trips it back in (done once; a human fixes the failed pages and re-imports; OCR travels with the PDF). Carries the illegibility flag.- Fixed: OCR'd chunks are now marked embedded.
submit_ocrembedded the chunks but didn't setembedded/:Embedded, sosearch()counted them as unembedded — warning they were "invisible" (they weren't) and, for a scanned-only document, raisingNotIndexedErrordespite the vectors existing. OCR'd text now lives on the graph exactly like native-text chunks::Ready :Embedded, searchable/studyable, withocr_derivedhonesty markers.
Added — adaptive image prep + tiling (attack illegibility at the source)¶
- The library right-sizes the page image it ships for OCR. A page rendered
larger than a vision model's input (~1.15 MP) is silently downscaled by the
API, destroying fine print on dense/faded scans (a chunk of the illegible 25%).
Now
request_ocrreturns the page as tiles fit to that cap: one full-page tile if it fits, else detail-preserving overlapping tiles rendered at full dpi (render_page_images).submit_ocracceptstiles=[{tile_index, markdown}]and stitches them in order (or whole-pagemarkdownas before). Preview renders (list_pending_ocr/illegible) are downscaled under the cap too. The page recordsocr_render_dpi/ocr_tilesfor provenance ("illegible even after tiling" vs "illegible because downscaled"). All in Python via pymupdf — no Rust, no new deps.
Added — re-OCR an already-done page (force)¶
ocr("request", …, force=true)re-OCRs a page that's already transcribed — so anillegible/partialresult can be escalated to a stronger model (ocr("illegible")→ re-request withforce→ re-submit).submit_ocrnow replaces all of a page's chunks (not just theneeds_ocrplaceholders), so a re-OCR overwrites cleanly instead of leaving duplicate chunks.
Fixed — silent illegibility: "OCR'd" ≠ "readable"¶
- A page OCR'd to all-
[ilegível]/empty no longer counts as covered. A full-corpus run found ~25% of OCR'd pages came back illegible/near-empty, yetocr_statusread "done" — the same silent-incompleteness failure as the original scanned-pages-pass-as-readybug, one stage later.submit_ocrnow computes a legibility metric (legible alnum after stripping[…]markers) and records a pageocr_outcome(ocr_ok/ocr_partial/ocr_illegible) +legible_chars.ocr_statusreportsillegible_pages/partial_pages/readable_pages(the honest count);coverage_reportsays so loudly; andocr("illegible")lists the unreadable pages as a retry worklist. The illegibility tag is computed by the library, not narrated by the agent.
[0.0.14] — 2026-05-30¶
Scanned primary evidence stops hiding. A field report found the strongest fact
in a case — the opposing party's sworn confession on a scanned page — invisible
out of the box: the page was mis-flagged ready, never queued, never OCR'd. This
release makes scans detectable, makes OCR a lazy agent-driven step, and adds a
source-party dimension so an admission against interest surfaces as the adverse
party's own primary text. Agent-first throughout; bring-your-own OCR engines
deferred.
Added — source-party dimension (whose words a document holds)¶
- Tag a document with who produced/filed it, inherited to its chunks, so an
admission against interest (primary text by the adverse party) surfaces
instead of sinking as someone else's characterization of it.
provenancesays what was checked;source_partysays whose words. Set it at ingest (document("ingest", …, source_party="respondent")) or after (document("set_party", doc_id=…, party=…)); it becomes a queryable label (MATCH (c:Chunk:Respondent)), shows ondocument("get"), and is surfaced per row in the studyledger(source_party) — so "primary-text supporting evidence by the adverse party" is one query. Generic + domain-opaque (free-text → PascalCase label); the legal pack registersclaimant / respondent / defense / accuser / judge / court / expert / third_partyfor discovery (document("parties")). Document-level for now (per-chunk override is future).
Added — lazy, agent-driven OCR (ocr("request"))¶
- The first time an agent needs a scanned page, it's handed the OCR task to do
itself instead of getting empty text.
ocr("request", page_id=…|doc_id=…, page_number=…, agent_id=…)returns the rendered page (image_b64) plus a strict verbatim transcription prompt (OCR_PROMPT— transcribe exactly, mark[illegible], no inference; it will be quoted as evidence); the agent transcribes and callsocr("submit"). kglite-docs still ships no OCR engine — the vision-capable agent is the engine (agent-first). Passagent_typeto route the task to a specific OCR subagent (echoed + recorded; the library never dispatches it). The request is recorded on the page (who/when/agent_type, first request preserved).submit_ocrnow stamps OCR'd chunksocr_derived(+ocr_by/ocr_model) so a reviewer knows to eyeball the image before quoting. Requesting a page that isn'tneeds_ocrraises.
Fixed — robust OCR-requirement detection¶
- Scanned pages stop silently passing as
ready. A field report found the single strongest fact in a case — the opposing party's sworn confession on a scanned page — invisible because the page was mis-flaggedreadyand never queued for OCR. Detection is now image-coverage- and density-aware (not presence + an absolute char floor): a page is flaggedneeds_ocrwhen it's image-bearing with sparse text or image-dominated without being text-rich — catching full-page scans whose raster isn't a detected XObject, and scans whose junk fragments clear the old 120-char floor. Recall-biased (over-flagging a figure page is cheap; missing an exhibit is not).PageContentnow carriesimage_coverage.
[0.0.13] — 2026-05-30¶
Cross-chunk synthesis & leveled studies — from four 2026-05-30 field reports. The per-chunk assess/verify model was silently blind to emergent cross-chunk patterns (disparate treatment, conflicting dispositions); a study could be concluded "complete" while a whole class of finding was unreachable. This release makes that class first-class, observable, and gated — on generic primitives, with the legal vocabulary registered as a data pack.
Added — regression fixture + test helper¶
- Cross-chunk regression fixture wired into CI. Adopted
sample_data/cross_chunk_fixture/(synthetic, anonymized case TR-7788) and a test that locks the whole arc end-to-end: the per-chunk baseline recovers nothing (study_conflicts == 0— the trap), a synthesis pass recovers F1 (disparate treatment) + F2 (conflicting dispositions) + the omission and passes the fixture's own gold-standardcheck()while leaving the negative control alone, andconcludeis refused until the study is synthesized. kglite_docs.testing.make_chunks(corpus, n)— a public helper that deterministically producesnchunks (encoding the structure-aware lever short paragraphs otherwise pack into one), de-duping a helper the test suite re-pasted.
Added — timeline / Event layer¶
- Queryable entity-value scalars on chunks. Ingest now stores
date_first,date_count,money_max,money_sum,money_countas chunk properties (from the same regex entity pass), so timelines and aggregates work in Cypher (ORDER BY c.date_first,sum(c.money_max)) without re-parsingentities_json. Eventlayer + sequence analysis.study("add_event", …)records a dated occurrence (date / actor / action / outcome, optionally chunk-anchored;study("event_prompt")is the extraction prompt);study("timeline")returns a document's events in order;study("timeline_conflicts")runs deterministic sequence analysis — disparate treatment (same trigger → different outcome by actor) and contradictory outcomes (same actor+action, conflicting outcomes) — completing the disparate-treatment detector over the ordered record. Reports how many events were scanned (honest coverage).
Added — follow-on study recommendations¶
- A concluded study never dead-ends a thread its own findings opened.
study("recommend")proposes follow-on studies a study's findings imply (proposals only — never auto-run), each seeded with the triggering findings;study("conclude")now also returns these.study("spawn", recommendation_id=…)approves one — creating the child study and aSPAWNED_FROMedge (child → source) carrying the reason + seed findings. The finding-signal → study-template mapping is an extensible trigger registry (recommend.py); the legal pack seedsdisparate_treatment → judicial-bias,conflicting_dispositions → which ruling operative,ignored_favorable_evidence → due-process.
Added — blind spots + completion policy¶
study("confidence")— per-finding confidence + named blind spots:contested/low_depth_unitsworklists,coverage_by_lens(every registered lens with whether it ran + units examined — un-run ones are listedblind_spots, never silent), arecommended_next_escalation, and whether the study issettled.study("set_policy")— acompletion_policy(target_confidence,required_lenses,max_rounds) makes "done" a checkable contract:concludenow refuses (with explicit reasons) while the policy is unmet — a required lens un-run, or findings below target confidence — in addition to the synthesis gate. The same auditedacknowledge_no_synthesis=trueoverride records the skip + its reasons.
Added — leveled review: escalation rounds + lens registry¶
- Spend review effort where it moves the needle.
study("escalate", …)opens aReviewRoundand hands back only its targeted worklist — never a blind re-run:scope="contested"/"low_depth"returns the findings that need more reviewers;scope="uncovered"returns study chunks not yet examined by a givenlens(a detectability sweep).study("next_review")claims a non-overlapping batch (punchcard keyed on the round);study("record_review")writes theEXAMINEDcoverage edge and, for a finding, casts the reviewer vote (updating confidence/escalation_state);study("close_round")counts the findings it produced.study("rounds")is the escalation history. - Lens registry (
lenses.py,study("lenses")) — a lens is a named reviewer strategy (prompt + unit type). A generic open seam: core ships it empty; the legal pack registerscontradiction / disparity / omission / quantitative / temporal. A registered-but-un-run lens becomes a named blind spot rather than a silent gap. Findings can carry anorigin_round_idlinking them to the round that surfaced them.
Added — semantic (cross-chunk) conflict detection¶
study("semantic_conflicts")— a deterministic scan that clusters non-neutral assessments by their classified element/topic and flags different chunks carrying opposing stances (one rulingsupports, a conflicting rulingagainst) — the disparate-treatment / conflicting- disposition class same-chunkconflictsis structurally blind to. Honest coverage: reportschecked(classified assessed chunks) vsskipped_unclassified, and when nothing is classified returns a loudnote("not looked", not a falsely-clean zero) recommendingtag("classify")first.
Added — synthesis lifecycle + honest-completeness gate¶
- A study can no longer be silently concluded while blind to cross-chunk
patterns. The lifecycle is now
score → verify → synthesize → conclude.study("synthesize", …)marks the ledger-wide pattern pass as run (the agent reads the whole ledger + records cross-chunk Findings);study("conclude")refuses (SynthesisRequiredError) until it has, unlessacknowledge_no_synthesis=truerecords an auditedSynthesisEvent(the skip is never silent).study("synthesis_prompt")returns what to hunt — a domain-neutral core list plus any registered domain addendum (the legal pack adds disparate-treatment / conflicting-disposition / ignored-argument cues).get_studysurfacessynthesis_status+ the synthesis-event trail.
Added — leveled review: Finding verification + confidence¶
- Findings are gradeable by independent reviewers (
study("verify", finding_id=…)). A cross-chunk Finding now accumulates independent verification votes (reusing the assessmentVerificationEventsubstrate; self-verification rejected).list_findings/get_studysurface a reviewer-agreement rollup computed on read —reviewer_count,vote_tally,agreement,confidence(=max(0, net) × provenance factor), and anescalation_state(settled/contested/needs_more) that is also a routing label (MATCH (f:Finding:Contested)). Confidence is a function of recorded votes, not a single agent's assertion — the substrate the leveled control system is built on.
Added — cross-chunk synthesis (honest completeness)¶
- Cross-chunk
Findingunit (study("finding"/"findings")). A first-class reified node asserting a pattern over a set of chunks — the unit per-chunkassessstructurally can't see (disparate treatment, conflicting rulings, …; a real run scored every chunkneutralwhile the strongest argument lived only in the JOIN of three). A Finding carries the same evidence axes as an assessment (stance / weight / provenance / rationale) but spans many chunks viaSUPPORTED_BYedges (each must cite a real chunk);finding_typebecomes a routing label (MATCH (f:Finding:DisparateTreatment)). Findings are a separate collection from the per-chunk assessment ledger — surfaced viastudy("findings")/get_study().findings, and cascade-deleted with the study. Generic:finding_typeis free-text (the legal pattern vocabulary stays in the vertical). Foundation for the synthesis gate (next phase).
[0.0.12] — 2026-05-30¶
Release theme: multi-study routing — classify a corpus once into a domain
element schema (a bundled legal vocabulary), then scope studies to the relevant
element so many studies stop re-scanning the whole corpus. Scoping is advisory
(rank-first) and carries a scope_coverage block — a speedup that is never
lossy.
Added — multi-study routing (element classification)¶
- Extensible element-discriminator registry (core primitive). A domain schema
can now register element discriminators (
register_element_discriminator(name, {value: label})) — the seam for classifying chunks into a controlled vocabulary so many studies route to their relevant chunks instead of re-scanning the corpus. Core stays domain-opaque: it routes opaque element tokens and validates them against the registered allow-list (valid_element_values()/element_label()— unknown tokens resolve toNoneso callers raise rather than silently match zero chunks). Adds theClassified/Unclassified/Contestedmarkers + achunk.classifydiscriminator. (Inert until the classify pass + legal data pack land in later phases; the legal vocabulary ships as registered data, never hard-coded in core.) classify.py— one-pass element classification + punchcard.next_unclassifiedhands out ready-but-unclassified chunks in reading order (claiming them per agent whenagent_idis given), reusing the study punchcard via a new sharedcheckout.pykeyed on a classify sentinel so classify and study fan-outs never collide.classify_chunk/classify_manywrite the validated element labels (add-only, recall-safe routing) + the canonicalelement_types_jsonrecord, and mark every chunk:Classifiedxor:Unclassified(exhaustive — the work-list never leaks). Two agents disagreeing add:Contested(never a silent clobber).study.next_unassessed's checkout logic was extracted to the sharedcheckout.py(behavior-preserving).- Bundled legal schema pack (
kglite_docs.schemas.load_schema("legal")). An opt-in controlled vocabulary of ~18 court-case element types across three families —legal_role(holding,reasoning,judge_remark,settlement,disposition_order, …),legal_authority(statute,case_citation, …), andlegal_evidence(testimony,documentary, …) — plus a detection rubric (rubric_text()) an agent reads to classify consistently. It's data only: the vocabulary registers through the generic seam, so the engine never names a legal term (boundary principle intact). Multi-label, jurisdiction-neutral (common + civil law; illustrative PT/PROJUDI mappings). A classified corpus then routes by label predicate:MATCH (c:Chunk:JudgeRemark). - Advisory element scoping on studies (
element=onnext_unassessed/study_ledger). Once a corpus is classified, a study can scope to an element type so it reads its relevant subset first instead of re-scanning the whole corpus — the multi-study win. It is advisory, never lossy: the full work-list/ledger is still returned, only reordered (in-scope element chunks first, then other-classified, then unclassified/not-yet last), so an analyst stops early as a visible choice and a misclassified chunk is deprioritized, never hidden (element labels are predictions, not ground truth likedoc_id/section_id). An unknown/unregisteredelement=raises (InvalidEnumError) rather than silently matching zero chunks. (Hard-exclude is a deferred, audit-gated follow-up.) - Honest-coverage surfacing for element scoping. A scoped
study_ledger(element=) now carries a mandatoryscope_coverageblock (in_scope/excluded_other_element/excluded_unclassified/excluded_total/ready_total) with the enforced invariantin_scope + excluded_total == ready_total— so an early stop on a scoped study is an informed choice, and a non-zeroexcluded_unclassifiedis a standing signal that the scope has a blind spot. Also exposed asCorpus.element_coverage(element).document("map")/triage_mapgains anelementsbreakdown +classified/unclassified/contestedcounts;status()gains the same three.Corpus.element_consistency()audits the element labels against the canonicalelement_types_json(drift is observable, never silent). - MCP surface for classification (zero new nouns). Folded into the
tagnoun:tag("unclassified", …)(punchcard work-list),tag("classify", chunk_id, elements=[…], agent_id),tag("classify_many", items=[…]). Thestudynoun'snext/ledgergain anelement=param (advisory rank + thescope_coverageblock). The MCP server loads the bundledlegalschema pack by default (build_app(schema_packs=…)), and_INSTRUCTIONSdocuments the "classify once → route many studies by element" happy path.
[0.0.11] — 2026-05-30¶
Release theme: agent assist (pre-enrichment) — a deterministic, zero-LLM triage layer so agents spend tokens on judgment, not mechanical work. Cheap content signals + structured-entity pre-tagging at ingest, plus a one-call corpus map for orientation. Every signal is additive, observable, and advisory — never lossy. (Local OCR with a modern engine is the deferred next release.)
Dependencies¶
- Adopt kglite 0.10.10 (pin
>=0.10.10). It ships the fixes for the two papercuts we offloaded (KG-1: a node property namedlabelis readable again; KG-2:CONTAINSusable as a relationship type) — both of which we already dodged, so no code change was needed; full suite re-verified green (195) on 0.10.10. It also fixes aprefix+numberid-coercion bug (a1/reader-1could collide to the same node) that could affect user-supplied agent ids — a correctness reason to require it. The one 0.10.10 breaking change (Wikidata prefixed-idn.idstring→int) does not touch us (our ids are non-coercible sha/uuid/composite strings).
Added — agent assist (pre-enrichment)¶
- Deterministic content signals at ingest (FEAT — agent assist). Ingest now precomputes cheap, model-free triage signals on every chunk so agents spend tokens on judgment, not mechanical work — and it's never lossy (signals only label; no chunk is altered or dropped):
word_count/char_count(alongside the existingtoken_count).content_kind—prose/table/list/code/sparse— as both a property and a label predicate (MATCH (c:Chunk:Table)), so an agent routes to (or past) tables, lists, and code without reading them.quality_score(0..1) with a:LowQualityflag on prose/sparse chunks whose text looks garbled (bad OCR/encoding) — advisory.:Boilerplateon verbatim-duplicate chunks (repeated disclaimers / pages), flagged but kept.- Structured-entity pre-tagging (regex, generic): dates, money, emails,
URLs, and identifiers are extracted into an
entitiesmap (surfaced parsed onget_chunk()) and flagged asHas*label predicates (MATCH (c:Chunk:HasMoney)) so an agent jumps straight to the high-value chunks instead of reading everything. Recall-oriented + advisory; domain-specific entity types stay in a vertical, not core. All surfaced onget_chunk(); backward-compatible (re-ingest to populate). document("map")/Corpus.triage_map()— one-call corpus orientation. Aggregates every signal above (chunk counts, thecontent_kindbreakdown, boilerplate / low-quality, structured-entity coverage, embedding state, OCR-pending pages) plus a humansummary, so an agent orients without reading the corpus and routes work via the matching label predicates (MATCH (c:Chunk:Table),:HasMoney, …). Scope withdoc_id.
[0.0.10] — 2026-05-30¶
Release theme: scale & polish — single-writer guardrails (advisory lock +
assess_many), opt-in summary-augmented chunking, result/detail ergonomics and
an MCP surface review, plus a raw-text fallback so no readable page is silently
dropped. End-to-end integration tests now exercise the 0.0.7–0.0.10 feature set
together on the real embedder.
Fixed¶
- Text pages no longer silently dropped when pymupdf4llm yields empty
markdown. Some PDFs structure poorly enough that
pymupdf4llm.to_markdownreturns an empty page even though the page has extractable text; that page became a silent:Emptychunk (and, with no raster images, wasn't even flaggedneeds_ocr) — readable text lost with no signal.parse_pdfnow falls back to raw PyMuPDFpage.get_text()when the markdown is empty, recovering the text as a normal ready chunk. Honest coverage: content we can read is never dropped.
Added — scale & polish¶
- Result/detail ergonomics + MCP surface review (FEAT-14).
corpus.cypher()returns kglite'sResultView— now documented as iterable (for row in res, each row a dict), indexable (res[0]["col"]),len()-able, with.to_list()/.columns(a regression test locks this in).get_chunk()returns anAttrDictso bothdetail["section_id"]anddetail.section_idwork (still a plain dict otherwise). Surface review: the 13 nouns each stand; the one redundancy —summary("claim")— is soft-deprecated in favour of the first-classstudyflow (define → assess → ledger), which is richer, multi-agent, and verifiable (the action still works). Rationale recorded indocs/architecture.md. - Summary-augmented chunking (FEAT-11, opt-in).
ingest(..., context_summary="…")(anddocument("ingest", context_summary=…)) prepends a document-level blurb to each chunk before embedding, so the vector carries global document context (mitigates cross-document speaker/source confusion à la contextual retrieval) while the stored chunk text stays clean (search hits are unchanged). The summary rides on the Document node and is applied by both inlineembed=Trueand the laterindex()pass. No LLM in core — you supply the summary; default off is byte-identical to before. - Single-writer guardrails (FEAT-12). kglite-docs is single-writer; this makes that safe and ergonomic:
- Advisory lock. Opening a
.kglwhile another live process already holds it now raisesConcurrencyError(a PID-stamped<db>.lock) instead of silently racing on save and corrupting. Same-process reopen (create → save → open) is allowed and a stale lock from a dead process is reclaimed; in-memory corpora take no lock. study("assess_many", rows=[…])/Corpus.assess_many()— assess a fan-out of chunks in one validated, batched write (and a single persist through the MCP layer) instead of N round-trips. All rows are validated before any write, so one bad row aborts the batch with nothing written.- Docs: a plain single-writer Concurrency section in
docs/architecture.md(fan out reads, funnel writes through one process).
[0.0.9] — 2026-05-30¶
Release theme: document structure — a middle grain between document and chunk. Sections (from the PDF outline or headings), section-scoped studies, pinpoint char-span cites on assessments, and opt-in structure-aware chunking.
Added — document structure¶
- Structure-aware chunking (FEAT-10).
ingest(..., structure_aware=True)(anddocument("ingest", structure_aware=True)) starts a fresh chunk at every top-level heading and never packs — or overlaps — across one, so a chunk never straddles two sections and its heading attribution is exact. Size-based splitting within a section is unchanged (the token target is still honored). Opt-in and additive — the default packs greedily exactly as before; pairs with Section nodes (cleaner section boundaries) and pinpoint cites. - Pinpoint spans on assessments (FEAT-6).
assesstakes an optionalquoteand/orchar_start/char_end— the exact passage an assessment rests on — validated against the chunk text (an out-of-range span or a quote not found in the chunk is rejected;quotealone is located to derive offsets). Surfaced per row instudy_ledger(quote/char_start/char_end), so the evidence record carries pinpoint cites instead of just "somewhere in this chunk." Backward-compatible: rows without a span read back as""/-1. Sectionnodes + section-scoped studies (FEAT-9). Ingest now derivesSectionnodes — the grain between document and chunk — from the PDF outline (doc.get_toc()) when present, else from top-level heading boundaries (generic, all formats).(:Document)-[:HAS_SECTION]->(:Section)-[:HAS_CHUNK]->(:Chunk); each chunk carriessection_id(+ adoc_typeslot verticals can fill).document("sections", doc_id)/Corpus.list_sections()list them with per- sectionchunk_count;study("next"/"ledger", section_id=…)scope the work-list and evidence ledger to one section.IngestResult.section_countandget_chunk().section_idmake the structure observable. Backward-compatible — re-ingest documents from before this release to populate sections.
[0.0.8] — 2026-05-30¶
Release theme: evidence integrity — make the assess/verify model legally defensible: record what was checked not just how strongly, let agents park blocked evidence, resolve cross-agent corrections to one current truth, and surface the disagreement.
Added — evidence integrity¶
study("conflicts", study_id)surfaces contested evidence (FEAT-8). Returns the chunks that have both a currentsupportsand a currentagainstassessment, each with its opposing rows split by side (most contested first) — the disagreement an orchestrator should review before concluding. Computed over the current set (latest-per-agent, non-superseded), so a correction that resolves a disagreement removes it from the list.study("supersede", …)+ current-by-default ledger (FEAT-5, BUG-4). Audit-preserving correction:supersede_assessment(old_id, …)records a replacement assessment linked to the one it corrects by an explicit(:Assessment)-[:SUPERSEDES]->(:Assessment)edge.study_ledgerand its tallies are now current-by-default — a superseded assessment is hidden (each row carries asupersededflag), withinclude_superseded=Truefor the full history. This resolves the cross-agent ambiguity where a pre-filter's row and an analyst's correction both showed with no winner, without weakening multi-agent coexistence (only explicitly superseded rows drop out). The old assessment is never deleted — the correction trail stays legible.- Provenance axis on assessments (FEAT-4). Each
assessnow recordsprovenance— what was actually checked (the basis), distinct fromweight(the strength):primary_text(read the source — default),characterization(a paraphrase/summary, not the source), orscanned_unread(a scan no one actually read — provisional). It's a secondary label on the Assessment, surfaced per row instudy_ledger, so a conclusion resting on characterizations or unread scans is no longer indistinguishable from one resting on primary text.verifytakes an optionalprovenancerecording what the verifier checked (stored on the verification event). Backward-compatible: assessments written before this release read back asprimary_text. deferredstance for evidence assessments (FEAT-7). Alongsidesupports/against/neutral, an agent can now assess a chunk asdeferred— "read but can't judge yet" (an image-only /needs_ocrchunk, or a claim awaiting a source not yet ingested). Unlikeneutral("read, irrelevant"),deferredis counted distinctly instudy_ledgertallies (deferred/deferred_weight) and keeps the chunk in thestudy("next")work-list so it resurfaces for a later pass — blocked evidence is parked, never silently dropped. Filter the ledger withstance="deferred"to list what's still blocked.
[0.0.7] — 2026-05-30¶
Release theme: honest coverage — every coverage-reducing decision (image-only pages, unembedded chunks, an unindexed corpus, a truncated ledger) is now observable in the return value instead of silently assumed.
Added — honest coverage¶
document("coverage")/Corpus.coverage_report()(FEAT-1): per-document- corpus extraction & embedding coverage —
image_pages,low_text_pages,extractable_text_ratio,pending_ocr,unembedded/embedded, and a human-readablesummary("N image-only & L low-text pages need OCR; U chunks unembedded — search blind until index()"). Coverage is reported as data, never silently assumed. document("status")/Corpus.status()(FEAT-2): one-call corpus snapshot (docs, pages, chunks, embedded/unembedded, image_pages, pending_ocr, studies) — the first thing to check. Pages now persistextractable_alnumat ingest; pages ingested before this release count as low-text until re-ingested.compose_context()now reportssearched_fraction(FEAT-3): the share of the corpus actually searchable (embedded chunks ÷ ready chunks).1.0= full coverage;< 1.0means part of the corpus was invisible to the query.study_ledger()reportstotal+returnedand acceptsdoc_idscoping (BUG-3). The ledger caps atlimit(default 200) but used to give no hint when it clipped the evidence — an orchestrator could draw a conclusion from a silently-truncated record. It now returnstotal(matches before the limit) andreturned(rows handed back);total > returnedmeans raiselimitto see the rest.doc_id=scopes the whole ledger (rows + counts) to one document, mirroringstudy("next", doc_id=…).
Fixed¶
- Retrieval over an unindexed corpus is now a loud signal, not a silent
[](BUG-2).search()/compose_context()used to return[]when nothing was embedded — indistinguishable from "this query genuinely has no matches," so an agent would wrongly conclude no evidence exists (amplified by 0.0.6'sembed=Falsedefault). Now: a corpus with ready chunks but 0 embedded raisesNotIndexedError(callindex()/ingest(embed=True)first); a partially indexed corpus emits aUserWarningand exposes the gap viasearched_fraction; a genuinely empty corpus still returns[]. Behaviour change for library callers: searching a ready-but-unembedded corpus now raises instead of returning[]. The MCP happy path (ingest → index → search) is unaffected. cluster_chunks(algorithm="louvain")no longer pollutes the graph with phantomChunkstubs. BareCALL louvain()clusters the structural graph (Pages + the Document, not just chunks), so the resultingIN_CLUSTERedges vivified non-chunk endpoints as stubChunknodes. Louvain now runs only when aSIMILAR_TOchunk-similarity graph exists (its documented precondition) and filters results to chunk nodes; otherwise it falls back to embedding k-means — which is what the corpus does today, so the behaviour is the same minus the stray stubs.- Test runs are now warning-clean: the
IN_CLUSTERstub warning is fixed at the source, and pymupdf's third-party SWIGDeprecationWarnings are filtered (tightly scoped inpyproject.tomlso our own warnings stay loud). - Image-only PDF pages are now detected for OCR by text density, not "has any
text at all" (BUG-1). pymupdf4llm emits a
==> picture … intentionally omitted <==placeholder for image regions, which previously made a scanned page count asreadyand hid it fromocr_status(). A page with image content but fewer thanOCR_TEXT_THRESHOLD(~120) real alphanumeric chars is now flaggedneeds_ocr. Pages also carry animage_block_count(for the upcoming coverage report). Honest-coverage: unreadable pages are observable.
Documentation¶
- Confidentiality posture (FEAT-13): new
Confidentiality page
(+ README section) stating plainly that all parsing, embedding, and
analysis run locally against a local
.kgl— the only network call is a one-time bge-m3 weight download from HuggingFace; no document content is ever transmitted. Covers the benign "unauthenticated requests to HF Hub" message, automaticHF_HUB_OFFLINEonce cached, and a fully air-gapped setup recipe.
[0.0.6] — 2026-05-29¶
Added — evidence-study workflow (new study MCP noun)¶
- First-class evidence analysis. A validated multi-agent trial showed
the prior workaround (stance smuggled into a tag name, weight overloaded
onto tag confidence, rationale in a disconnected
Summary, a 3-query stitch to rank) was the wrong shape. The newstudynoun owns the flow:definea question →assesseach chunk (supports/against/neutral - 0–1 probative
weight+rationale) →ledger(weight-ranked evidence - support/against tallies;
stance=filters to one side) →verify(second agent:verified/disputed/duplicate) →conclude(a verifiable summary on the study) →list/get/reopen/delete.nextis a resumable work-list of un-assessed chunks. - Reified
Assessmentnodes ((Chunk)-[:ASSESSED_AS]->(Assessment)-[:OF_STUDY]->(Study),(Agent)-[:AUTHORED]->): multiple agents can co-assess the same chunk; each assessment is independently verifiable. Append-only / latest-wins (no mutated-property hazard); stance/status/verification tracked as secondary labels for index-speed filtering. - No embeddings required.
assessnever touches the model (rationale is a plain property) — a whole study runs on an un-indexed corpus; agents iterate chunks vianext. Recording is ~10ms/chunk. - Punchcard claiming on
next.study("next", study_id, agent_id=…)atomically checks out the returned chunks (aCheckoutnode) and excludes chunks already claimed by others, so a fan-out of analysts gets disjoint batches and never overlaps. Claims auto-expire after 30 min and assessing releases implicitly; omittingagent_idis a read-only preview. (A blind two-agent trial had both analysts grab the same chunks; this closes that.) - Read-context + recorded spans for incoherent chunks.
chunk("get", window=N)returns the N chunks before/after in reading order with text (via theNEXT_CHUNKspine), so an agent can interpret a chunk that doesn't stand on its own. When the neighbours were actually needed,study("assess", context_chunk_ids=[…])records them asUSED_CONTEXTedges: theledgerrow surfaces the span (retrieval pulls the full relevant neighbours), and those context chunks are excluded from the work-list so no agent re-judges the same meaning. - MCP surface: 12 → 13 noun tools (revisit merges later). Server
_INSTRUCTIONSgains an evidence-study happy-path; thestudytool docstring is a copy-pasteable loop so agents need no prompt-side convention.
Fixed — tag ranking exposes confidence¶
tag("list")now returns each application'sconfidence(it was dropped, so the typed surface couldn't rank by it);tag("chunks")ranks by strongest confidence then tagger count.add_summary(embed=False)makes summary embedding opt-out (study conclusions default to no-embed).
Changed — embedding is now optional and explicit¶
- Ingest no longer embeds by default.
document("ingest")(andCorpus.ingest) now parse, chunk, and store without touching the embedding model — fast, no model load, no per-call-timeout risk. Real PDFs that took 48–90s to ingest now return in ~6s. Ready chunks are tracked as unembedded via thec.embeddedproperty. - New
document("index")action (Corpus.index) computes embeddings for ready-but-unembedded chunks, in length-sorted batches to cut bge-m3 padding waste. Run it after ingest to enablesearch; skip it entirely for non-semantic workflows (browse / cypher / tag / review / ocr / export / translate need no embeddings). One-shot still available viadocument("ingest", ..., embed=True). indexis bounded per call and loop-friendly. A blind-agent test showed that while ingest was now fast, indexing a whole multi-document corpus in a single call still ran ~100s (and a 144-chunk corpus ran long enough that the agent abandoned it) — the per-call-timeout risk had just moved from ingest to index.indexnow does at most ~30s of work (wall-clock budget, ormax_chunks) per call, commits the partial progress, and returnspending > 0with ahintto call again. The agent loopswhile pending: index(); no single call exceeds the budget (+ at most one batch).max_seconds=Nonedrains everything in one pass (used by CLI--ingestpreload, where there's no timeout).- Ingest results now carry
embeddedcount + ahintpointing atindexwhen chunks are unembedded.
Fixed¶
- Tool-driven ingest now persists to disk. The long-lived MCP server
previously only saved when launched with
--ingest; interactivedocument("ingest")mutations were lost on restart. Now saved at the tool boundary (ingest/index) plus a save-on-shutdown backstop for all mutations. - Embedding lifecycle is tracked by the
c.embeddedproperty (with an additive:Embeddedlabel for browsing) rather than a removable:Unembeddedlabel. This was originally a workaround for a kglite multi-label read bug —MATCH (n:Label)over-reported afterremove_label— which we reported and kglite fixed in 0.10.6 (root cause was read-side fast-paths consulting only the primary-type index, not a stale index as we first guessed; no data corruption, no.kglmigration). The property-based design is version-independent so we kept it; removable labels /swap_labelnow work correctly regardless.
Dependencies¶
- Bumped
kglite>=0.10.9(multi-label read fix from 0.10.6; FxHash query perf from 0.10.7:multi_where −38%,group_by −23%,where_scan −21%; four Cypher-dialect fixes in 0.10.8 we reported buildingstudy—labels()/properties on acollect()[0]node, parameters insideEXISTS {}, node-expression inline-map values, andDETACH DELETEskipping NULL vars; and the 0.10.9 self-healing id-index that makesMATCH (n {id: X})/ MERGE lookups O(1) — a free win for the study workflow's many id lookups) andmcp-methods>=0.3.40(fixesgraph_overviewforwarding the droppeddescribe(limit=)kwarg, andcypher_queryfailing-> strvalidation on kglite'sResultView). Both escape-hatch tools now work on kglite 0.10.x. The latent over-reporting in the otherswap_label` lifecycles (summary verification, OCR, review kanban, translations) is fixed by the 0.10.6 upgrade.
Performance¶
- Background bge-m3 warm-load at server boot (off-thread),
HF_HUB_OFFLINEwhen weights are cached, andcooldown=0for the server — the model load (~8s) and HF-hub network checks no longer land on the first ingest/search call.--no-warmupskips it for non-embedding deployments.
[0.0.5] — 2026-05-28¶
Changed (MCP surface — agent-facing)¶
- Collapsed 45 standalone MCP tools into 12 CLI-style noun dispatchers. Each dispatcher takes an action verb as the first positional arg:
document(action, ...) ingest | list | get | export | compare
chunk(action, ...) get | similar
search(query, mode=…) hits (default) | compose
summary(action, ...) add | verify | list | ground | claim | consensus
tag(action, ...) add | remove | list | chunks
agent(action, ...) upsert | get | list | activity
review(action, ...) enqueue | enqueue_chunks | claim_next | claim |
unclaim | complete | list | get | stats
ocr(action, ...) status | pending | submit
cluster(action, ...) run | get | list | export
translate(action, ...) add | list | assemble
Plus cypher_query and graph_overview from mcp-methods. An agent
reads the methodology skill and copies verb-noun pairs straight into
tool calls (document("ingest", path=...), summary("verify", id=...,
verdict=...)) — patterned like git branch list, not 45 standalone
functions to scan.
The Python Corpus API is unchanged — the dispatch only lives in
the MCP shim.
Added (agent onboarding)¶
Corpus.compare_documents(doc_a, doc_b, queries=[...])— side-by-side cross-document retrieval. For each query, returns the top hits from each document plus a budgeted merged context bundle. Exposed asdocument("compare", ...)over MCP.- First-contact
instructions=on the FastMCP server. Connecting agents receive a ~250-word orientation oninitializecovering the CLI surface and the canonical happy path. - 4 methodology skill files registered as MCP prompts via
mcp-methods'
SkillRegistry: /00-start-here— overview + the canonical happy path/analyze-documents— canonical pipeline for "given N docs + a task"/compare-documents— the two-doc comparison idiom/cross-checked-review— write/verify/ground flow for trustworthy summaries
Skills are loaded by the agent on demand (/<skill-name>) — not
preloaded into context.
Removed (redundant — covered elsewhere)¶
register_agent→agent("upsert", ...)link_verification→ niche; usecypher_queryrecord_view→ implicit when passingagent_idtosearch/chunk("get", ...)untag_chunk→tag("remove", ...)ingest_dir,ingest_text→document("ingest", directory=...)/document("ingest", text=..., title=...)
0.0.4 — 2026-05-28¶
Changed (internal data model — public API unchanged)¶
- Adopted kglite 0.10.5 multi-label nodes. Categorical / lifecycle
state —
Agent.role,Agent.kind,Chunk.status,Summary.verification_status,Tag.kind,Translation.status, andReviewTicketlifecycle — now lives as secondary labels on the relevant node ((a:Agent:Reviewer:LLM),(c:Chunk:Ready),(s:Summary:Verified), etc.). Property scans are replaced withMATCH (n:Label)predicates, which kglite indexes natively. Cross- type predicates likeMATCH (n:Reviewed)return any reviewed thing in the graph regardless of primary type — useful for governance queries.
The Corpus Python / CLI / MCP surface is unchanged: callers
still pass status="verified" / role="reviewer" / verdict="reviewed"
strings. A new schema.label_for(discriminator, value) helper maps
user-facing strings → canonical PascalCase label names at the
boundary. Free-text discriminators (notably Agent.role) get
slug → PascalCase conversion: "fact-checker" → :FactChecker.
Labels are maintained atomically via a new Store.swap_label
primitive: every state transition (verify_summary, submit_ocr,
claim_review, complete_review, mark_translation_reviewed)
removes the old label and adds the new one in one call.
Event-sourced models (VerificationEvent, ReviewEvent) still
carry the immutable audit trail. The labels denormalise "current
state" for O(label-index) reads instead of "scan events, pick latest".
-
7 new tests covering label round-trip, swap mutual-exclusion, save/reload survival, and cross-type label predicates.
-
kglite>=0.10.5required (was>=0.10.4).
Breaking — raw Cypher only¶
If you wrote raw Cypher against corpus.cypher(...) and filtered on
Summary.verification_status, Chunk.status, Agent.role, Agent.kind,
Tag.kind, or Translation.status, switch from
WHERE n.prop = '...' to MATCH (n:NodeType:Label). Example:
-- before
MATCH (s:Summary) WHERE s.verification_status = 'verified' RETURN s
-- after
MATCH (s:Summary:Verified) RETURN s
Label names use PascalCase: Verified, Disputed, NeedsRevision,
Stale, Unverified, Ready, NeedsOcr, Empty, LLM, Human,
Service, Reviewer, Writer, FactChecker, etc. The Cypher
escape hatch tool in the MCP server still works the same way — the
agent just writes label predicates now.
.kgl files created by v0.0.x still load on v0.0.4 — labels won't
exist on those nodes (so label-filter queries against old corpora
return empty); re-ingest to populate labels.
0.0.3 — 2026-05-28¶
Added¶
- Agent nodes carry a reusable template. The
Agentnode was previously identity + counters; it now also holdsrole,system_prompt,model,tools(list),context(free-form JSON dict), anddescription. The graph IS the registry — orchestrators fetch an agent's loading context withget_agent(agent_id), use the fields to launch the actual LLM call, and every subsequent graph write under the sameagent_idattributes back to the template.
New methods on Corpus:
upsert_agent(agent_id, *, kind, model, role, system_prompt, tools, context, description)— field-level merge.get_agent(agent_id)— returns full template + counters;toolsandcontextcome back hydrated (real list / dict, not JSON strings).list_agents(role=..., kind=...)— discovery, with filters.agent_activity(agent_id, *, target_id=None)— bucketed rollup of everything an agent has done (views, summaries, tags, translations, review/verification events). Passtarget_idto scope to one chunk.
Old register_agent (the lazy on-first-use path) is preserved
and now explicitly does not clobber template fields when
the agent already exists. add_summary / tag_chunk / etc.
still lazy-register if you haven't upserted first.
New types: AgentConfig, AgentActivity.
New MCP tools: upsert_agent, get_agent, agent_activity
(existing register_agent / list_agents keep working;
list_agents gained role= / kind= filters).
9 new tests covering create → update merge → preserves-on-lazy → scoped activity rollups → realistic orchestration round-trip.
Example::
corpus.upsert_agent(
"reviewer-strict",
role="reviewer", model="claude-sonnet-4-6",
system_prompt="You are a strict fact-checker...",
tools=["check_grounding", "verify_claim"],
context={"strictness": "high", "min_citations": 2},
)
# Later — orchestrator side
cfg = corpus.get_agent("reviewer-strict")
anthropic.messages.create(
model=cfg["model"], system=cfg["system_prompt"], ...
)
0.0.2 — 2026-05-28¶
Changed¶
mcp+mcp-methodsmoved into core dependencies. They were previously gated behind the[mcp]extra, butmcpwas already pulled in transitively bykgliteanyway, andmcp-methodsis only ~17 MB — rounding error againstpymupdf(30 MB) andonnxruntime(80 MB) which are core. The MCP server is the headline feature; gating it behind an extra was friction with no real savings.
Install is now just pip install kglite-docs. Old install commands
with [mcp] keep working (pip warns, doesn't fail).
CI / tooling¶
- Bumped all
actions/*to versions supporting Node.js 24 ahead of GitHub's 2026-06-02 deprecation of Node 20.
0.0.1 — 2026-05-28¶
First public alpha. The core agent-first PDF knowledge-base API is in place: ingest → chunk → embed → search → enrich → cluster → review, served over Python, CLI, and MCP. Everything is exercised by 81 unit + integration tests; a real end-to-end Sonnet workflow demo is included.
Added¶
- Multi-format ingest. PDF, DOCX, PPTX, MD, HTML, TXT, and common image formats (PNG/JPG/TIFF/WebP/BMP), all flowing into the same Document → Page → Chunk graph.
- Token-aware paragraph chunking at ~512 bge-m3 tokens with 64-token overlap; never crosses a page boundary.
- BAAI/bge-m3 embeddings via ONNX (CLS-pooled, 1024-dim, 8192-token cap), inherits kglite's cool-down lifecycle for warm-call latency.
- Semantic search +
compose_context(query, max_tokens=…)— budgeted, ranked, prompt-ready bundles for agents. - Enrichment with cross-checking.
add_summary/verify_summaryenforce that the verifier is a different agent. Status is event-sourced viaVerificationEventso we keep a full audit trail. - Tagging via reified
Taggingnodes so multiple agents can tag the same chunk distinctly with(by_agent, created_at, confidence). - Agent identity propagation. Lazy-registered
Agentnodes;search/get_chunkacceptagent_id=to bumpChunk.view_countand recordViewnodes when the query context is worth keeping. - Scanned-page OCR loop.
list_pending_ocr→ agent reads PNG →submit_ocr(page_id, markdown)re-chunks + re-embeds. ocr_status()— coverage summary across the corpus, per-doc detail with a pending-fraction. Exposed as Python / CLI / MCP.kglite-docs ocr-do— CLI subcommand that drives the OCR loop with any agent command containing{image}(e.g.claude -p --image …).- Clustering.
cluster_chunks(algorithm='louvain'|'kmeans'|'dbscan')withCluster+IN_CLUSTERgraph state;most_connected_clusterfor synthesis use cases. - Quality / anti-hallucination.
check_grounding(summary_id)scores per-sentence support;verify_claim(claim_text, …)finds best-supporting chunks. - Translation layer.
Translationnodes per chunk × language;assemble_translated_documentstitches back with fallback to source. - Export.
export_document/export_cluster/export_summary/export_bundleto Markdown, DOCX (python-docx), or PDF (ReportLab). - Review queue (kanban). Event-sourced
ReviewTicket→ReviewEventaudit trail.claim_next_review/complete_reviewwith verdict + accuracy + authenticity + tags. Process-local lock protects against double-claim within a process. - MCP server.
kglite-docs-mcp --db kb.kglexposes 30+ typed tools pluscypher_query/graph_overviewescape hatches via mcp-methods. - CLI.
kglite-docs ingest|search|list|cluster|show|ocr-status|ocr-do. - Context manager on
Corpus.with Corpus.open(path) as c:auto-saves on clean exit, skips save on exception so a partial mutation isn't persisted. - Typed errors.
KgliteDocsErrorbase + concrete subclasses (IngestError,UnsupportedFormatError,MissingSourceError,ReviewConflict,SelfVerificationError,InvalidEnumError,GroundingError,ConcurrencyError). - Typed args + returns.
Literal[…]for verdicts, statuses, depths, target kinds, algorithms, tag kinds;TypedDictreturn shapes (SearchHit,OcrStatus,ReviewTicketDetail, …) for IDE autocomplete andmypy --strict. - End-to-end Sonnet workflow demo (
demos/workflow.py): ingest → cluster → Sonnet summarises → Sonnet drafts an article with[chunk_id]back-references → second Sonnet pass fact-checks → verifications persist asVerificationEventnodes. - Docs. Getting-started, architecture, workflows, performance,
publishing, troubleshooting, contributing — all in
docs/. - CI + release. GitHub Actions workflows for
ruff+mypy+pyteston Py 3.10–3.13 × macOS/Linux, plus a trusted-publisher PyPI release pipeline triggered onv*tags.
Dependencies¶
- Requires
kglite>=0.10.4. (Earlier 0.10.3 hit two upstream bugs we filed; both fixed in 0.10.4 — see "Bug workarounds peeled" below.) - Requires
mcp-methods>=0.3(was previously in the[mcp]extra; moved to core deps in 0.0.2). - bge-m3 ONNX weights download to
~/.cache/fastembed/on first use (~2 GB, one-time). SetHF_HUB_CACHEto reuse an existing HF cache.
Bug workarounds peeled¶
Two kglite bugs we hit and reported during this build, both fixed upstream in 0.10.4:
add_nodes(Chunk, …)invalidating the id index used byset_embeddings. Pre-0.10.4 this silently wiped all prior chunk embeddings on the second-and-later document ingested into a corpus. We carried a save+reload workaround inpipeline.py(since removed).mmap_vecpanic on the second String SET on the same node. Forced us into event-sourced verifications and reviews. The kglite fix lets us mutate Strings normally now, but we kept the event sourcing — the audit trail is a better model on its own merits.
Filed in the kglite inbox at
KGLite/inbox/read/2026-05-28-from-kglite-docs-multi-file-ingest-loses-old-chunk-embeddings.md.
Known limitations¶
- Single-writer per
.kglfile. kglite is process-local; concurrent writers to the same file aren't supported.Corpusholds a Python- level lock for the review queue's claim path, which covers multi-threaded but not multi-process scenarios. - No OpenAI/Cohere embedder adapter shipped. The
EmbeddingModelprotocol is documented (dimension,embed, optionalload/unload), but onlyBgeM3Embedderis wired by default. Writing an alternative is straightforward. - PDF export is "good enough" ReportLab Platypus, not pixel-perfect. Use the MD or DOCX path for richer output, or pipe MD through pandoc.
find_consensusandlink_verificationare experimental. They work but the shape of the API isn't as well-considered as the rest. Subject to change in 0.x.
Test posture¶
- 81 unit + integration tests, ~1s run time with the stub embedder.
@pytest.mark.embedtests use the real bge-m3 model (skipped in CI to keep things fast; run locally with the model cached).- End-to-end Sonnet workflow demo proves the agent path beyond unit tests.