From 70219ed55697336dd118b0b5eca1d416f7518dbc Mon Sep 17 00:00:00 2001 From: Brian H Date: Wed, 7 Oct 2026 21:00:58 +0000 Subject: [PATCH] spike(docling): core extraction template, offline edge test, and the root cause of docling-graph's 0 edges docling-graph drops every edge when the template root has is_entity=False (the edge walk returns at the root). Verified offline on a saved Qwen3.8 output (36 nodes/0 edges vs 37 nodes/67 edges) and live (28 nodes/53 edges). The template root is now an entity; tests/test_template_edges.py pins both the working template and the failure mode. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- containers/kr0ki-docling/README.md | 23 ++ .../kr0ki-docling/kr0ki_docling/__init__.py | 0 .../kr0ki_docling/templates/__init__.py | 0 .../kr0ki_docling/templates/core.py | 64 ++++ .../tests/fixtures/assembled_root.json | 309 ++++++++++++++++++ .../tests/test_template_edges.py | 48 +++ 6 files changed, 444 insertions(+) create mode 100644 containers/kr0ki-docling/README.md create mode 100644 containers/kr0ki-docling/kr0ki_docling/__init__.py create mode 100644 containers/kr0ki-docling/kr0ki_docling/templates/__init__.py create mode 100644 containers/kr0ki-docling/kr0ki_docling/templates/core.py create mode 100644 containers/kr0ki-docling/tests/fixtures/assembled_root.json create mode 100644 containers/kr0ki-docling/tests/test_template_edges.py diff --git a/containers/kr0ki-docling/README.md b/containers/kr0ki-docling/README.md new file mode 100644 index 0000000..caf8b4b --- /dev/null +++ b/containers/kr0ki-docling/README.md @@ -0,0 +1,23 @@ +# kr0ki-docling — SPIKE (not wired into kr0ki-server) + +Throwaway-grade groundwork for the Docling sidecar (option C in issue #97): document → structure (Docling) → knowledge graph +(docling-graph, local Qwen3.8) → PROV-O. **What exists:** the extraction template and its tests. **What does not:** the HTTP +wrapper, the Containerfile, the docling-mcp entrypoint, the PROV-O mapping, kr0ki-server integration. + +## Findings (2026-10-07, all measured; details in `docs/evaluations/EVAL-graalvm-vs-jvm-kroki.md` §Related measurements) + +- Docling reads PPTX, including speaker notes (own `notes` layer); 67 slides in 0.9 s, 440 MiB peak. The 43-page PDF takes 730 s and + 3.0 GiB on CPU-only torch; fewer threads cost time, not memory. docling-mcp needs `DOCLING_MCP_CONVERSION_MODE=local` and only + returns a document key from its `conversion` tools (no content-returning tool), so the product path should be a stateless HTTP wrapper. +- **docling-graph's "0 edges" cause:** `GraphConverter._create_edges_pass` returns an empty list at once when the template's + ROOT class has `is_entity=False`. The model output was fine. Same output: 36 nodes / 0 edges vs 37 nodes / 67 edges once the root is an + entity. Live (Qwen3.8, dense contract): 28 nodes / 53 edges. Pinned by `tests/test_template_edges.py`. +- LiteLLM needs the model as `openai/` for an OpenAI-compatible server. Install CPU-only torch to keep the venv small + (1.6 GB) and keep Docling off the GPU that the 27B model fills. + +## Test + +```bash +# venv with docling + docling-graph (CPU torch): see the install line in the eval doc +python -m unittest discover -s tests +``` diff --git a/containers/kr0ki-docling/kr0ki_docling/__init__.py b/containers/kr0ki-docling/kr0ki_docling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/containers/kr0ki-docling/kr0ki_docling/templates/__init__.py b/containers/kr0ki-docling/kr0ki_docling/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/containers/kr0ki-docling/kr0ki_docling/templates/core.py b/containers/kr0ki-docling/kr0ki_docling/templates/core.py new file mode 100644 index 0000000..adc74b0 --- /dev/null +++ b/containers/kr0ki-docling/kr0ki_docling/templates/core.py @@ -0,0 +1,64 @@ +"""The small core vocabulary for ad hoc document knowledge (docling-graph extraction template). + +Deliberately generic: no domain classes. `Entity`/`Concept` are things the document talks about, a `Claim` is something it says +about them. Provenance is not modelled here: docling-graph records source chunk, page and bbox on every node, and kr0ki maps +that to PROV-O. Per the docling-graph template rules, validators normalize and never reject, and only identity fields are required. +""" +from typing import Any, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +def edge(label: str, reference: bool = False, **kwargs: Any) -> Any: + """Declare a field as a graph edge (docling-graph reads `edge_label` from json_schema_extra). + + `reference=True` marks an identity-only link to an entity described in full elsewhere (`graph_reference`): the parent's own + fill call supplies it, which is what keeps edges from coming back empty on a small local model. + """ + extra = dict(kwargs.pop("json_schema_extra", {}) or {}) + extra["edge_label"] = label + if reference: + extra["graph_reference"] = True + return Field(json_schema_extra=extra, **kwargs) + + +class Concept(BaseModel): + """An idea, topic, term or category the document discusses (e.g. "ownership", "garbage collection").""" + + model_config = ConfigDict(graph_id_fields=["name"]) + name: str = Field(description="Short canonical name of the concept, as the document names it.") + definition: Optional[str] = Field(default=None, description="One sentence definition, only if the document gives one.") + broader: List["Concept"] = edge("BROADER", reference=True, default_factory=list, description="More general concepts this one is a kind of.") + + +class Entity(BaseModel): + """A named thing: a person, organisation, product, system, tool, standard, place or document.""" + + model_config = ConfigDict(graph_id_fields=["name"]) + name: str = Field(description="The name exactly as written in the document.") + kind: Optional[str] = Field(default=None, description="Free-text type, e.g. 'language', 'person', 'standard'.") + related_to: List["Entity"] = edge("RELATED_TO", reference=True, default_factory=list, description="Other entities this one is explicitly related to.") + + +class Claim(BaseModel): + """A single assertion the document makes, stated in one sentence and kept close to the source wording.""" + + model_config = ConfigDict(graph_id_fields=["statement"]) + statement: str = Field(description="The assertion as one self-contained sentence.") + mentions: List[Entity] = edge("MENTIONS", reference=True, default_factory=list, description="Entities the claim is about.") + about: List[Concept] = edge("ABOUT", reference=True, default_factory=list, description="Concepts the claim is about.") + + +class DocumentKnowledge(BaseModel): + """Root: everything worth keeping from the document as entities, concepts and claims. + + The root MUST be an entity (the default). docling-graph starts its edge walk at the root and returns at once when the root is + a component (`is_entity=False`), so every edge below it is silently dropped: that gave "N nodes, 0 edges" with a model output + that did contain the relations (verified 2026-10-07: same output, 36 nodes / 0 edges vs 37 nodes / 67 edges). + """ + + model_config = ConfigDict(graph_id_fields=["title"]) + title: str = Field(description="The document's title; if it has none, a short descriptive title taken from its first heading.") + claims: List[Claim] = edge("HAS_CLAIM", default_factory=list, description="The assertions the document makes.") + entities: List[Entity] = edge("HAS_ENTITY", default_factory=list, description="Named things the document mentions.") + concepts: List[Concept] = edge("HAS_CONCEPT", default_factory=list, description="Ideas and topics the document discusses.") diff --git a/containers/kr0ki-docling/tests/fixtures/assembled_root.json b/containers/kr0ki-docling/tests/fixtures/assembled_root.json new file mode 100644 index 0000000..c9a45b5 --- /dev/null +++ b/containers/kr0ki-docling/tests/fixtures/assembled_root.json @@ -0,0 +1,309 @@ +{ + "title": "Considering Rust", + "claims": [ + { + "statement": "Rust is much faster.", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "performance" + } + ] + }, + { + "statement": "Rust has much lower memory use", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "memory use" + } + ] + }, + { + "statement": "Rust supports multi-threading.", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "multi-threading" + } + ] + }, + { + "statement": "Rust has algebraic data types", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "algebraic data types" + } + ] + }, + { + "statement": "Rust has pattern matching", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "pattern matching" + } + ] + }, + { + "statement": "Rust has comprehensive static typing", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "static typing" + } + ] + }, + { + "statement": "Rust has many fewer runtime crashes", + "mentions": [ + { + "name": "Rust" + } + ], + "about": [ + { + "name": "runtime crashes" + } + ] + } + ], + "entities": [ + { + "name": "Jon Gjengset", + "kind": "person", + "related_to": [ + { + "name": "MIT", + "kind": "organisation" + }, + { + "name": "Parallel and Distributed Operating Systems group", + "kind": "organisation" + }, + { + "name": "Noria", + "kind": "product" + }, + { + "name": "Rust", + "kind": "language" + }, + { + "name": "C", + "kind": "language" + }, + { + "name": "C++", + "kind": "language" + }, + { + "name": "Go", + "kind": "language" + }, + { + "name": "Python", + "kind": "language" + }, + { + "name": "Java", + "kind": "language" + } + ] + }, + { + "name": "MIT", + "kind": "organisation", + "related_to": [ + { + "name": "Parallel and Distributed Operating Systems group", + "kind": "organisation" + } + ] + }, + { + "name": "Parallel and Distributed Operating Systems group", + "kind": "research group", + "related_to": [ + { + "name": "MIT", + "kind": "university" + }, + { + "name": "Jon Gjengset", + "kind": "person" + } + ] + }, + { + "name": "Noria", + "kind": "database", + "related_to": [ + { + "name": "Rust" + } + ] + }, + { + "name": "Rust", + "kind": "language", + "related_to": [ + { + "name": "Mozilla" + }, + { + "name": "Java" + }, + { + "name": "C++" + }, + { + "name": "Python" + }, + { + "name": "Go" + }, + { + "name": "C" + } + ] + }, + { + "name": "Rust std", + "kind": "standard library", + "related_to": [ + { + "name": "Rust", + "kind": "language" + } + ] + }, + { + "name": "C", + "kind": "language", + "related_to": [] + }, + { + "name": "C++", + "kind": "language", + "related_to": [] + }, + { + "name": "Go", + "kind": "language", + "related_to": [] + }, + { + "name": "Python", + "kind": "language", + "related_to": [] + }, + { + "name": "Java", + "kind": "language", + "related_to": [] + }, + { + "name": "Mozilla", + "kind": "organisation", + "related_to": [] + } + ], + "concepts": [ + { + "name": "systems programming", + "definition": null, + "broader": [] + }, + { + "name": "fearless concurrency", + "definition": null, + "broader": [] + }, + { + "name": "compiled language", + "definition": "A language that is compiled to machine code rather than bytecode.", + "broader": [] + }, + { + "name": "machine code", + "definition": null, + "broader": [] + }, + { + "name": "strong, static typing", + "definition": null, + "broader": [] + }, + { + "name": "imperative", + "definition": null, + "broader": [] + }, + { + "name": "functional aspects", + "definition": null, + "broader": [] + }, + { + "name": "garbage collection", + "definition": null, + "broader": [] + }, + { + "name": "runtime", + "definition": null, + "broader": [] + }, + { + "name": "type system", + "definition": null, + "broader": [] + }, + { + "name": "multi-threading", + "definition": null, + "broader": [] + }, + { + "name": "algebraic data types", + "definition": null, + "broader": [] + }, + { + "name": "pattern matching", + "definition": null, + "broader": [] + }, + { + "name": "runtime crashes", + "definition": null, + "broader": [] + } + ] +} \ No newline at end of file diff --git a/containers/kr0ki-docling/tests/test_template_edges.py b/containers/kr0ki-docling/tests/test_template_edges.py new file mode 100644 index 0000000..7bdcdfb --- /dev/null +++ b/containers/kr0ki-docling/tests/test_template_edges.py @@ -0,0 +1,48 @@ +"""The core template must yield edges. Offline: feeds a saved model output (a real Qwen3.8 dense extraction of the +Considering.Rust.pptx excerpt) through docling-graph's converter, so no model or GPU is needed. + +Run with the venv that has docling-graph: `python -m unittest discover -s tests` (from containers/kr0ki-docling). +""" +import json +import pathlib +import sys +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) + +from docling_graph.core.converters.graph_converter import GraphConverter # noqa: E402 +from pydantic import ConfigDict # noqa: E402 + +from kr0ki_docling.templates.core import DocumentKnowledge # noqa: E402 + +OUTPUT = json.loads((HERE / "fixtures" / "assembled_root.json").read_text()) + + +def edge_labels(model_cls): + graph, _ = GraphConverter().pydantic_list_to_graph([model_cls.model_validate(OUTPUT)]) + labels = {} + for _, _, data in graph.edges(data=True): + labels[data.get("label")] = labels.get(data.get("label"), 0) + 1 + return graph, labels + + +class CoreTemplateEdges(unittest.TestCase): + def test_template_yields_every_edge_kind(self): + graph, labels = edge_labels(DocumentKnowledge) + for kind in ("HAS_CLAIM", "HAS_ENTITY", "HAS_CONCEPT", "MENTIONS", "ABOUT", "RELATED_TO"): + self.assertGreater(labels.get(kind, 0), 0, f"no {kind} edges: {labels}") + self.assertGreater(graph.number_of_edges(), graph.number_of_nodes(), "a connected graph has at least as many edges as nodes") + + def test_a_component_root_drops_all_edges(self): + """Pins WHY the root must be an entity: with is_entity=False docling-graph returns no edges at all, from valid output.""" + + class ComponentRoot(DocumentKnowledge): + model_config = ConfigDict(is_entity=False, graph_id_fields=["title"]) + + _, labels = edge_labels(ComponentRoot) + self.assertEqual(labels, {}, "docling-graph changed: a component root now produces edges; revisit the template rule") + + +if __name__ == "__main__": + unittest.main()