Skip to content

Commit 33cd343

Browse files
ana-danieleclaudeceberam
authored
fix(enrich): propagate task to enricher and enforce label whitelist (#31)
* fix(enrich): propagate task to enricher and enforce label whitelist Two related bugs prevented YAML-declared entity-type constraints from reaching NuExtract: 1. `orchestrator._ensure_enriched` hard-coded `task=""` when the YAML used the `operations:` shortcut, dropping `task.query` on the floor. With no task, `_infer_entity_targets` returned None, so the prompt sent to the model never included the allowed labels. Now `_ensure_enriched` accepts `task` and both `_run_enrich` and `_run_rag` pass `task.query` through. 2. `enricher._generate_entities` only applied `entity_targets` as a soft hint, and had no post-filter on returned labels. When the model invented types (Project, Software, Algorithm, IP-Address, …) they flowed through into the rendered output. Now the prompt carries a HARD CONSTRAINT block listing the allowed labels, and the parser drops any mention whose case-folded label is not in the allowed set (with an INFO log line summarising drops per call). Verified on df3 with the `2026-05-22a_enrich_nuextract3_postpatch` run that the enricher-only patch was a no-op without (1); end-to-end re-run with both fixes is queued. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Ana Daniele <ana.daniele@ibm.com> * fix(enrich): accept raw JSON from rewrite LLM in _infer_entity_targets `_validate_entity_target_spec` (and the post-validation parse on line 688) used `find_json_dicts`, which only matches JSON wrapped in a ```json ...``` markdown block. NuExtract3 ignores that part of the prompt and returns a bare JSON object instead — well-formed, just unfenced. Validation then failed and `_infer_entity_targets` returned None, so `entity_targets` reached `_generate_entities` as None and the HARD CONSTRAINT clause from the previous commit was silently skipped. Introduce `_parse_spec_dict` inside `_infer_entity_targets`: try `find_json_dicts` first (preserves existing behaviour for models that do use the fence), and fall back to `json.loads(content.strip())` when no fence is found. Both the validation hook and the final parse use the same helper. Confirmed end-to-end on df3 (`2026-06-01b_..._postpatch_v3`): the brief now parses, `entity_targets["labels"] = ["MODEL", "DATASET", "KPI"]`, and every per-chunk LLM REQUEST carries the HARD CONSTRAINT block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Ana Daniele <ana.daniele@ibm.com> * fix(enrich): isolate sessions, tolerate raw entity responses, use docling-core fields Three issues surfaced once the prompt actually carried the HARD CONSTRAINT block and NuExtract started responding with the expected schema. 1. Session bleed. `_detect_key_entities` reused one LM Studio session for both `_infer_entity_targets` (which primes the model with the spec schema `{"generic":..., "labels":...}`) and the per-chunk extraction calls. NuExtract carried the prior turn's schema forward and answered every chunk with the spec dict instead of an entity array. Now the leaf stage opens its own session via `_create_extraction_session`. 2. Entity parser too strict. The response parser required a ```json ...``` fenced block and assumed the top-level payload was a list. NuExtract returns bare JSON, usually as `{"entities": [...]}`. Fall back to `result.strip()` when no fence is present and unwrap a single `entities` key into the list before iterating; coerce other shapes to an empty list so downstream code keeps its invariants. 3. EntityMention field names. The mention constructor used the old docling-core argument names (`original=`, `span=`). The current docling-core EntityMention expects `orig=` and `charspan=`; the mismatch raised a pydantic validation error on every successful response, which the surrounding `try` swallowed as "Failed to parse entities JSON". End-to-end on df3: `2026-06-01e_..._postpatch_v6` is the first run where parsed entities are non-empty, e.g. `[EntityMention( text='Nougat', label='MODEL', charspan=(0, 6)), ...]`, with zero pydantic warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Ana Daniele <ana.daniele@ibm.com> * style: fix formatting Signed-off-by: Cesar Berrospi Ramis <ceb@zurich.ibm.com> * chore(enricher): add defensive handling in '_validate_entities' Add defensive handling in '_validate_entities()' to unwrap responses ensuring validation consistency with the main parsing logic. Signed-off-by: Cesar Berrospi Ramis <ceb@zurich.ibm.com> * test: add regression tests for entity constraints Signed-off-by: Cesar Berrospi Ramis <ceb@zurich.ibm.com> --------- Signed-off-by: Ana Daniele <ana.daniele@ibm.com> Signed-off-by: Cesar Berrospi Ramis <ceb@zurich.ibm.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cesar Berrospi Ramis <ceb@zurich.ibm.com>
1 parent b72d208 commit 33cd343

5 files changed

Lines changed: 498 additions & 14 deletions

File tree

docling_agent/agent/enricher.py

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -860,8 +860,9 @@ def _detect_key_entities(
860860
)
861861

862862
with self._timed_stage("entities: leaf entities"):
863+
m_leaf = self._create_extraction_session()
863864
self._extract_entities_from_leaf_items(
864-
m=m,
865+
m=m_leaf,
865866
document=hier_doc,
866867
loop_budget=loop_budget,
867868
entity_targets=entity_targets,
@@ -880,11 +881,20 @@ def _infer_entity_targets(
880881
if not task:
881882
return None
882883

883-
def _validate_entity_target_spec(content: str) -> bool:
884+
def _parse_spec_dict(content: str) -> dict | None:
884885
matches = find_json_dicts(text=content)
885-
if len(matches) != 1:
886+
if len(matches) == 1 and isinstance(matches[0], dict):
887+
return matches[0]
888+
try:
889+
parsed = json.loads(content.strip())
890+
except (json.JSONDecodeError, ValueError):
891+
return None
892+
return parsed if isinstance(parsed, dict) else None
893+
894+
def _validate_entity_target_spec(content: str) -> bool:
895+
spec = _parse_spec_dict(content)
896+
if spec is None:
886897
return False
887-
spec = matches[0]
888898
labels = spec.get("labels", [])
889899
focus_terms = spec.get("focus_terms", [])
890900
generic = spec.get("generic", False)
@@ -923,8 +933,7 @@ def _validate_entity_target_spec(content: str) -> bool:
923933
if not answer:
924934
return None
925935

926-
specs = find_json_dicts(text=answer)
927-
return specs[0] if specs else None
936+
return _parse_spec_dict(answer)
928937

929938
def _walk_and_extract_entities(
930939
self,
@@ -1027,6 +1036,8 @@ def _validate_entities(content: str) -> bool:
10271036
return False
10281037
try:
10291038
val = json.loads(match.group(1))
1039+
if isinstance(val, dict) and isinstance(val.get("entities"), list):
1040+
val = val["entities"]
10301041
if not isinstance(val, list):
10311042
return False
10321043
return all(isinstance(item, dict) and "text" in item for item in val)
@@ -1035,17 +1046,28 @@ def _validate_entities(content: str) -> bool:
10351046

10361047
target_clause = ""
10371048
rewritten_task = task or ""
1049+
allowed_labels: list[str] = []
10381050
if entity_targets:
1039-
labels = entity_targets.get("labels", [])
1051+
allowed_labels = [str(lbl).strip() for lbl in entity_targets.get("labels", []) if str(lbl).strip()]
10401052
focus_terms = entity_targets.get("focus_terms", [])
10411053
rewritten_task = entity_targets.get("rewritten_task", rewritten_task)
10421054
generic = entity_targets.get("generic", False)
1043-
if not generic or labels or focus_terms or rewritten_task:
1055+
if allowed_labels:
1056+
target_clause = (
1057+
"\nHARD CONSTRAINT — you MUST obey this:\n"
1058+
f"Use ONLY these label values: {allowed_labels}.\n"
1059+
"Reject any other entity type. If a candidate mention does not fit one of those labels, "
1060+
"omit it entirely — do NOT output it under a different label, do NOT invent new labels.\n"
1061+
"Apply the same definitions to every paragraph and every section, including reference lists, "
1062+
"captions, and tables.\n"
1063+
f"\nExtraction brief (for context only, does not expand the label set):\n{rewritten_task}\n"
1064+
f"- focus_terms (hints, not exhaustive): {focus_terms}\n"
1065+
)
1066+
elif not generic or focus_terms or rewritten_task:
10441067
target_clause = (
10451068
"\nUse this rewritten extraction brief:\n"
10461069
f"{rewritten_task}\n"
10471070
"Focus on entities that match the brief, including obvious instances even if the wording differs.\n"
1048-
f"- labels: {labels}\n"
10491071
f"- focus_terms: {focus_terms}\n"
10501072
"If an entity is not relevant to that brief, omit it."
10511073
)
@@ -1073,14 +1095,28 @@ def _validate_entities(content: str) -> bool:
10731095

10741096
if result:
10751097
match = re.search(r"```json\s*(.*?)\s*```", result, re.DOTALL)
1076-
if match:
1098+
json_text = match.group(1) if match else result.strip()
1099+
if json_text:
10771100
try:
1078-
payload = json.loads(match.group(1))
1101+
payload = json.loads(json_text)
1102+
if isinstance(payload, dict) and isinstance(payload.get("entities"), list):
1103+
payload = payload["entities"]
1104+
if not isinstance(payload, list):
1105+
payload = []
1106+
allowed_lookup = {lbl.casefold() for lbl in allowed_labels}
10791107
mentions: list[EntityMention] = []
1108+
dropped_by_label: dict[str, int] = {}
10801109
search_start = 0
10811110
for item in payload:
10821111
if not isinstance(item, dict) or not str(item.get("text", "")).strip():
10831112
continue
1113+
if allowed_lookup:
1114+
raw_label = str(item.get("label", "")).strip()
1115+
if raw_label.casefold() not in allowed_lookup:
1116+
dropped_by_label[raw_label or "<empty>"] = (
1117+
dropped_by_label.get(raw_label or "<empty>", 0) + 1
1118+
)
1119+
continue
10841120
mention = self._make_entity_mention(
10851121
item=item,
10861122
source_text=text,
@@ -1090,6 +1126,12 @@ def _validate_entities(content: str) -> bool:
10901126
if mention.charspan is not None:
10911127
search_start = mention.charspan[1]
10921128
mentions.append(mention)
1129+
if dropped_by_label:
1130+
log_info(
1131+
"Dropped mentions with disallowed labels",
1132+
count=sum(dropped_by_label.values()),
1133+
by_label=dropped_by_label,
1134+
)
10931135
log_debug("Parsed entities", count=len(mentions))
10941136
if mentions:
10951137
return EntitiesMetaField(mentions=mentions)

docling_agent/agent/orchestrator.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def _ensure_enriched(
205205
source_pairs: list[_SourcePair],
206206
library: DoclingLibrary,
207207
operations: list[str],
208+
task: str = "",
208209
) -> list[_SourcePair]:
209210
"""Run enrichment on documents that are missing the requested enrichments.
210211
@@ -229,7 +230,7 @@ def _ensure_enriched(
229230

230231
if needed:
231232
log_info(f"Enriching {doc.name!r} with operations={needed}")
232-
enriched_doc = enricher.run(task="", document=doc, operations=needed)
233+
enriched_doc = enricher.run(task=task, document=doc, operations=needed)
233234
# Persist enriched document back to library
234235
library.store(enriched_doc, entry.source_path if entry else "in-memory")
235236
# Update status flags
@@ -278,7 +279,7 @@ def _run_rag(
278279
) -> DoclingDocument:
279280
log_info(f"_run_rag: query={task.query!r}, docs={len(source_pairs)}")
280281
if task.enrich_before_rag:
281-
source_pairs = self._ensure_enriched(source_pairs, library, operations=["summarize"])
282+
source_pairs = self._ensure_enriched(source_pairs, library, operations=["summarize"], task=task.query)
282283

283284
docs: list[DoclingDocument | Path] = [doc for doc, _ in source_pairs]
284285
rag_agent = DoclingRAGAgent(
@@ -364,7 +365,7 @@ def _run_enrich(
364365
enriched_pairs.append((enriched_doc, doc_id))
365366
else:
366367
ops: list[str] = list(task.operations)
367-
enriched_pairs = self._ensure_enriched(source_pairs, library, operations=ops)
368+
enriched_pairs = self._ensure_enriched(source_pairs, library, operations=ops, task=task.query)
368369

369370
# Return: single doc → return it directly; multiple → a composite summary doc
370371
if len(enriched_pairs) == 1:

tests/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Pytest configuration and shared fixtures for docling-agent tests.
2+
3+
This file is automatically discovered by pytest and makes fixtures
4+
available to all test modules without explicit imports.
5+
"""
6+
7+
import json
8+
from pathlib import Path
9+
10+
import pytest
11+
from docling_core.types.doc.document import DoclingDocument
12+
13+
from .test_utils import MockBackend
14+
15+
16+
@pytest.fixture(scope="module")
17+
def mock_backend() -> MockBackend:
18+
"""Fixture providing a mocked backend instance."""
19+
return MockBackend()
20+
21+
22+
@pytest.fixture(scope="module")
23+
def test_document() -> DoclingDocument:
24+
"""Fixture providing the test document loaded from JSON."""
25+
json_path = Path("tests/data/2408.09869v5.json")
26+
with open(json_path) as f:
27+
doc_dict = json.load(f)
28+
return DoclingDocument.model_validate(doc_dict)

0 commit comments

Comments
 (0)