graph-knowledge-doc-parser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. graph_knowledge_doc_parser-0.1.0.dist-info/METADATA +326 -0
  2. graph_knowledge_doc_parser-0.1.0.dist-info/RECORD +38 -0
  3. graph_knowledge_doc_parser-0.1.0.dist-info/WHEEL +4 -0
  4. graph_knowledge_doc_parser-0.1.0.dist-info/entry_points.txt +3 -0
  5. kg_doc_parser/__init__.py +9 -0
  6. kg_doc_parser/cast_hinting.py +19 -0
  7. kg_doc_parser/document_ingester_logger.py +766 -0
  8. kg_doc_parser/models.py +277 -0
  9. kg_doc_parser/ocr.py +752 -0
  10. kg_doc_parser/pdf2png.py +286 -0
  11. kg_doc_parser/semantic_document_splitting_layerwise_edits.py +3302 -0
  12. kg_doc_parser/text_processing_utils.py +30 -0
  13. kg_doc_parser/utils/__init__.py +0 -0
  14. kg_doc_parser/utils/bounded_threadpool_executor.py +37 -0
  15. kg_doc_parser/utils/file_loaders.py +405 -0
  16. kg_doc_parser/utils/langchain.py +220 -0
  17. kg_doc_parser/utils/log.py +135 -0
  18. kg_doc_parser/utils/version_chaining.py +1278 -0
  19. kg_doc_parser/workflow_ingest/__init__.py +187 -0
  20. kg_doc_parser/workflow_ingest/_kogwistar.py +13 -0
  21. kg_doc_parser/workflow_ingest/adapters.py +212 -0
  22. kg_doc_parser/workflow_ingest/cache.py +63 -0
  23. kg_doc_parser/workflow_ingest/cli.py +324 -0
  24. kg_doc_parser/workflow_ingest/clients.py +444 -0
  25. kg_doc_parser/workflow_ingest/demo_harness.py +427 -0
  26. kg_doc_parser/workflow_ingest/design.py +208 -0
  27. kg_doc_parser/workflow_ingest/handlers.py +617 -0
  28. kg_doc_parser/workflow_ingest/models.py +575 -0
  29. kg_doc_parser/workflow_ingest/ocr_pipeline.py +1581 -0
  30. kg_doc_parser/workflow_ingest/page_index.py +473 -0
  31. kg_doc_parser/workflow_ingest/parser_core.py +862 -0
  32. kg_doc_parser/workflow_ingest/parsing.py +249 -0
  33. kg_doc_parser/workflow_ingest/probe.py +164 -0
  34. kg_doc_parser/workflow_ingest/providers.py +412 -0
  35. kg_doc_parser/workflow_ingest/runners.py +546 -0
  36. kg_doc_parser/workflow_ingest/semantics.py +231 -0
  37. kg_doc_parser/workflow_ingest/service.py +112 -0
  38. kg_doc_parser/workflow_ingest/smoke_assets.py +62 -0
@@ -0,0 +1,3302 @@
1
+ """
2
+ Semantic Document Splitting & Layer-wise Parsing Pipeline
3
+ =========================================================
4
+
5
+ This module implements a layer-wise document parsing strategy using LLMs to construct
6
+ a semantic hierarchy (Knowledge Graph) from raw OCR data.
7
+
8
+ Usage
9
+ -----
10
+ >>> from kg_doc_parser.semantic_document_splitting_layerwise_edits import parse_doc
11
+ >>> # doc_data = {"file.pdf": [page1_ocr, page2_ocr...]}
12
+ >>> document_tree, source_map = parse_doc(doc_id="my_doc", raw_doc_dict=doc_data)
13
+ >>> # Convert to KGE Payload
14
+ >>> from kg_doc_parser.semantic_document_splitting_layerwise_edits import semantic_tree_to_kge_payload
15
+ >>> graph_payload = semantic_tree_to_kge_payload(document_tree)
16
+
17
+ Pipeline Flow
18
+ -------------
19
+ [Raw OCR Data]
20
+ |
21
+ v
22
+ [prepare_document_for_llm]
23
+ |
24
+ v
25
+ [Root SemanticNode]
26
+ |
27
+ v
28
+ +----------------------- [Layer-wise BFS Loop] -----------------------+
29
+ | |
30
+ | [LLM: Breakdown Node] -> [LLM: Correction (CUD)] -> [Next Level] |
31
+ | ^ | |
32
+ | |____________________________| |
33
+ | |
34
+ +---------------------------------------------------------------------+
35
+ |
36
+ v
37
+ [Tree Reconstruction]
38
+ |
39
+ v
40
+ [Validation & Coverage Check]
41
+ |
42
+ v
43
+ [Final SemanticNode Tree]
44
+ / \
45
+ v v
46
+ [KGE Payload] [Index Terms]
47
+
48
+ Data Structures
49
+ ---------------
50
+ 1. SemanticNode:
51
+ - Represents a logical section (e.g., "Section 1", "Clause 2.1").
52
+ - Contains a list of `HydratedTextPointer`s mapping to source text.
53
+
54
+ 2. HydratedTextPointer:
55
+ - A specific span of text in the original OCR output.
56
+ - Links Semantic Nodes back to `TextCluster`s in `kg_doc_parser.models`.
57
+
58
+ 3. Source Map Structure:
59
+ - A lookup dictionary mapping unique cluster IDs to their original OCR data.
60
+ - Format: `Dict[str, TextClusterDict]`
61
+ - Key format: `"p{page_num}_c{cluster_index}"` (e.g., "p1_c0")
62
+ - Value: Dictionary representation of `TextCluster` (including text and bbox).
63
+ """
64
+ '''
65
+ parsedoc pipeline:
66
+ entry func : build_document_tree
67
+
68
+
69
+
70
+
71
+ '''
72
+
73
+
74
+ # ==============================================================================
75
+ # PHASE 1: SETUP - MODELS AND IMPORTS
76
+ # ==============================================================================
77
+ import json
78
+ import re
79
+
80
+ from dataclasses import dataclass
81
+ from enum import Enum
82
+ from typing import Annotated, List, Union, Literal, Dict, Any, Tuple, Optional
83
+ try:
84
+ from typing import TypeAlias
85
+ except ImportError: # pragma: no cover
86
+ from typing_extensions import TypeAlias
87
+ from langchain_core.language_models import BaseChatModel
88
+ from pydantic import BaseModel, Field, ValidationError, validator, field_validator
89
+ from typing import ClassVar
90
+ from uuid import UUID
91
+ from collections import deque
92
+ from pydantic import BaseModel, Field, model_validator
93
+ import math
94
+ import os
95
+ from rapidfuzz.distance import LCSseq
96
+ from datetime import datetime
97
+ from typing import Callable, TypeVar, ParamSpec, cast
98
+ from joblib import Memory
99
+ from kg_doc_parser.document_ingester_logger import DocumentIngestSQLiteCallback
100
+ from kogwistar.id_provider import stable_id
101
+ from .workflow_ingest.providers import WorkflowProviderSettings, build_chat_model
102
+
103
+ def get_llm(model_name:str):
104
+ settings = WorkflowProviderSettings.from_env()
105
+ spec = settings.parser.model_copy(update={"model": model_name})
106
+ return build_chat_model(spec, callbacks=[cb])
107
+
108
+
109
+ def _default_parser_model_names() -> List[str]:
110
+ """Use the active parser model unless an explicit list is provided."""
111
+ try:
112
+ settings = WorkflowProviderSettings.from_env()
113
+ model = (settings.parser.model or "").strip()
114
+ if model:
115
+ return [model]
116
+ except Exception:
117
+ pass
118
+ return [
119
+ "gemini-3-flash-preview",
120
+ "gemini-2.5-flash",
121
+ "gemini-2.5-pro",
122
+ "gemini-2.5-flash-lite",
123
+ ]
124
+
125
+ _DOCUMENT_INGEST_LOG_DB = os.path.join("logs", "document_ingest.sqlite")
126
+ os.makedirs(os.path.dirname(_DOCUMENT_INGEST_LOG_DB), exist_ok=True)
127
+
128
+ cb = DocumentIngestSQLiteCallback(db_path=_DOCUMENT_INGEST_LOG_DB,
129
+ log_prompts = True,
130
+ log_chat_messages = True,
131
+ log_responses = True,
132
+ log_errors = True,
133
+ include_traceback = True)
134
+
135
+
136
+ P = ParamSpec("P")
137
+ R = TypeVar("R")
138
+
139
+ def joblib_memory_cached(memory: Memory, *arg, **kwarg):
140
+ def wrapper(fn: Callable[P, R]) -> Callable[P, R]:
141
+ return cast(Callable[P, R], memory.cache(fn, *arg, **kwarg))
142
+ return wrapper
143
+
144
+
145
+ def partition(iterable, predicate):
146
+ t1, t2 = [], []
147
+ for x in iterable:
148
+ (t1 if predicate(x) else t2).append(x)
149
+ return t1, t2
150
+ from pydantic_extension.model_slicing import DtoType, BackendField, BackendType, FrontendField, FrontendType
151
+ from pydantic_extension.model_slicing.mixin import DtoField, LLMField, LLMType, ExcludeMode, ModeSlicingMixin
152
+ class HydratedTextPointer(ModeSlicingMixin, BaseModel):
153
+ default_include_modes: ClassVar= {"dto", "backend", "frontend"}
154
+ default_exclude_modes: ClassVar = {"llm"}
155
+ source_cluster_id: Annotated[str, FrontendField(), BackendField(), DtoField(), LLMField()] = Field(description="The unique ID of the source text block (e.g., 'p1_c0').")
156
+ start_char: Annotated[int, FrontendField(), BackendField(), DtoField(), LLMField()] = Field(description="The starting character index within the source text block.")
157
+ end_char: Annotated[int, FrontendField(), BackendField(), DtoField(), LLMField()] = Field(description="The inclusive ending character index. Use -1 for 'to the end'.")
158
+ verbatim_text: Annotated[Optional[str], FrontendField(), BackendField(), DtoField(), LLMField()] = Field(None, description="The exact text of this fragment. This MUST match the text at the specified pointer location. Required if delimiters are not provided.")
159
+
160
+ start_delimiter: Annotated[Optional[str], FrontendField(), BackendField(), DtoField(), LLMField()] = Field(None, description="Start delimiter to locate the text")
161
+ end_delimiter: Annotated[Optional[str], FrontendField(), BackendField(), DtoField(), LLMField()] = Field(None, description="End delimiter to locate the text")
162
+
163
+ validation_method: Optional[Annotated[str, BackendField(), LLMField(), ExcludeMode("llm")]] = Field(None, description="The exact text of this fragment. This MUST match the text at the specified pointer location.")
164
+ # backend and llm used, default to dump include, but ExcludeMode("llm") specified must be excluded when dumping to llm mode
165
+
166
+ @model_validator(mode="after")
167
+ def end_char_minus_1_to_ending_index(self):
168
+ if self.end_char == -1 and self.verbatim_text:
169
+ self.end_char += len(self.verbatim_text)
170
+ return self
171
+ # --------------------------
172
+ # pointer -> ref dict
173
+ # --------------------------
174
+ def to_ref_dict(
175
+ self,
176
+ *,
177
+ doc_id: str,
178
+ insertion_method: str = "semantic_document_parser_v1",
179
+ base_doc_url: Optional[str] = None,
180
+ page_num: Optional[int] = None,
181
+ ) -> Dict[str, Any]:
182
+ """
183
+ Convert this pointer to a plain dict reference, no external model required.
184
+ We encode the cluster id in document_page_url as ...#{source_cluster_id},
185
+ so we can reverse later.
186
+ """
187
+ if not base_doc_url:
188
+ base_doc_url = f"doc://{doc_id}"
189
+
190
+ start_page = page_num or 1
191
+ end_page = start_page
192
+
193
+ # keep the -1 sentinel reversible by using a large number
194
+ end_char = self.end_char if self.end_char != -1 else 10**9
195
+
196
+ return {
197
+ "doc_id": doc_id,
198
+ "collection_page_url": base_doc_url,
199
+ "document_page_url": f"{base_doc_url}#{self.source_cluster_id}",
200
+ "insertion_method": insertion_method,
201
+ "start_page": start_page,
202
+ "end_page": end_page,
203
+ "start_char": self.start_char,
204
+ "end_char": end_char,
205
+ "snippet": self.verbatim_text[:400],
206
+ }
207
+
208
+ # --------------------------
209
+ # ref dict -> pointer
210
+ # --------------------------
211
+ @classmethod
212
+ def from_ref_dict(cls, ref: Dict[str, Any]) -> "HydratedTextPointer":
213
+ """
214
+ Rebuild a pointer from a plain dict ref.
215
+ We rely on document_page_url ending with '#{cluster_id}'.
216
+ If missing, we fallback to UNKNOWN_CLUSTER.
217
+ """
218
+ doc_url = ref.get("document_page_url") or ""
219
+ if ref.get("source_cluster_id"):
220
+ source_cluster_id = ref.get("source_cluster_id") or "UNKNOWN_CLUSTER"
221
+ elif "#" in doc_url:
222
+ source_cluster_id = doc_url.split("#", 1)[1]
223
+ else:
224
+ source_cluster_id = "UNKNOWN_CLUSTER"
225
+
226
+ start_char = int(ref.get("start_char", 0))
227
+ end_char_raw = int(ref.get("end_char", 0))
228
+ end_char = -1 if end_char_raw >= 10**8 else end_char_raw
229
+
230
+ return cls(
231
+ source_cluster_id=source_cluster_id,
232
+ start_char=start_char,
233
+ end_char=end_char,
234
+ verbatim_text=ref.get("excerpt") or "",
235
+ validation_method = ref.get('verification')
236
+ )
237
+ HydratedTextPointerLLM : TypeAlias = HydratedTextPointer['llm']
238
+
239
+ class LLMChildNodeResponse(ModeSlicingMixin, BaseModel): # for LLM
240
+ default_include_modes: ClassVar= {"dto"}
241
+ default_exclude_modes: ClassVar = set() # in an llm model, all field are llm field and not expected to be excluded
242
+ include_unmarked_for_modes: ClassVar = {"dto", "frontend", "backend"}
243
+ parent_node_id: Annotated[str, DtoField(),BackendField(),LLMField()] = Field(description="The UUID string of the parent node this child belongs to.")
244
+ node_type: Annotated[Literal["TEXT_FLOW", "KEY_VALUE_PAIR", "TABLE"], BackendField(), LLMField(),DtoField()] = Field(description="The semantic type of the child node.")
245
+ title: Annotated[str, BackendField(), LLMField(),DtoField()]= Field(description="The title, key, or a concise summary of the child section.")
246
+ pointers: Annotated[List[HydratedTextPointer], BackendField(), LLMField(),DtoField()] = Field(description="A list of rich, hydrated pointers that physically constitute this logical child node."
247
+ "For key value pair, can split the key and value into 2 different pointers. "
248
+ "If a sentence has been broken down into multiple text_clusters, leave them separatedly included in this list of pointers. ")
249
+ # value_pointers: Optional[List[HydratedTextPointer]] = Field(None, description="(Used for KEY_VALUE_PAIR only, Null/None otherwise) Pointers to the value part of the node.")
250
+ @field_validator("parent_node_id")
251
+ def _check_parent_in_prev_layer(cls, v):
252
+ allowed_ids = [str(i.node_id) for i in current_level_nodes.get()]
253
+ if str(v) not in allowed_ids:
254
+ raise ValueError("parent_node_id not in current allowed parent layer")
255
+ return v
256
+ def to_BE(self):
257
+ return LLMChildNodeResponseBE.model_validate(self.model_dump())
258
+ # @model_validator(mode="after")
259
+ # def _check_consistency(self):
260
+ # if self.node_type == "KEY_VALUE_PAIR":
261
+ # if self.value_pointers is None:
262
+ # raise ValueError('value_pointers cannot be None if node type is KEY_VALUE_PAIR')
263
+ # else:
264
+ # assert len(self.pointers) == len(self.value_pointers), 'pointer length must be same as value pointer for KEY_VALUE_PAIR'
265
+ # else:
266
+ # assert not self.value_pointers, "value_pointers must be None if node type is not KEY_VALUE_PAIR"
267
+ # return self
268
+ class LLMChildNodeResponseBE(ModeSlicingMixin, BaseModel):
269
+ default_include_modes: ClassVar= {"dto", "backend", "frontend"}
270
+ default_exclude_modes: ClassVar = set()
271
+ include_unmarked_for_modes: ClassVar = {"dto", "frontend", "backend"}
272
+ id: Annotated[UUID | None, BackendField()] = Field(default=None, description="Deterministic id for the child candidate.") # no Field to avoid being accidentally passed to LLM
273
+ parent_node_id: Annotated[str, DtoField(), BackendField()] = Field(description="The UUID string of the parent node this child belongs to.")
274
+ node_type: Annotated[Literal["TEXT_FLOW", "KEY_VALUE_PAIR", "TABLE"], DtoField(), BackendField()] = Field(description="The semantic type of the child node.")
275
+ title: Annotated[str, DtoField(), BackendField()] = Field(description="The title, key, or a concise summary of the child section.")
276
+ pointers: List[Annotated[HydratedTextPointer, DtoField(), BackendField()]] = Field(description="A list of rich, hydrated pointers that physically constitute this logical child node.")
277
+ # value_pointers: Optional[List[HydratedTextPointer]] = Field(None, description="(For KEY_VALUE_PAIR only) Pointers to the value part of the node.")
278
+
279
+
280
+ # historical reason: the dto type is used for this model
281
+
282
+ class LLMLevelResponse(ModeSlicingMixin, BaseModel):
283
+ default_include_modes: ClassVar= {"frontend", "llm", "backend", "dto"}
284
+ default_exclude_modes: ClassVar = set()
285
+ include_unmarked_for_modes: ClassVar = {"dto", "frontend", "backend", "llm"}
286
+ children: List[Annotated[LLMChildNodeResponse, BackendField(), FrontendField(), DtoField(), LLMField()]] = Field(description='a list of parsing response, prefer gently narrow down scope.')
287
+
288
+ class LLMLevelResponseBE(ModeSlicingMixin, BaseModel):
289
+ default_include_modes: ClassVar= {"dto", "backend", "frontend"}
290
+ include_unmarked_for_modes: ClassVar = {"dto", "frontend", "backend", "llm"}
291
+ children: List[Annotated[LLMChildNodeResponseBE, DtoField(), LLMField(), FrontendField(), BackendField()]]
292
+
293
+
294
+ class SemanticNode(BaseModel):
295
+ node_id: UUID | None = None
296
+ parent_id: Optional[UUID] = None
297
+ node_type: Literal["DOCUMENT_ROOT", "TEXT_FLOW", "KEY_VALUE_PAIR", "TABLE"] = Field("TEXT_FLOW")
298
+ title: str
299
+ total_content_pointers: List[HydratedTextPointer]
300
+ child_nodes: List['SemanticNode'] = Field([])
301
+ level_from_root: int
302
+
303
+ @model_validator(mode="after")
304
+ def _ensure_stable_node_id(self):
305
+ if self.node_id is not None:
306
+ return self
307
+ pointer_fp = "|".join(
308
+ f"{p.source_cluster_id}:{p.start_char}:{p.end_char}:{p.verbatim_text or ''}"
309
+ for p in self.total_content_pointers
310
+ )
311
+ self.node_id = stable_id(
312
+ "legacy.semantic_node",
313
+ str(self.parent_id or "root"),
314
+ str(self.node_type),
315
+ str(self.title),
316
+ str(self.level_from_root),
317
+ pointer_fp,
318
+ )
319
+ return self
320
+ # -------------------------------------------------
321
+ # 🔍 Search descriptor builder (unchanged)
322
+ # -------------------------------------------------
323
+ def _build_search_descriptors(self, source_map: Dict[str, Dict]) -> Dict[str, Any]:
324
+ text = "".join((p.verbatim_text or "") for p in self.total_content_pointers)
325
+ title = self.title or ""
326
+ lowered = title.lower()
327
+ tokens = re.findall(r"[a-zA-Z0-9_/.-]+", lowered)
328
+ numbers = re.findall(r"\b\d[\d,./:-]*\b", text)
329
+ dates = re.findall(r"\b\d{1,2}/\d{1,2}/\d{2,4}\b", text)
330
+ return {
331
+ "search": {
332
+ "title_terms": tokens,
333
+ "numbers": numbers,
334
+ "dates": dates,
335
+ "fulltext": text[:3000],
336
+ }
337
+ }
338
+
339
+ # -------------------------------------------------
340
+ # 🪶 to_kg_node — emit real Node dict with references
341
+ # -------------------------------------------------
342
+ def to_kg_node(
343
+ self,
344
+ source_map: Dict[str, Dict],
345
+ doc_id: str,
346
+ *,
347
+ insertion_method: str = "semantic_document_parser_v1",
348
+ namespace: str = "docs",
349
+ base_doc_url: Optional[str] = None,
350
+ ) -> Dict[str, Any]:
351
+ """Serialize this SemanticNode into a well-formed KGE Node dictionary."""
352
+
353
+ # 1️ Convert pointers → ref dicts using built-in reversible mapping
354
+ references = [
355
+ p.to_ref_dict(
356
+ doc_id=doc_id,
357
+ insertion_method=insertion_method,
358
+ base_doc_url=base_doc_url,
359
+ )
360
+ for p in self.total_content_pointers
361
+ ]
362
+ if not references:
363
+ references = [{
364
+ "doc_id": doc_id,
365
+ "collection_page_url": base_doc_url or f"doc://{doc_id}",
366
+ "document_page_url": f"{base_doc_url or 'doc://'+doc_id}#UNKNOWN",
367
+ "insertion_method": insertion_method,
368
+ "start_page": 1,
369
+ "end_page": 1,
370
+ "start_char": 0,
371
+ "end_char": 0,
372
+ "snippet": self.title[:400],
373
+ }]
374
+
375
+ # # 2 Metadata block (LLM/trace info)
376
+ # metadata = {
377
+ # "pointers": [p.model_dump() for p in self.total_content_pointers],
378
+ # "doc_id": doc_id,
379
+ # "node_type_src": self.node_type,
380
+ # "created_at": datetime.utcnow().isoformat() + "Z",
381
+ # "insertion_method": insertion_method,
382
+ # }
383
+ # metadata.update(self._build_search_descriptors(source_map))
384
+
385
+ # 3 Text summary
386
+ display_text = "".join((p.verbatim_text or "") for p in self.total_content_pointers)[:1000]
387
+
388
+ # 4 Compose Node dict (aligned with your KGE models)
389
+ return {
390
+ "id": str(self.node_id),
391
+ "label": self.title,
392
+ "type": "entity", # for persistence
393
+ "subtype": self.node_type, # semantic subtype
394
+ "namespace": namespace,
395
+ "summary": display_text,
396
+ "references": references,
397
+ # "metadata": metadata,
398
+ }
399
+
400
+ # -------------------------------------------------
401
+ # from_kg_node — reconstruct from Node dict
402
+ # -------------------------------------------------
403
+ @classmethod
404
+ def from_kg_node(cls, kg_node: Dict[str, Any]) -> 'SemanticNode':
405
+ """Rebuild a SemanticNode from a KGE Node dictionary."""
406
+ # meta = {} # kg_node.get("metadata") or {}
407
+ # pointers_raw = meta.get("pointers") or []
408
+
409
+ # if not present, reconstruct pointers from references
410
+ # if not pointers_raw and (refs := kg_node.get("references")):
411
+ refs = kg_node.get("references")
412
+ if refs is None:
413
+ raise ValueError('kg_node must have field "refs"')
414
+ pointers_raw = [
415
+ HydratedTextPointer.from_ref_dict(r).model_dump() for r in refs
416
+ ]
417
+
418
+ return cls(
419
+ node_id=UUID(kg_node["id"]),
420
+ parent_id=None, # re-linked later from edges
421
+ node_type=kg_node.get("subtype", "TEXT_FLOW"),
422
+ title=kg_node.get("label", ""),
423
+ total_content_pointers=[
424
+ HydratedTextPointer.model_validate(p) for p in pointers_raw
425
+ ],
426
+ child_nodes=[],
427
+ )
428
+
429
+ # -------------------------------------------------
430
+ # flatten_tree_to_kge_payload — recursive traversal
431
+ # -------------------------------------------------
432
+ def flatten_tree_to_kge_payload(
433
+ self,
434
+ source_map: Dict[str, Dict],
435
+ doc_id: str,
436
+ *,
437
+ insertion_method: str = "semantic_document_parser_v1",
438
+ namespace: str = "docs",
439
+ base_doc_url: Optional[str] = None,
440
+ ) -> Dict[str, Any]:
441
+ """
442
+ Flatten this SemanticNode hierarchy into a full KGE upsert payload.
443
+ Returns:
444
+ {
445
+ "nodes": [...],
446
+ "edges": [...]
447
+ }
448
+ """
449
+ nodes: List[Dict[str, Any]] = []
450
+ edges: List[Dict[str, Any]] = []
451
+
452
+ def walk(node: 'SemanticNode'):
453
+ # add node
454
+ nodes.append(
455
+ node.to_kg_node(
456
+ source_map,
457
+ doc_id,
458
+ insertion_method=insertion_method,
459
+ namespace=namespace,
460
+ base_doc_url=base_doc_url,
461
+ )
462
+ )
463
+ # add edges
464
+ for child in node.child_nodes:
465
+ ref = (
466
+ child.total_content_pointers[0].to_ref_dict(
467
+ doc_id=doc_id,
468
+ insertion_method=insertion_method,
469
+ base_doc_url=base_doc_url,
470
+ )
471
+ if child.total_content_pointers else None
472
+ )
473
+ edges.append(
474
+ {
475
+ "id": str(
476
+ stable_id(
477
+ "legacy.edge",
478
+ "HAS_CHILD",
479
+ str(node.node_id),
480
+ str(child.node_id),
481
+ str(doc_id),
482
+ str(namespace),
483
+ )
484
+ ),
485
+ "subject_id": str(node.node_id),
486
+ "predicate": "HAS_CHILD",
487
+ "object_id": str(child.node_id),
488
+ "type": "relationship",
489
+ "namespace": namespace,
490
+ "references": [ref] if ref else [],
491
+ "metadata": {
492
+ "doc_id": doc_id,
493
+ "insertion_method": insertion_method,
494
+ },
495
+ }
496
+ )
497
+ walk(child)
498
+
499
+ walk(self)
500
+ return {
501
+ "doc_id": doc_id,
502
+ "namespace": namespace,
503
+ "insertion_method": insertion_method,
504
+ "nodes": nodes,
505
+ "edges": edges,
506
+ }
507
+ SemanticNode.model_rebuild()
508
+ from contextvars import ContextVar
509
+ current_level_nodes = ContextVar("allowed_choices", default=[])
510
+
511
+ def reject_self_recursion(parent_type: str, child: LLMChildNodeResponse) -> bool:
512
+ """
513
+ Return True if the child should be rejected for self-recursion.
514
+ Current rule: forbid KEY_VALUE_PAIR directly under KEY_VALUE_PAIR.
515
+ """
516
+ return parent_type == "KEY_VALUE_PAIR" and child.node_type == "KEY_VALUE_PAIR"
517
+
518
+ def not_self_like(parent: SemanticNode, child: LLMChildNodeResponse) -> bool:
519
+ """
520
+ Reject if child mirrors the parent (same type, same title, same spans).
521
+ """
522
+ same_title = _normalize_title(child.title) == _normalize_title(parent.title)
523
+ same_type = child.node_type == parent.node_type
524
+ parent_spans = {_pkey(p) for p in parent.total_content_pointers}
525
+ child_spans = {_pkey(p) for p in child.pointers}
526
+ same_spans = child_spans == parent_spans
527
+ return not (same_title and same_type and same_spans)
528
+ # ==============================================================================
529
+ # PHASE 2: PRE-PROCESSING
530
+ # ==============================================================================
531
+ def prepare_document_for_llm(doc_dict: Dict) -> Tuple[Dict, Dict[str, Dict]]:
532
+ # Simple restructure of input format
533
+ filename = list(doc_dict.keys())[0]
534
+ pages_data = doc_dict[filename]
535
+ source_cluster_map = {}
536
+ for page in pages_data:
537
+ if 'pdf_page_num' not in page or 'OCR_text_clusters' not in page:
538
+ continue
539
+ page_num = page['pdf_page_num']
540
+ for i, cluster in enumerate(page['OCR_text_clusters']):
541
+ cluster_id = f"p{page_num}_c{i}"
542
+ cluster['id'] = cluster_id
543
+ source_cluster_map[cluster_id] = cluster
544
+ for i, cluster in enumerate(page['non_text_objects']):
545
+ cluster_id = f"p{page_num}_c{i}"
546
+ cluster['id'] = cluster_id
547
+ source_cluster_map[cluster_id] = cluster
548
+ return {"document_filename": filename, "pages": pages_data}, source_cluster_map
549
+
550
+
551
+ def _source_map_entry_text(entry: Dict[str, Any]) -> str:
552
+ """Best-effort text extraction for OCR-shaped source-map entries.
553
+
554
+ The legacy parser sometimes sees raw OCR cluster dicts and sometimes sees
555
+ already-normalized text-bearing records. We accept both so a missing `text`
556
+ key does not break tree bootstrapping.
557
+ """
558
+
559
+ if not isinstance(entry, dict):
560
+ return str(entry or "")
561
+
562
+ for key in ("text", "content", "verbatim_text", "snippet", "summary"):
563
+ value = entry.get(key)
564
+ if isinstance(value, str) and value.strip():
565
+ return value
566
+
567
+ clusters = entry.get("OCR_text_clusters") or []
568
+ if isinstance(clusters, list) and clusters:
569
+ parts = []
570
+ for cluster in clusters:
571
+ if not isinstance(cluster, dict):
572
+ continue
573
+ text = (
574
+ cluster.get("text")
575
+ or cluster.get("content")
576
+ or cluster.get("verbatim_text")
577
+ or cluster.get("snippet")
578
+ or ""
579
+ )
580
+ if text:
581
+ parts.append(str(text))
582
+ if parts:
583
+ return "\n".join(parts)
584
+
585
+ non_text_objects = entry.get("non_text_objects") or []
586
+ if isinstance(non_text_objects, list) and non_text_objects:
587
+ parts = []
588
+ for obj in non_text_objects:
589
+ if not isinstance(obj, dict):
590
+ continue
591
+ text = obj.get("description") or obj.get("text") or ""
592
+ if text:
593
+ parts.append(str(text))
594
+ if parts:
595
+ return "\n".join(parts)
596
+
597
+ return ""
598
+
599
+ # ==============================================================================
600
+ # PHASE 3: LLM PROCESSING - ROBUST LAYER-WISE (BFS)
601
+ # ==============================================================================
602
+
603
+ PROMPT_POINTER_CORRECTION = """
604
+ **ROLE:**
605
+ You are an expert AI data validation specialist. Your task is to correct errors in previously extracted data.
606
+
607
+ **TASK:**
608
+ You will be given a list of child nodes that failed validation. The `pointers` for these nodes likely do not match their `verbatim_text`.
609
+ For EACH node in the list, re-analyze the full document context and provide a corrected list of `HydratedTextPointer` objects.
610
+
611
+ **INSTRUCTIONS:**
612
+ 1. **Focus on Correction:** Your primary goal is to fix the `start_char` and `end_char` for each pointer so that it perfectly matches its `verbatim_text`.
613
+ 2. **Maintain Structure:** Do not change the `parent_node_id`, `node_type`, or `title`. Only correct the pointers.
614
+ 3. **Return Only Corrected Nodes:** If you successfully correct a node, include it in your output. If you cannot fix a node, omit it.
615
+
616
+ **OUTPUT FORMAT:**
617
+ You MUST output a valid JSON object conforming to the `LLMLevelResponse` schema, containing only the nodes you were able to successfully correct.
618
+
619
+ **FULL DOCUMENT JSON (for context):**
620
+ ```json
621
+ {full_document_json}
622
+ ```
623
+ **NODES REQUIRING CORRECTION:**
624
+ ```json
625
+ {nodes_to_correct_json}
626
+ """
627
+
628
+ # PROMPT_BATCH_SUBDIVIDER = """
629
+ # **ROLE:**
630
+ # You are a meticulous AI document analyst. Your task is to physically locate and report on all text fragments that make up the immediate children of the parent sections provided.
631
+
632
+ # **TASK:**
633
+ # For EACH parent section in the list, identify its IMMEDIATE children. For each child, you must identify every single text fragment that constitutes it.
634
+
635
+ # **OUTPUT FORMAT:**
636
+ # You MUST output a valid JSON object conforming to the `LLMLevelResponse` schema.
637
+ # For each child you find, you must provide a list of `HydratedTextPointer` objects in the `pointers` field.
638
+ # Each `HydratedTextPointer` object MUST contain:
639
+ # 1. `source_cluster_id`: The ID of the text block where the fragment is located.
640
+ # 2. `start_char` and `end_char`: The precise start and end character indices of the fragment, relative to the start of its source text block.
641
+ # 3. `verbatim_text`: The exact text of that fragment. This MUST match the text at the specified pointer location.
642
+
643
+ # **EXAMPLE for a child "Clause A.1" that is split across two text blocks:**
644
+ # ```json
645
+ # {
646
+ # "parent_node_id": "uuid-of-parent",
647
+ # "node_type": "TEXT_FLOW",
648
+ # "title": "Clause A.1",
649
+ # "pointers": [
650
+ # {
651
+ # "source_cluster_id": "p1_c5",
652
+ # "start_char": 50,
653
+ # "end_char": 150,
654
+ # "verbatim_text": "This is the first part of the clause..."
655
+ # },
656
+ # {
657
+ # "source_cluster_id": "p2_c1",
658
+ # "start_char": 0,
659
+ # "end_char": 80,
660
+ # "verbatim_text": "...and this is the second part of the clause."
661
+ # }
662
+ # ]
663
+ # **FULL DOCUMENT JSON (for context):**
664
+ # ```json
665
+ # {full_document_json}
666
+ # ```
667
+
668
+ # parent layer nodes:
669
+ # ```json
670
+ # {parent_sections_json}
671
+ # ```
672
+ # }"""
673
+ from string import Template
674
+ import json
675
+ from uuid import UUID
676
+
677
+ PROMPT_BATCH_SUBDIVIDER_DELIMITER = Template(
678
+ r"""
679
+ **ROLE:**
680
+ You are a meticulous AI document analyst. Your task is to identify the immediate children of the parent sections provided by finding unique start and end delimiters in the text.
681
+
682
+ **TASK:**
683
+ For EACH parent section in the list, identify its IMMEDIATE children.
684
+ Instead of extracting the full text, provide a `start_delimiter` and `end_delimiter` for each child.
685
+ - `start_delimiter`: A unique short phrase (5-20 words) that marks the beginning of the child section.
686
+ - `end_delimiter`: A unique short phrase (5-20 words) that marks the end of the child section (inclusive).
687
+
688
+ **RULES:**
689
+ 1. **Uniqueness**: The delimiters MUST be unique within the parent's context. If a phrase appears multiple times, include enough surrounding context words to make it unique.
690
+ 2. **Coverage**: The combination of children should cover the parent's content meaningfully.
691
+ 3. **Granularity**: Break down into logical sections (e.g. clauses, paragraphs). Do not break down too finely (sentences) unless they are independent items.
692
+ 4. **Pointers**: You do NOT need to provide `start_char`, `end_char` or full `verbatim_text`. Just the delimiters.
693
+
694
+ **OUTPUT FORMAT:**
695
+ Output a valid JSON object conforming to `LLMLevelResponse`.
696
+ For each child in `pointers`:
697
+ - `source_cluster_id`: (Optional/Implied) The ID of the text block.
698
+ - `start_delimiter`: The start phrase.
699
+ - `end_delimiter`: The end phrase.
700
+ - `verbatim_text`: (Optional) Can be empty or a short summary.
701
+
702
+ **EXAMPLE:**
703
+ ```json
704
+ {
705
+ "parent_node_id": "...",
706
+ "node_type": "TEXT_FLOW",
707
+ "title": "Section 1.1",
708
+ "pointers": [
709
+ {
710
+ "source_cluster_id": "p1_c0",
711
+ "start_delimiter": "1.1 Scope of Services The Provider shall",
712
+ "end_delimiter": "specifications listed in Exhibit A."
713
+ }
714
+ ]
715
+ }
716
+ ```
717
+
718
+ FULL DOCUMENT JSON (for context):
719
+
720
+ ```json
721
+
722
+ $full_document_json
723
+ ```
724
+ PARENT SECTIONS TO PROCESS:
725
+
726
+ ```json
727
+
728
+ $parent_sections_json
729
+ ```
730
+
731
+ **CRITICAL OUTPUT RULES:**
732
+ - This is OCR/document extraction, not a conversation with a human.
733
+ - Do not ask for clarification or mention that an image is missing a question.
734
+ - Do not provide prose, explanations, or apologies.
735
+ - Return only a single valid JSON object matching `LLMLevelResponse`.
736
+ - If a parent has no children, return it with an empty `children` list.
737
+ """
738
+ )
739
+
740
+ PROMPT_BATCH_SUBDIVIDER = Template(
741
+ r"""
742
+ **ROLE:**
743
+ You are a meticulous AI document analyst. Your task is to physically locate and report on all text fragments that make up the immediate children of the parent sections provided.
744
+ Some sections might be broken down in other iterations. Do not attempt to add them back. Only consider current given parent nodes should be broken down or not.
745
+ **TASK:**
746
+ For EACH parent section in the list, identify its IMMEDIATE children. For each child, you must identify every single text fragment that constitutes it.
747
+ Prioritize at breaking into textflows. Key-value pairs can then be discovered when the test flow is granular later.
748
+ For example, if a node contains section-A and section B, each contain sub-section, being coarser is correct and preferred. Because next iteration will further breakdown from section level to subsection level.
749
+ If a node is already very fine-grained and handlable, you can then start parsing it into key value pairs.
750
+ Leave small node unparsed when it convey a single meaning.
751
+ When breaking down, all children combined together must preserve their parent's meaning.
752
+ Each parent must have more than 1 child or no child. Explanation: Having 1 child mean that child has same meaning and granularity as parent and it is not break down.
753
+ If a parent has no children, it just mean it is granular enough. It is still preserved, not discarded.
754
+ If all nodes are granular enough. Just give no children in response to indicate completion
755
+
756
+ **OUTPUT FORMAT:**
757
+ You MUST output a valid JSON object conforming to the `LLMLevelResponse` schema.
758
+ For each child you find, provide a list of `HydratedTextPointer` objects in `pointers`.
759
+
760
+ **EXAMPLE for a child "Clause A.1" that is split across two text blocks:**
761
+ ```json
762
+ {
763
+ "parent_node_id": "uuid-of-parent",
764
+ "node_type": "TEXT_FLOW",
765
+ "title": "Clause A.1",
766
+ "pointers": [
767
+ {
768
+ "source_cluster_id": "p1_c5",
769
+ "start_char": 50,
770
+ "end_char": 150,
771
+ "verbatim_text": "This is the first part of the clause..."
772
+ },
773
+ {
774
+ "source_cluster_id": "p2_c1",
775
+ "start_char": 0,
776
+ "end_char": 80,
777
+ "verbatim_text": "...and this is the second part of the clause."
778
+ }
779
+ ]
780
+ }
781
+
782
+
783
+ FULL DOCUMENT JSON (for context):
784
+
785
+ ```json
786
+
787
+ $full_document_json
788
+ ```
789
+ PARENT SECTIONS TO PROCESS:
790
+
791
+ ```json
792
+
793
+ $parent_sections_json
794
+ ```
795
+
796
+ **CRITICAL OUTPUT RULES:**
797
+ - This is OCR/document extraction, not a conversation with a human.
798
+ - Do not ask for clarification or mention that the input is an image.
799
+ - Do not provide prose, explanations, or apologies.
800
+ - Return only a single valid JSON object matching `LLMLevelResponse`.
801
+ - If a parent has no children, return it with an empty `children` list.
802
+ """
803
+ )
804
+ # PROMPT_SUBDIVIDER = """
805
+ # **ROLE:**
806
+ # You are a micro-analyst specializing in deconstructing text sections.
807
+ # **TASK:**
808
+ # Analyze ONLY the provided `text_to_analyze`. Your goal is to identify its IMMEDIATE children. Do not find grandchildren. If there are no clear sub-sections, return an empty list.
809
+ # **CONTEXT:**
810
+ # The parent section is titled: "{parent_title}"
811
+ # **OUTPUT FORMAT:**
812
+ # Provide a JSON list, where each object conforms to the `LLMChildNodeResponse` schema.
813
+ # - For each child, provide its `node_type`, `title`, `verbatim_text`, and relative `pointers`.
814
+ # - For `KEY_VALUE_PAIR`, also provide the `value_text`.
815
+ # - The pointers' `start_char` and `end_char` MUST be relative to the beginning of the `text_to_analyze`.
816
+ # **TEXT TO ANALYZE:**
817
+ # {text_to_analyze}
818
+ # """
819
+ # MASTER_PROMPT_HIERARCHICAL = """
820
+ # **ROLE:**
821
+ # You are an expert AI document analyst that understands document structure as a hierarchy.
822
+
823
+ # **TASK:**
824
+ # Analyze the provided document JSON. Your task is to identify ALL semantic sections and sub-sections, from the highest-level clauses down to individual points. You will represent this entire hierarchy as a single FLAT LIST of nodes.
825
+
826
+ # **INSTRUCTIONS:**
827
+ # 1. **Discover the Hierarchy:** Read the entire document to understand its structure (sections, sub-sections, key-value pairs).
828
+ # 2. **Generate Unique IDs:** For every semantic part you identify (e.g., "Section 1", "Clause 1.1", "Fee Amount"), create a `FlatSemanticNode` object and assign it a new, unique `node_id`.
829
+ # 3. **Establish Parent-Child Links:** For each node, set its `parent_id` to the `node_id` of the section that contains it. Top-level sections should have a `parent_id` of `null`.
830
+ # 4. **Define Pointers:** For every node, provide the `direct_content_pointers` that correspond ONLY to its title or key text (e.g., for "1.1 Definitions", the pointers cover just that heading text).
831
+
832
+ # **OUTPUT FORMAT:**
833
+ # You MUST output a single, valid JSON object that conforms to the `DocumentParseResult` schema. The output MUST be a flat list of nodes, not a nested tree.
834
+ # """
835
+ # PROMPT_BATCH_SUBDIVIDER = """
836
+ # **ROLE:**
837
+ # You are a parallel-processing AI document analyst with full contextual awareness.
838
+
839
+ # **TASK:**
840
+ # You will be given the complete JSON of a source document and a specific list of parent sections to analyze. For EACH parent section in the list, your task is to identify its IMMEDIATE children.
841
+
842
+ # **INSTRUCTIONS:**
843
+ # 1. **Use the Full Document for Context:** Refer to the `full_document_json` to understand definitions, cross-references, and the overall purpose of the document. This is your knowledge base.
844
+ # 2. **Focus on Your Assigned Task:** Your primary goal is to analyze ONLY the text corresponding to the `parent_sections_to_analyze`. Do not analyze or return children for any other part of the document.
845
+ # 3. **Return a Map:** Your output must be a map where the keys are the `parent_node_id`s you were asked to process, and the values are the lists of children you found for each.
846
+
847
+ # **OUTPUT FORMAT:**
848
+ # You MUST output a single, valid JSON object that conforms to the `LLMLevelResponse` schema.
849
+ # The `child_map` keys MUST be the `parent_node_id`s from the input.
850
+
851
+ # **FULL DOCUMENT JSON (for context):**
852
+ # ```json
853
+ # {full_document_json}
854
+ # ```
855
+
856
+ # parent layer nodes:
857
+ # ```json
858
+ # {parent_sections_json}
859
+ # ```
860
+ # """
861
+
862
+ from langchain_core.messages import HumanMessage,SystemMessage,BaseMessage
863
+ from joblib import Memory
864
+ memory = Memory(location = os.getenv("KG_DOC_PARSER_JOBLIB_CACHE_DIR", ".joblib"))
865
+ @joblib_memory_cached(memory, ignore = ['model_names', 'event_name'])
866
+ def retried_level_node_llm_parsing(model_names, nodes_at_level, messages, doc_id, event_name, parent_node_id_set):
867
+
868
+ i_model = 0
869
+ while True:
870
+ model_name = model_names[i_model]
871
+ try:
872
+ print(f"\n--- Calling LLM ({model_name}) for {len(nodes_at_level)} nodes at this level ---")
873
+ llm = get_llm(model_name)
874
+ import inspect
875
+ cf = inspect.currentframe()
876
+ line_no = cf.f_lineno if cf else None
877
+ max_retry = 2
878
+ # Use with_structured_output with our new batch response model
879
+ for retries in range(max_retry):
880
+ try:
881
+ response: dict = llm.with_structured_output(LLMLevelResponse["llm"], include_raw=True).invoke(messages,
882
+ config={
883
+ "metadata": {
884
+ "document_id": doc_id,
885
+ "event_name": event_name,
886
+ "source_filename": __file__,
887
+ "line_number": line_no
888
+ }
889
+ }) # type: ignore) # type: ignore
890
+
891
+ if response.get('parsing_error'):
892
+ raise response['parsing_error']
893
+ parsed: LLMLevelResponse["llm"] = response['parsed']
894
+ assert all(i.parent_node_id in parent_node_id_set for i in parsed.children), "llm generated non existed parent id"
895
+ return response['parsed'].model_dump()
896
+ except Exception as e:
897
+ err_msg = str(e)
898
+ messages.append(SystemMessage((("error: " + err_msg[:10000] + '...' + err_msg[-2000:]) if len(err_msg)>=12000 else err_msg)))
899
+ if retries == max_retry -1 :
900
+ messages.append(SystemMessage("retry"))
901
+ else:
902
+ raise Exception ("retried too many times single model, switching to next llm model")
903
+ except Exception as e:
904
+ print(f"⚠️ Model {model_name} failed: {e}")
905
+ i_model += 1
906
+ err_msg = str(e)
907
+ messages.append(SystemMessage((("error: " + err_msg[:10000] + '...' + err_msg[-2000:]) if len(err_msg)>=12000 else err_msg)))
908
+ if i_model >= len(model_names):
909
+ raise Exception(f"All models ({model_names}) failed for this batch.") from e
910
+ # @memory.cache(ignore = ['model_names'])
911
+ @joblib_memory_cached(memory, ignore = ['model_names', 'event_name'])
912
+ def level_node_llm_parsing(
913
+ nodes_at_level: List[dict], # type: ignore
914
+ source_map: Dict,
915
+ full_document_json_str: str,
916
+ doc_id: str,
917
+ model_names: List[str],
918
+ event_name: str,
919
+ parsing_mode: Literal["snippet", "delimiter"] = "snippet"
920
+ ) -> LLMLevelResponse:
921
+ """
922
+ Processes an entire level of parent nodes in a single, batched, context-aware LLM call.
923
+ """
924
+ if not nodes_at_level:
925
+ return LLMLevelResponse.model_validate({"children":[]})
926
+ nodes_at_level: list[SemanticNode] = [SemanticNode.model_validate (i) for i in nodes_at_level]
927
+ # 1. Prepare the list of tasks for the LLM
928
+ parent_sections_for_prompt = []
929
+ for node in nodes_at_level:
930
+ parent_sections_for_prompt.append({
931
+ "parent_node_id": node.node_id,
932
+ "parent_title": node.title,
933
+ "text_to_analyze": reconstruct_text_from_pointers(node.total_content_pointers, source_map)
934
+ })
935
+ parent_node_id_set = set(str(i.node_id) for i in nodes_at_level)
936
+
937
+ # 2. Construct the full, context-aware prompt
938
+ if parsing_mode == "delimiter":
939
+ prompt_template = PROMPT_BATCH_SUBDIVIDER_DELIMITER
940
+ else:
941
+ prompt_template = PROMPT_BATCH_SUBDIVIDER
942
+
943
+ final_prompt = prompt_template.substitute(
944
+ full_document_json=full_document_json_str,
945
+ parent_sections_json=json.dumps([
946
+ {k: str(v) if isinstance(v, UUID) else v for k, v in p.items()}
947
+ for p in parent_sections_for_prompt
948
+ ], indent=2)
949
+ )
950
+ # 3. Use your robust LangChain invoker
951
+
952
+ messages: list[BaseMessage] = [
953
+ SystemMessage(
954
+ "You are a document parser. Output only JSON that matches the requested Pydantic schema. "
955
+ "Do not ask the user any questions. Do not answer like a general assistant. "
956
+ "Treat OCR text as data, not as an image request."
957
+ ),
958
+ HumanMessage(final_prompt),
959
+ ]
960
+ return retried_level_node_llm_parsing(model_names, nodes_at_level, messages, doc_id, event_name, parent_node_id_set)
961
+
962
+ from functools import lru_cache
963
+ @memory.cache
964
+ def get_node(pid, child_def, parent_level: int):
965
+ # child_def: Union[LLMChildNodeResponse, LLMChildNodeResponseBE].model_dump()
966
+ child_def_obj: LLMChildNodeResponseBE = LLMChildNodeResponseBE.model_validate(child_def)
967
+ absolute_pointers = child_def_obj.pointers
968
+ pointer_fp = "|".join(
969
+ f"{p.source_cluster_id}:{p.start_char}:{p.end_char}:{p.verbatim_text or ''}"
970
+ for p in absolute_pointers
971
+ )
972
+ child_node = SemanticNode(
973
+ node_id=child_def_obj.id or stable_id(
974
+ "legacy.get_node",
975
+ str(pid),
976
+ str(child_def_obj.node_type),
977
+ str(child_def_obj.title),
978
+ str(parent_level + 1),
979
+ pointer_fp,
980
+ ),
981
+ parent_id=pid,
982
+ title=child_def_obj.title,
983
+ node_type=child_def_obj.node_type,
984
+ total_content_pointers=absolute_pointers,
985
+ child_nodes = [],
986
+ level_from_root = parent_level +1,
987
+ # value_pointers=None # You would add logic to handle this
988
+ )
989
+ return child_node.model_dump()
990
+ @memory.cache
991
+ def get_root_node(title, source_map):
992
+ # root_node = SemanticNode(
993
+ # title=title,
994
+ # node_type="DOCUMENT_ROOT",
995
+ # total_content_pointers=[TextPointer(source_cluster_id=cid, start_char=0, end_char=-1) for cid in sorted(source_map.keys())]
996
+ # )
997
+ root_node = SemanticNode(
998
+ node_id=stable_id(
999
+ "legacy.root_node",
1000
+ str(title),
1001
+ "|".join(sorted(str(cid) for cid in source_map.keys())),
1002
+ ),
1003
+ title=title,
1004
+ node_type="DOCUMENT_ROOT",
1005
+ total_content_pointers=[
1006
+ HydratedTextPointer(
1007
+ source_cluster_id=cid,
1008
+ start_char=0,
1009
+ end_char=-1,
1010
+ verbatim_text=_source_map_entry_text(source_map[cid]),
1011
+ validation_method = None
1012
+ ) for cid in sorted(source_map.keys())
1013
+ ],
1014
+ child_nodes = [],
1015
+ level_from_root = 0,
1016
+ )
1017
+ return root_node.model_dump()
1018
+
1019
+ def _schema_guard(parent, child) -> bool:
1020
+ # Disallow KEY_VALUE_PAIR directly under KEY_VALUE_PAIR
1021
+ if parent.node_type == "KEY_VALUE_PAIR" and child.node_type == "KEY_VALUE_PAIR":
1022
+ return False
1023
+ return True
1024
+
1025
+ def _normalize_child_type(parent, child):
1026
+ if parent.node_type == "KEY_VALUE_PAIR" and child.node_type == "KEY_VALUE_PAIR":
1027
+ child.node_type = "TEXT_FLOW" # coerce value to text fragment
1028
+ return child
1029
+
1030
+ def build_document_tree(
1031
+ doc_id : str,
1032
+ llm_input_dict: Dict,
1033
+ source_map: Dict,
1034
+ max_depth: int = 10,
1035
+ allow_review = True,
1036
+ parsing_mode: Literal["snippet", "delimiter"] = "snippet",
1037
+ model_names: List[str] | None = None,
1038
+ ) -> SemanticNode:
1039
+ """Builds the hierarchy using an efficient, batched, layer-wise (BFS) approach.
1040
+ Initial breakdown -> check pointers/ spans validated
1041
+ -> CUD pass round get updated nodes
1042
+ next level
1043
+ output structure
1044
+ layers of nodes from coarse to fine grained
1045
+ """
1046
+ root_node : SemanticNode= SemanticNode.model_validate(get_root_node(title=llm_input_dict['document_filename'], source_map=source_map))
1047
+ # SemanticNode(
1048
+ # title=llm_input_dict['document_filename'],
1049
+ # node_type="DOCUMENT_ROOT",
1050
+ # total_content_pointers=[TextPointer(source_cluster_id=cid, start_char=0, end_char=-1) for cid in sorted(source_map.keys())]
1051
+ # )
1052
+ nodes_for_next_level: list[SemanticNode] = [root_node]
1053
+ current_depth = 0
1054
+ fixed_children: list[LLMChildNodeResponseBE]
1055
+ model_names = model_names or _default_parser_model_names()
1056
+ full_document_json_str = json.dumps(llm_input_dict)
1057
+ while nodes_for_next_level and current_depth < max_depth:
1058
+
1059
+ print(f"\nProcessing Level {current_depth} with {len(nodes_for_next_level)} nodes...")
1060
+
1061
+ nodes_at_this_level: list[SemanticNode] = nodes_for_next_level
1062
+ current_level_node_context_reset_token = current_level_nodes.set(nodes_at_this_level)
1063
+ nodes_for_next_level: list[SemanticNode] = []
1064
+ node_this_level_lookup_by_id = {str(node.node_id): node for node in nodes_at_this_level}
1065
+ # This is the single, batched call for the entire level
1066
+ llm_response_json = level_node_llm_parsing(
1067
+ [i.model_dump() for i in nodes_at_this_level],
1068
+ source_map,
1069
+ full_document_json_str,
1070
+ doc_id,
1071
+ model_names,
1072
+ "level_parsing",
1073
+ parsing_mode=parsing_mode
1074
+ )
1075
+ @joblib_memory_cached(memory)
1076
+ def get_level_response(llm_response_json) -> Dict[str, Any]:
1077
+ response_cacheable = LLMLevelResponseBE.model_validate(llm_response_json).model_dump() # only dumped version cacheable by joblib
1078
+ return response_cacheable
1079
+ response_cacheable = get_level_response(llm_response_json)
1080
+ level_response : LLMLevelResponseBE= LLMLevelResponseBE.model_validate(response_cacheable)
1081
+ # [{i.title + "|" + i.node_type: [j.verbatim_text for j in i.pointers]} for i in level_response.children]
1082
+ # correct excerpts
1083
+ corrected_children, unfixed_children = correct_level_children_with_iterative_pipeline(
1084
+
1085
+ level_response_json=level_response.model_dump(),
1086
+ source_map=source_map,
1087
+ full_document_json=llm_input_dict, # same dict you pass to the LLM
1088
+ doc_id=doc_id,
1089
+ model_names=model_names,
1090
+ # model_names=["gpt-4.1", "gpt-4o-mini"] # or keep your Gemini list; it’s pluggable
1091
+ )
1092
+
1093
+ corrected_children : list[LLMChildNodeResponseBE]
1094
+ unfixed_children: list[LLMChildNodeResponseBE]
1095
+ if unfixed_children:
1096
+ raise NotImplementedError("Not implemented for the case unfixed_children")
1097
+ fixed_children = corrected_children
1098
+
1099
+ # fixed_children -> for next level iteration use as root
1100
+ fe_children, layer_parent_types, layer_parent_sigs = prepare_frontend_children(nodes_at_this_level, level_response, fixed_children) # for next level of LLM
1101
+
1102
+ if allow_review:
1103
+ fixed_children, _reasoning_history= iterative_review_loop(fe_children, layer_parent_types, layer_parent_sigs, source_map,
1104
+ model_names, doc_id, full_document_json_str, current_depth, llm_input_dict, nodes_at_this_level)
1105
+ for ch in fixed_children:
1106
+ ch: LLMChildNodeResponseBE
1107
+ child_def = ch.model_dump()
1108
+ child_def.pop("id")
1109
+ node_dict = get_node(pid = ch.parent_node_id, child_def = child_def, parent_level = current_depth)
1110
+ child_node = SemanticNode.model_validate(node_dict)
1111
+ parent_node = node_this_level_lookup_by_id[str(ch.parent_node_id)]
1112
+ parent_node.child_nodes.append(child_node)
1113
+ if child_node.node_type == 'KEY_VALUE_PAIR':
1114
+ pass
1115
+ else:
1116
+ nodes_for_next_level.append(child_node)
1117
+ current_level_nodes.reset(current_level_node_context_reset_token)
1118
+ current_depth += 1
1119
+
1120
+ return root_node
1121
+ def prepare_frontend_children(nodes_at_this_level, level_response, fixed_children: List[LLMChildNodeResponseBE]):
1122
+ # RUN LLM loop make sure missing content will be guarded by LLM
1123
+ corrected_level_response = LLMLevelResponse.model_validate(level_response.model_dump())
1124
+
1125
+ # llm_response_json['children'] = [i.model_dump() for i in fixed_children]
1126
+ corrected_level_response.children = [LLMChildNodeResponse.model_validate(i.model_dump(field_mode='backend')) for i in fixed_children]
1127
+ child_map : dict[str, list[LLMChildNodeResponse]] = {}
1128
+ for child in corrected_level_response.children:
1129
+ if child_map.get(child.parent_node_id):
1130
+ child_map[child.parent_node_id].append(child)
1131
+ else:
1132
+ child_map[child.parent_node_id] = [child]
1133
+
1134
+ # Process the results and build the next level
1135
+ layer_parent_sigs = [] # data for simple sanity, non exhausitive non perfect check for duplication
1136
+ layer_parent_types = [] # data for simple sanity, non exhausitive non perfect check for duplication
1137
+ child_definitions: list[LLMChildNodeResponse] = []
1138
+ fe_children: List[LLMChildNodeResponse] = []
1139
+ for parent_node in nodes_at_this_level:
1140
+ child_definitions = child_map.get(str(parent_node.node_id), [])
1141
+
1142
+ # Convert backend -> frontend models for CUD
1143
+ fe_children.extend([
1144
+ LLMChildNodeResponse.model_validate(c.model_dump())
1145
+ for c in child_definitions
1146
+ ])
1147
+ # Build layer signatures. If you truly want the whole layer, pass nodes_at_this_level.
1148
+ # If you only want the current parent, pass [parent_node].
1149
+ layer_parent_sigs.extend(build_parent_signatures([parent_node]))
1150
+ layer_parent_types.extend([t for (t, _, _) in layer_parent_sigs])
1151
+
1152
+ # Guards BEFORE CUD
1153
+ # [(not reject_self_recursion_multi(layer_parent_types, ch), not_self_like_multi(layer_parent_sigs, ch)) for ch in fe_children]
1154
+ fe_children = [
1155
+ ch for ch in fe_children
1156
+ if not reject_self_recursion_multi(layer_parent_types, ch)
1157
+ and not_self_like_multi(layer_parent_sigs, ch)
1158
+ ]
1159
+ fe_children = dedupe_children_level(fe_children)
1160
+ return fe_children, layer_parent_types, layer_parent_sigs
1161
+
1162
+ def iterative_review_loop(fe_children: List[LLMChildNodeResponse], layer_parent_types, layer_parent_sigs, source_map,
1163
+ model_names,
1164
+ doc_id: str,
1165
+ full_document_json_str,
1166
+ current_depth,
1167
+ llm_input_dict,
1168
+ nodes_at_this_level: list[SemanticNode]):
1169
+ """_summary_
1170
+
1171
+ Args:
1172
+ fe_children (_type_): _description_ => the children parsed so far
1173
+ layer_parent_types (_type_): _description_ => the layer text heading signature for quick dedupe detection / printing
1174
+ layer_parent_sigs (_type_): _description_ => the layer text heading signature for quick dedupe detection / printing
1175
+ source_map (_type_): _description_ => p1c1 p2c2 etc usually refer to the bounding box id
1176
+ model_names (_type_): _description_ => what model to LLM
1177
+ full_document_json_str (_type_): _description_ => the doc_or_docgroup ocr data
1178
+ current_depth (_type_): _description_ => how many layers of parsing (tree levels) so far
1179
+ llm_input_dict (_type_): _description_ => LLM model specific parameters
1180
+
1181
+ Raises:
1182
+ NotImplementedError: _description_
1183
+
1184
+ Returns:
1185
+ _type_: _description_
1186
+ """
1187
+ # --- NEW: CUD iterative refinement loop ---
1188
+ # iteratively edit to cover most content if missing or remove duplicate/ conflicting ideas,
1189
+ # after the loop, at the end, just like the initial run, have to check the verbatim/excepts really exists
1190
+ # and correct check the except really exists again
1191
+
1192
+ edited = False
1193
+ proposals = True
1194
+ retries = 0
1195
+ max_retry = 3
1196
+ CUD_reasoning_history = []
1197
+ while proposals and retries < max_retry:
1198
+ # (1) per-level dedupe
1199
+ fe_children = [
1200
+ ch for ch in fe_children
1201
+ if not reject_self_recursion_multi(layer_parent_types, ch)
1202
+ and not_self_like_multi(layer_parent_sigs, ch)
1203
+ ]
1204
+ fe_children = dedupe_children_level(fe_children)
1205
+
1206
+ # (2) request one CUD round
1207
+ # token = current_level_nodes.set(nodes_at_this_level)
1208
+
1209
+
1210
+ proposals_response = CUD_proposal(
1211
+ # parent_id=str(parent_node.node_id),
1212
+ children=fe_children,
1213
+ source_map=source_map,
1214
+ model_names=model_names,
1215
+ reasoning_history = CUD_reasoning_history,
1216
+ full_document_json_str = full_document_json_str if current_depth > 0 else "[Now at root level, root node content is full doc, omitted to prevent duplication]",
1217
+ doc_id = doc_id,
1218
+ last_layer = [{"node_id": str(node.node_id),
1219
+ "content": reconstruct_text_from_pointers(node.total_content_pointers, source_map)}
1220
+ for node in nodes_at_this_level],
1221
+ attempt = retries
1222
+ )
1223
+ CUD_reasoning_history.append({"role": "ai_assistant", 'content': proposals_response.reasoning})
1224
+ # current_level_nodes.reset(token)
1225
+ if not (proposals_response.is_empty()):#
1226
+ break
1227
+ edited = True
1228
+ # (3) apply proposals (ADD/DELETE/EDIT → pointer revalidation)
1229
+ fe_children, err_messages = apply_proposal(
1230
+ proposals=proposals_response.get_proposals(),
1231
+ children=fe_children,
1232
+ source_map=source_map,
1233
+ )
1234
+ if err_messages:
1235
+ CUD_reasoning_history.append({"role": "system", 'content': err_messages})
1236
+ fe_children_tft = [{i.title + "|" + i.node_type: [j.verbatim_text for j in i.pointers]} for i in fe_children]
1237
+ fe_children = [
1238
+ ch for ch in fe_children
1239
+ if not reject_self_recursion_multi(layer_parent_types, ch)
1240
+ and not_self_like_multi(layer_parent_sigs, ch)
1241
+ ]
1242
+ fe_children = dedupe_children_level(fe_children)
1243
+ retries += 1
1244
+ # --- END CUD loop --- then post CUD validate below
1245
+ if not fe_children or (not edited):
1246
+ return [LLMChildNodeResponseBE.model_validate(i) for i in fe_children] , CUD_reasoning_history# no children even after re-check in iterative pipeline, time to early stop
1247
+ # Create SemanticNode children for this parent
1248
+ else:
1249
+ be_children = []
1250
+ for ch in fe_children:
1251
+ temp = ch.model_dump()
1252
+ temp['node_id'] = get_node(pid = ch.parent_node_id, child_def = ch.model_dump(), parent_level= current_depth)['node_id']
1253
+ be_children.append(LLMChildNodeResponseBE.model_validate(temp))
1254
+ corrected_level_response2 : LLMLevelResponseBE= LLMLevelResponseBE.model_validate({'children': be_children})
1255
+ # corrected_level_response2 : LLMLevelResponseBE= LLMLevelResponseBE.model_validate(get_level_response(llm_response_json))
1256
+
1257
+ # corrected_level_response2 : LLMLevelResponseBE= LLMLevelResponseBE.model_validate({'children': fe_children})
1258
+
1259
+ # correct excerpts again, this focus on edited indeed
1260
+ corrected_children, unfixed_children, *_ = correct_level_children_with_iterative_pipeline(
1261
+ level_response_json=corrected_level_response2.model_dump(),
1262
+ source_map=source_map,
1263
+ doc_id = doc_id,
1264
+ full_document_json=llm_input_dict, # same dict you pass to the LLM
1265
+ model_names=model_names,
1266
+ # model_names=["gpt-4.1", "gpt-4o-mini"] # or keep your Gemini list; it’s pluggable
1267
+ )
1268
+
1269
+ corrected_children : list[LLMChildNodeResponseBE]
1270
+ if unfixed_children:
1271
+ raise NotImplementedError("Not implemented for the case unfixed_children")
1272
+ # ids = [str(c.id) for c in corrected_children]
1273
+ # corrected_children_map: dict[str, LLMChildNodeResponseBE] = {str(i.id) : i for i in corrected_children}
1274
+ fixed_children: list[LLMChildNodeResponseBE] = corrected_children #[corrected_children_map[str(i)] for i in ids]
1275
+ return fixed_children, CUD_reasoning_history
1276
+
1277
+ from typing import Dict, List, Optional, Tuple, Callable, Iterable
1278
+ import re, json
1279
+ from uuid import UUID
1280
+
1281
+ from pydantic import BaseModel, ValidationError
1282
+
1283
+
1284
+ # ============================================================================
1285
+ # Utilities — deterministic, no‑LLM fixes first
1286
+ # ============================================================================
1287
+
1288
+ def _safe_slice(text: str, start: int, end_inclusive: int) -> str:
1289
+ end_excl = len(text) if end_inclusive == -1 else end_inclusive + 1
1290
+ if start < 0:
1291
+ start = 0
1292
+ if end_excl < 0:
1293
+ end_excl = 0
1294
+ return text[start:end_excl]
1295
+
1296
+
1297
+ def _all_exact_occurrences(haystack: str, needle: str) -> List[Tuple[int, int]]:
1298
+ """Return all (start, end_inclusive) exact matches for `needle` in `haystack`.
1299
+ Uses Python's find() loop for speed and determinism. Empty needle => none.
1300
+ """
1301
+ if not needle:
1302
+ return []
1303
+ out: List[Tuple[int, int]] = []
1304
+ i = 0
1305
+ L = len(needle)
1306
+ while True:
1307
+ i = haystack.find(needle, i)
1308
+ if i == -1:
1309
+ break
1310
+ out.append((i, i + L - 1))
1311
+ i += max(1, L)
1312
+ return out
1313
+
1314
+
1315
+ def _best_occurrence_by_proximity(
1316
+ occurrences: List[Tuple[int, int]], proposed_start: int
1317
+ ) -> Optional[Tuple[int, int]]:
1318
+ if not occurrences:
1319
+ return None
1320
+ return min(occurrences, key=lambda ab: abs(ab[0] - (proposed_start or 0)))
1321
+
1322
+
1323
+ def _whitespace_collapse(s: str) -> str:
1324
+ return re.sub(r"\s+", " ", s.strip())
1325
+
1326
+
1327
+ def _soft_exact_positions(
1328
+ source_text: str,
1329
+ verbatim: str,
1330
+ *,
1331
+ fuzzy_threshold: Optional[float] = None, # 0..100 if rapidfuzz; 0..1 with difflib (we normalize to 0..100)
1332
+ fuzzy_len_stretch: float = 0.25, # allow window length to vary ±25%
1333
+ fuzzy_stride_frac: float = 0.10, # stride as a fraction of |verbatim|
1334
+ ) -> Tuple[List[Tuple[int, int]], Dict | None]:
1335
+ """Exact match with minimal sanitation; optional fuzzy fallback.
1336
+ Returns list of candidate (start, end_incl) positions.
1337
+ """
1338
+ # 1) raw exact
1339
+ occ = _all_exact_occurrences(source_text, verbatim)
1340
+ if occ:
1341
+ return occ, {"name": "exact", "collapsed": False}
1342
+
1343
+ # 2) whitespace-collapsed exact (build collapsed and span map)
1344
+ v2 = _whitespace_collapse(verbatim)
1345
+ if not v2:
1346
+ return [], None
1347
+ spans: List[Tuple[int, int]] = [] # (orig_start, orig_end_incl) per collapsed char
1348
+ collapsed_chars = []
1349
+ i = 0
1350
+ N = len(source_text)
1351
+ while i < N:
1352
+ if source_text[i].isspace():
1353
+ j = i
1354
+ while j < N and source_text[j].isspace():
1355
+ j += 1
1356
+ if collapsed_chars and collapsed_chars[-1] == " ":
1357
+ # already collapsed previous WS run; just advance
1358
+ pass
1359
+ else:
1360
+ collapsed_chars.append(" ")
1361
+ spans.append((i, j - 1))
1362
+ i = j
1363
+ else:
1364
+ collapsed_chars.append(source_text[i])
1365
+ spans.append((i, i))
1366
+ i += 1
1367
+
1368
+ collapsed_text = "".join(collapsed_chars)
1369
+ occ2 = _all_exact_occurrences(collapsed_text, v2)
1370
+ if occ2:
1371
+ mapped: List[Tuple[int, int]] = []
1372
+ for s_idx, e_idx in occ2:
1373
+ mapped.append((spans[s_idx][0], spans[e_idx][1]))
1374
+ return mapped, {"name": "exact", "collapsed": True}
1375
+
1376
+ # 3) Optional fuzzy fallback on collapsed_text
1377
+ if fuzzy_threshold is None:
1378
+ return []
1379
+
1380
+ # --- helper to map collapsed [s,e] -> original inclusive span
1381
+ def _map_back(s_idx: int, e_idx: int) -> Tuple[int, int]:
1382
+ try:
1383
+ _ = spans[s_idx][0], spans[e_idx][1]
1384
+ except Exception as _e:
1385
+ pass # for debugger see what happen
1386
+ return spans[s_idx][0], spans[e_idx][1]
1387
+
1388
+ Lq = len(v2)
1389
+ if Lq == 0 or len(collapsed_text) == 0:
1390
+ return [], None
1391
+
1392
+ # Normalize threshold to 0..100
1393
+ thr = float(fuzzy_threshold)
1394
+ if thr <= 1.0: # user may pass 0..1 when thinking "difflib ratio"
1395
+ thr *= 100.0
1396
+
1397
+ best_score = -1.0
1398
+ best_span = None # (s_idx, e_idx)
1399
+
1400
+ # --- RapidFuzz path (fast): use LCS ratio as a cheap, positionable proxy ---
1401
+ # if _HAVE_RAPIDFUZZ:
1402
+ # We search windows near the query length (±stretch).
1403
+ Lmin = max(1, int(math.floor(Lq * (1.0 - fuzzy_len_stretch))))
1404
+ Lmax = max(Lmin, int(math.ceil(Lq * (1.0 + fuzzy_len_stretch))))
1405
+ stride = max(1, int(max(1, round(Lq * fuzzy_stride_frac))))
1406
+
1407
+ for wlen in (Lmin, Lq, Lmax):
1408
+ if wlen > len(collapsed_text):
1409
+ continue
1410
+ for s in range(0, len(collapsed_text) - wlen + 1, stride):
1411
+ e = s + wlen # exclusive
1412
+ # LCSseq.ratio returns 0..100
1413
+ score = LCSseq.normalized_similarity(v2, collapsed_text[s:e]) * 100.0
1414
+ if score > best_score:
1415
+ best_score = score
1416
+ best_span = (s, e - 1)
1417
+
1418
+ if best_span and best_score >= thr:
1419
+ orig_s, orig_e = _map_back(*best_span)
1420
+ return [(orig_s, orig_e)], {"name" : "LCSseq.normalized_similarity", "threshold": thr, "collapsed": True}
1421
+ # --- True RapidFuzz path (fast): use LCS ratio as a cheap, positionable proxy ---
1422
+ import difflib
1423
+ def locate_span(query: str, text: str):
1424
+ sm = difflib.SequenceMatcher(
1425
+ None,
1426
+ text.lower(),
1427
+ query.lower()
1428
+ )
1429
+ match = max(sm.get_matching_blocks(), key=lambda m: m.size)
1430
+ if match.size == 0:
1431
+ return None
1432
+ #end_inclusive span need converted to python like index style
1433
+ return match.a, match.a + match.size-1
1434
+ best_span = locate_span(verbatim, collapsed_text)
1435
+ from rapidfuzz import fuzz
1436
+ if best_span:
1437
+ best_score = fuzz.ratio(verbatim, collapsed_text[best_span[0]: best_span[1]])
1438
+ if best_span and best_score >= thr:
1439
+ orig_s, orig_e = _map_back(*best_span)
1440
+ return [(orig_s, orig_e)], {"name" : "difflib.SequenceMatcher", "threshold": thr, "collapsed": True}
1441
+ best_span = locate_span(verbatim, source_text)
1442
+ from rapidfuzz import fuzz
1443
+ if best_span:
1444
+ best_score = fuzz.ratio(verbatim, collapsed_text[best_span[0]: best_span[1]])
1445
+ if best_span and best_score >= thr:
1446
+ return [best_span], {"name" : "difflib.SequenceMatcher", "threshold": thr, "collapsed": False}
1447
+
1448
+ return [], None
1449
+
1450
+
1451
+ # ============================================================================
1452
+ # Pointer correction — deterministic tier
1453
+ # ============================================================================
1454
+
1455
+ def resolve_delimiter_pointer(
1456
+ pointer: HydratedTextPointer,
1457
+ source_map: Dict,
1458
+ ) -> Optional[HydratedTextPointer]:
1459
+ """
1460
+ Resolves a pointer using start/end delimiters.
1461
+ Raises ValueError if delimiters are ambiguous or not found.
1462
+ Returns a NEW pointer with start_char/end_char/verbatim_text populated.
1463
+ """
1464
+ if not pointer.start_delimiter or not pointer.end_delimiter:
1465
+ return None
1466
+
1467
+ cluster = source_map.get(pointer.source_cluster_id)
1468
+ if not cluster:
1469
+ # Fallback logic for cluster ID resolution could go here if needed
1470
+ return None
1471
+
1472
+ text = _source_map_entry_text(cluster)
1473
+ if not text:
1474
+ return None
1475
+
1476
+ # Find start
1477
+ start_matches = _all_exact_occurrences(text, pointer.start_delimiter)
1478
+ if not start_matches:
1479
+ # Try soft match? For now strict as per requirements "unique".
1480
+ # Maybe "let llm select multiple given longer context" implies we need to handle non-unique by failing?
1481
+ raise ValueError(f"Start delimiter '{pointer.start_delimiter}' not found in cluster '{pointer.source_cluster_id}'")
1482
+ if len(start_matches) > 1:
1483
+ raise ValueError(f"Start delimiter '{pointer.start_delimiter}' is not unique (found {len(start_matches)} times)")
1484
+
1485
+ start_idx = start_matches[0][0] # start of start_delimiter
1486
+
1487
+ # Find end
1488
+ # We search for end delimiter AFTER start index?
1489
+ # Or globally unique? Requirement says "unique within the parent's context".
1490
+ # Assuming unique globally in the cluster for safety.
1491
+ end_matches = _all_exact_occurrences(text, pointer.end_delimiter)
1492
+ if not end_matches:
1493
+ raise ValueError(f"End delimiter '{pointer.end_delimiter}' not found in cluster '{pointer.source_cluster_id}'")
1494
+ if len(end_matches) > 1:
1495
+ # If multiple, pick the first one after start_idx?
1496
+ # But requirements say "raise error if the delimiter is not unique".
1497
+ # This usually applies to the delimiter string itself.
1498
+ raise ValueError(f"End delimiter '{pointer.end_delimiter}' is not unique (found {len(end_matches)} times)")
1499
+
1500
+ end_idx = end_matches[0][1] # end (inclusive) of end_delimiter
1501
+
1502
+ if end_idx < start_idx:
1503
+ raise ValueError(f"End delimiter occurs before Start delimiter")
1504
+
1505
+ verbatim_text = _safe_slice(text, start_idx, end_idx)
1506
+
1507
+ return HydratedTextPointer(
1508
+ source_cluster_id=pointer.source_cluster_id,
1509
+ start_char=start_idx,
1510
+ end_char=end_idx,
1511
+ verbatim_text=verbatim_text,
1512
+ start_delimiter=pointer.start_delimiter,
1513
+ end_delimiter=pointer.end_delimiter,
1514
+ validation_method="delimiter_exact"
1515
+ )
1516
+
1517
+ def correct_and_validate_pointer(
1518
+ proposed_pointer: HydratedTextPointer,
1519
+ source_map: Dict,
1520
+ ) -> Optional[HydratedTextPointer]:
1521
+ """Deterministic multi‑step correction. Returns fixed pointer or None.
1522
+
1523
+ Steps:
1524
+ TIER 0: Delimiter resolution if applicable.
1525
+ TIER 1: Trust‑but‑verify using proposed indices.
1526
+ TIER 2: Exact search for verbatim (raw, then whitespace‑collapsed).
1527
+ If multiple matches, pick the one closest to proposed start.
1528
+ """
1529
+
1530
+ # --- TIER 0: Delimiter Mode ---
1531
+ if proposed_pointer.start_delimiter and proposed_pointer.end_delimiter:
1532
+ try:
1533
+ resolved = resolve_delimiter_pointer(proposed_pointer, source_map)
1534
+ if resolved:
1535
+ return resolved
1536
+ except ValueError as e:
1537
+ print(f"🚨 Delimiter Resolution Error: {e}")
1538
+ return None # Or propagate error? Returning None causes it to be added to "unresolved" which triggers LLM retry.
1539
+
1540
+ # Ensure verbatim_text is present for legacy logic
1541
+ if not proposed_pointer.verbatim_text:
1542
+ # If no delimiters and no verbatim text, we can't do anything
1543
+ print("🚨 REJECTED: Pointer missing both delimiters and verbatim_text.")
1544
+ return None
1545
+
1546
+ ids = list(source_map.keys())
1547
+ ids_same_page, id_dif_page = partition(ids, predicate = lambda x: x.split("_")[0] == proposed_pointer.source_cluster_id.split('_')[0])
1548
+ _, ids_same_page_dif_cluster = partition(ids_same_page, predicate = lambda x: x == proposed_pointer.source_cluster_id)
1549
+ # Try some heuristic possible hallucinated cluster ids
1550
+ verification_method = None
1551
+ for _i_source_cluster, source_cluster in enumerate([source_map.get(proposed_pointer.source_cluster_id)] + \
1552
+ [source_map.get(i) for i in ids_same_page_dif_cluster + id_dif_page]):
1553
+ validation_method = None
1554
+ if not source_cluster:
1555
+ # print(
1556
+ # f"⚠️ REJECTED: Pointer references non‑existent cluster '{proposed_pointer.source_cluster_id}'."
1557
+ # )
1558
+ # return None
1559
+ continue
1560
+
1561
+ source_text: str = source_cluster["text"]
1562
+
1563
+ # --- TIER 1: verify current indices
1564
+ try:
1565
+ actual = _safe_slice(
1566
+ source_text, proposed_pointer.start_char, proposed_pointer.end_char
1567
+ )
1568
+ # Safe access to verbatim_text (we checked it's not None above)
1569
+ if normalize_text(actual) == normalize_text(proposed_pointer.verbatim_text or ""):
1570
+ return proposed_pointer
1571
+ except Exception:
1572
+ pass
1573
+ # try ast
1574
+ import ast
1575
+ candidates = []
1576
+ try:
1577
+ quotes = ["'''", "'", '"""', '"']
1578
+ for quote in quotes:
1579
+ try:
1580
+ verbatim =ast.literal_eval(quote + (proposed_pointer.verbatim_text or "") + quote)
1581
+ except:
1582
+ continue
1583
+ source_cluster_text = _source_map_entry_text(source_cluster)
1584
+ if source_cluster_text and verbatim in source_cluster_text:
1585
+ candidates, verification_method = _soft_exact_positions(source_text, verbatim)
1586
+ if candidates:
1587
+ validation_method = verification_method
1588
+ pass
1589
+ except:
1590
+ pass
1591
+ # --- TIER 2: search for verbatim (raw then WS‑collapsed)
1592
+ if not candidates:
1593
+ verbatim = proposed_pointer.verbatim_text or ""
1594
+ # case len(verbatim):
1595
+ vlen = len(verbatim)
1596
+ if 0 <= vlen <= 10:
1597
+ fuzzy_threshold = None
1598
+ elif 11 <= vlen <= 20:
1599
+ fuzzy_threshold = 0.99
1600
+ elif 21 <= vlen <= 50:
1601
+ fuzzy_threshold = 0.95
1602
+ elif 51 <= vlen <= 100:
1603
+ fuzzy_threshold = 0.90
1604
+ else:
1605
+ fuzzy_threshold = 0.85
1606
+ candidates, verification_method = _soft_exact_positions(source_text, verbatim, fuzzy_threshold = fuzzy_threshold)
1607
+ if candidates:
1608
+ validation_method = verification_method
1609
+ if candidates:
1610
+ best = _best_occurrence_by_proximity(candidates, proposed_pointer.start_char)
1611
+ if best:
1612
+ s, e = best
1613
+ return HydratedTextPointer(
1614
+ source_cluster_id= source_cluster['id'], #proposed_pointer.source_cluster_id,
1615
+ start_char=s,
1616
+ end_char=e,
1617
+ verbatim_text=_safe_slice(source_text, s, e),
1618
+ validation_method = json.dumps(validation_method if verification_method else None)
1619
+ )
1620
+
1621
+ print(
1622
+ f"🚨 REJECTED: Unrecoverable pointer in cluster '{proposed_pointer.source_cluster_id}' for text '{(proposed_pointer.verbatim_text or '')[:140]}...'"
1623
+ )
1624
+ return None
1625
+
1626
+
1627
+ # ============================================================================
1628
+ # Child correction — run deterministic tier for every pointer in a child
1629
+ # ============================================================================
1630
+
1631
+ def _correct_child_deterministic(
1632
+ child: LLMChildNodeResponseBE, source_map: Dict, with_coverage_check = True
1633
+ ) -> Tuple[Optional[LLMChildNodeResponseBE], List[HydratedTextPointer]]:
1634
+ """Attempt to fix all pointers deterministically. Returns (fixed_child, unresolved_pointers).
1635
+ If at least one pointer is unrecoverable deterministically, include it in unresolved list.
1636
+ If *all* pointers are fixed, returns the fully corrected child and empty unresolved list.
1637
+ """
1638
+ fixed_pointers: List[HydratedTextPointer] = []
1639
+ unresolved: List[HydratedTextPointer] = []
1640
+
1641
+ for p in child.pointers:
1642
+ ok = correct_and_validate_pointer(p, source_map)
1643
+ if ok is None:
1644
+ unresolved.append(p)
1645
+ else:
1646
+ fixed_pointers.append(ok)
1647
+
1648
+ if unresolved:
1649
+ return None, unresolved
1650
+
1651
+ # all good
1652
+ return (
1653
+ LLMChildNodeResponseBE(
1654
+ id = child.id,
1655
+ parent_node_id=child.parent_node_id,
1656
+ node_type=child.node_type,
1657
+ title=child.title,
1658
+ pointers=fixed_pointers,
1659
+ # value_pointers=None, # adjust if you also want to fix value_pointers
1660
+ ),
1661
+ [],
1662
+ )
1663
+
1664
+ from collections import namedtuple
1665
+ from typing import NamedTuple
1666
+
1667
+ class ChildrenCorrectionResult(NamedTuple):
1668
+ fixed_children: list[LLMChildNodeResponseBE]
1669
+ pending_fix_children: list[LLMChildNodeResponseBE]
1670
+
1671
+ # ============================================================================
1672
+ # LLM batch correction wiring (pluggable)
1673
+ # ============================================================================
1674
+ from typing import Protocol, Type, Any, List, TypeVar
1675
+
1676
+ T = TypeVar("T", bound=BaseModel)
1677
+
1678
+ class StructuredLLMCaller(Protocol):
1679
+ def __call__(
1680
+ self,
1681
+ prompt: str,
1682
+ model_names: List[str],
1683
+ schema: Type[T],
1684
+ doc_id: str,
1685
+ model_json_schema: dict,
1686
+ event_name: str,
1687
+ i_attempt: int
1688
+ ) -> T:
1689
+ ...
1690
+
1691
+ @memory.cache(ignore = ['model_names', 'schema', 'event_name'])
1692
+ def _default_call_llm_structured(
1693
+ prompt: str, model_names: List[str], schema: type[T] ,doc_id: str, model_json_schema : dict, event_name: str, i_attempt: int
1694
+ ) -> T:
1695
+ """Default implementation using LangChain Google Generative AI stack.
1696
+ Swap this out if you prefer OpenAI or another provider.
1697
+ """
1698
+ from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage
1699
+
1700
+ messages: List[BaseMessage] = [HumanMessage(prompt)]
1701
+ last_err = None
1702
+ max_retry_per_model = 2
1703
+ for name in model_names:
1704
+ for i in range(max_retry_per_model):
1705
+ import inspect
1706
+ cf = inspect.currentframe()
1707
+ line_no = cf.f_lineno if cf else None
1708
+ try:
1709
+ llm = get_llm(name)
1710
+ resp: dict = llm.with_structured_output(schema, include_raw=True).invoke(messages,
1711
+ config={
1712
+ "metadata": {
1713
+ "document_id": doc_id,
1714
+ "event_name": event_name,
1715
+ "source_filename": __file__,
1716
+ "line_number": line_no,
1717
+ "n_try": i}
1718
+ }
1719
+ ) # type: ignore
1720
+ if resp.get("parsing_error"):
1721
+ raise resp["parsing_error"]
1722
+ return schema.model_validate(resp["parsed"].model_dump())
1723
+
1724
+ except Exception as e: # noqa: BLE001
1725
+ last_err = e
1726
+ str_err = str(e)
1727
+ messages.append(SystemMessage(f"previous_error: {str_err[:2000] + str_err[-2000:]}"))
1728
+ continue
1729
+ raise RuntimeError(f"All models failed. Last error: {last_err}")
1730
+
1731
+
1732
+ # ============================================================================
1733
+ # Iterative level correction orchestrator
1734
+ # ============================================================================
1735
+ T2 = TypeVar("T2", bound=BaseModel)
1736
+ from typing import Any, TypeVar, overload
1737
+
1738
+
1739
+ @memory.cache(ignore = ['call_llm_structured', 'max_rounds', 'model_names'])
1740
+ def iterative_correct_children_for_level(
1741
+ children: List[LLMChildNodeResponseBE],
1742
+ source_map: Dict,
1743
+ full_document_json: Dict,
1744
+ model_names: List[str] | None = None,
1745
+ max_rounds: int = 3,
1746
+ doc_id: str | None = None,
1747
+ call_llm_structured: StructuredLLMCaller = _default_call_llm_structured,
1748
+ ) -> ChildrenCorrectionResult: # List[List[LLMChildNodeResponseBE]]:
1749
+ """Main entry: fix child pointers at a layer.
1750
+
1751
+ Strategy:
1752
+ 1) Deterministic pass: try to fix each child locally.
1753
+ 2) Batch LLM pass for *only* the unresolved children.
1754
+ Repeat up to `max_rounds` until convergence or no unresolved.
1755
+ """
1756
+ if doc_id is None:
1757
+ raise Exception("Missing doc_id")
1758
+ model_names = model_names or _default_parser_model_names()
1759
+
1760
+ fixed: Dict[str, LLMChildNodeResponseBE] = {}
1761
+ pending: Dict[str, LLMChildNodeResponseBE] = {f"{i.parent_node_id}|{i.title}": i for i in children}
1762
+ still_unsolved_same_cnt = 0
1763
+ pending_length_history = []
1764
+ for round_idx in range(max_rounds):
1765
+ if not pending:
1766
+ break
1767
+ pending_length_history.append({'round_idx': round_idx, "stage":'pre-deterministic', 'length_pending': len(pending), "still_unsolved_same_cnt": still_unsolved_same_cnt})
1768
+ # ----- 1) deterministic pass
1769
+ still_unresolved: Dict[str, LLMChildNodeResponseBE] = {}
1770
+ for key, child in list(pending.items()):
1771
+ ok_child, unresolved_pointers = _correct_child_deterministic(child, source_map, with_coverage_check = True)
1772
+ if ok_child is not None:
1773
+ fixed[key] = ok_child
1774
+ else:
1775
+ still_unresolved[key] = child
1776
+ if len(pending) == len(still_unresolved):
1777
+ still_unsolved_same_cnt += 1
1778
+ else:
1779
+ still_unsolved_same_cnt = 0
1780
+ pending = still_unresolved
1781
+ if not pending:
1782
+ break # short circuit if all resovled correctly
1783
+ pending_length_history.append({'round_idx': round_idx, "stage":'pre-llm-correct', 'length_pending': len(pending), "still_unsolved_same_cnt": still_unsolved_same_cnt})
1784
+ # ----- 2) LLM batch pass over *only* unresolved children
1785
+ nodes_to_correct = [
1786
+ {
1787
+ "parent_node_id": c.parent_node_id,
1788
+ "node_type": c.node_type,
1789
+ "title": c.title,
1790
+ "pointers": [p.model_dump() for p in c.pointers],
1791
+ }
1792
+ for c in pending.values()
1793
+ ]
1794
+ prompt = PROMPT_POINTER_CORRECTION.format(
1795
+ full_document_json=json.dumps(full_document_json, ensure_ascii=False),
1796
+ nodes_to_correct_json=json.dumps(nodes_to_correct, ensure_ascii=False),
1797
+ )
1798
+ try:
1799
+ schema = LLMLevelResponse.model_json_schema()
1800
+ parsed: LLMLevelResponse = call_llm_structured(
1801
+ prompt, model_names, LLMLevelResponse, doc_id, schema, "correct_level_children_schema", still_unsolved_same_cnt
1802
+ )
1803
+ parsed_be = LLMLevelResponseBE.model_validate(parsed.model_dump())
1804
+ # validate each returned child again deterministically (trust but verify)
1805
+ returned_by_key: Dict[str, LLMChildNodeResponseBE] = {}
1806
+ for ch in parsed_be.children:
1807
+ key = f"{ch.parent_node_id}|{ch.title}"
1808
+ ok_child, unresolved_pointers = _correct_child_deterministic(ch, source_map)
1809
+ if ok_child is not None and not unresolved_pointers:
1810
+ returned_by_key[key] = ok_child
1811
+ # merge fixes
1812
+ for k, v in returned_by_key.items():
1813
+ fixed[k] = v
1814
+ pending.pop(k, None)
1815
+ except Exception as e: # noqa: BLE001
1816
+ # LLM failed this round; keep items pending for next round or exit
1817
+ print(f"LLM correction round {round_idx+1} failed: {e}")
1818
+ # fall through; next round will retry or terminate
1819
+
1820
+ # Final set = fixed + whatever remains pending (keep originals for transparency)
1821
+
1822
+ # ChildrenCorrectionResult = namedtuple("ChildrenCorrectionResult", ["fixed_children", "pending_fix_children"])
1823
+ # 1) add fixed children first
1824
+ # 2) add unresolved originals (so caller can decide whether to drop/flag)
1825
+
1826
+ out = ChildrenCorrectionResult(fixed_children = list(fixed.values()) ,
1827
+ pending_fix_children= list(pending.values()))
1828
+ return out
1829
+ # ======== Minimal additions to support your CUD loop (matching your usage) ========
1830
+ from string import Template as _CUDTemplate
1831
+
1832
+ # ---------- Pydantic models for CUD ----------
1833
+
1834
+ from typing import Optional, List, Literal, Dict, Any, Tuple, Union
1835
+
1836
+ # --- how to select an existing child in THIS layer ---
1837
+ class UDTarget(BaseModel):
1838
+ node_id: str = Field(description="Optional direct child id if present in your FE objects.")
1839
+ node_type: Optional[Literal["TEXT_FLOW", "KEY_VALUE_PAIR", "TABLE"]] = None
1840
+ title: Optional[str] = None
1841
+
1842
+ @model_validator(mode="after")
1843
+ def _at_least_one_selector(self):
1844
+ if not (self.node_id or (self.node_type and self.title is not None)):
1845
+ raise ValueError("UDTarget requires either node_id OR (node_type AND title).")
1846
+ return self
1847
+
1848
+
1849
+ # --- partial edit payload (only provided fields are changed) ---
1850
+ class LLMChildNodePatch(BaseModel):
1851
+ parent_node_id: Optional[str] = None
1852
+ node_type: Optional[Literal["TEXT_FLOW", "KEY_VALUE_PAIR", "TABLE"]] = None
1853
+ title: Optional[str] = None
1854
+ pointers: Optional[List[HydratedTextPointer]] = None
1855
+
1856
+ class LLMChildNodeAdd(BaseModel):
1857
+ parent_node_id: str
1858
+ node_type: Literal["TEXT_FLOW", "KEY_VALUE_PAIR", "TABLE"]
1859
+ title: str
1860
+ pointers: List[HydratedTextPointer]
1861
+ # --- strictly typed proposal ---
1862
+ class CUDProposal(BaseModel):
1863
+ edit_type: Literal["ADD_NODE", "DELETE_NODE", "EDIT_NODE"]
1864
+ target: Optional[UDTarget] = Field(default=None, description="Target required for DELETE.")
1865
+ add: Optional[LLMChildNodeAdd] = Field(default=None, description="Strict child for ADD.")
1866
+ patch: Optional[LLMChildNodePatch] = Field(default=None, description="Partial patch for EDIT.")
1867
+ reasoning: str = Field(description="Reasoning for each proposal")
1868
+ @model_validator(mode="after")
1869
+ def _check_consistency(self):
1870
+ if self.edit_type == "ADD_NODE":
1871
+ if self.add is None:
1872
+ raise ValueError("ADD_NODE requires 'add'.")
1873
+ if self.target is not None or self.patch is not None:
1874
+ raise ValueError("ADD_NODE may not include 'target' or 'patch'.")
1875
+ elif self.edit_type == "DELETE_NODE":
1876
+ if self.target is None:
1877
+ raise ValueError("DELETE_NODE requires 'target'.")
1878
+ if self.add is not None or self.patch is not None:
1879
+ raise ValueError("DELETE_NODE may not include 'add' or 'patch'.")
1880
+ elif self.edit_type == "EDIT_NODE":
1881
+ if self.target is None or self.patch is None:
1882
+ raise ValueError("EDIT_NODE requires both 'target' and 'patch'.")
1883
+ if self.add is not None:
1884
+ raise ValueError("EDIT_NODE may not include 'add'.")
1885
+ return self
1886
+ class DProposal(BaseModel):
1887
+ edit_type: Literal["DELETE_NODE"]
1888
+ target: UDTarget = Field(..., description="Target required for DELETE existing node.")
1889
+ reasoning_delete : str = Field(..., description = "reason for delete")
1890
+ @model_validator(mode="after")
1891
+ def _check_consistency(self):
1892
+ if self.edit_type == "DELETE_NODE":
1893
+ if self.target is None:
1894
+ raise ValueError("DELETE_NODE requires 'target'.")
1895
+ else:
1896
+ raise(ValueError('unrecognized mode'))
1897
+ return self
1898
+ class UProposal(BaseModel):
1899
+ edit_type: Literal["EDIT_NODE"]
1900
+ target: UDTarget = Field(..., description="Target required for EDIT.")
1901
+ patch: LLMChildNodePatch = Field(..., description="Partial patch for EDIT existing node.")
1902
+ reasoning_update : str = Field(..., description = "reason for Update")
1903
+ @model_validator(mode="after")
1904
+ def _check_consistency(self):
1905
+ if self.edit_type == "EDIT_NODE":
1906
+ if self.target is None or self.patch is None:
1907
+ raise ValueError("EDIT_NODE requires both 'target' and 'patch'.")
1908
+ else:
1909
+ raise(ValueError('unrecognized mode'))
1910
+
1911
+ return self
1912
+ class CProposal(BaseModel):
1913
+ edit_type: Literal["ADD_NODE"]
1914
+ add: LLMChildNodeAdd = Field(..., description="Strict child for ADD or CREAT new node.")
1915
+ reasoning_create : str = Field(..., description = "reason for Create")
1916
+ @model_validator(mode="after")
1917
+ def _check_consistency(self):
1918
+ if self.edit_type == "ADD_NODE":
1919
+ if self.add is None:
1920
+ raise ValueError("ADD_NODE requires 'add'.")
1921
+ else:
1922
+ raise(ValueError('unrecognized mode'))
1923
+ return self
1924
+
1925
+ class CUDResponse(ModeSlicingMixin, BaseModel):
1926
+ default_include_modes: ClassVar= {"frontend", "llm", "backend", "dto"}
1927
+ default_exclude_modes: ClassVar = set()
1928
+ include_unmarked_for_modes: ClassVar = {"dto", "frontend", "backend", "llm"}
1929
+ reasoning:str = Field(description = 'reasoning at top level')
1930
+ cproposals: List[CProposal] = Field(default_factory=list, description = 'a list of create proposals, empty if existing is good. ')
1931
+ uproposals: List[UProposal] = Field(default_factory=list, description = 'a list of update proposals, empty if existing is good. ')
1932
+ dproposals: List[DProposal] = Field(default_factory=list, description = 'a list of delete proposals, empty if existing is good. ')
1933
+ def is_empty(self):
1934
+ return len(self.get_proposals()) > 0
1935
+ def get_proposals(self):
1936
+ return self.cproposals + self.uproposals + self.dproposals
1937
+ class CResponse(ModeSlicingMixin, BaseModel):
1938
+ default_include_modes: ClassVar= {"frontend", "llm", "backend", "dto"}
1939
+ default_exclude_modes: ClassVar = set()
1940
+ include_unmarked_for_modes: ClassVar = {"dto", "frontend", "backend", "llm"}
1941
+ reasoning:str = Field(description = 'reasoning at top level')
1942
+ reasoning:str = Field(description = 'reasoning at top level')
1943
+ proposals: List[CProposal] = Field(default_factory=list)
1944
+ def is_empty(self):
1945
+ return len(self.get_proposals()) > 0
1946
+ def get_proposals(self):
1947
+ return self.proposals
1948
+ # ---------- small helpers ----------
1949
+ def _normalize_title(s: str) -> str:
1950
+ return " ".join((s or "").lower().split())
1951
+
1952
+ def _pkey(p: HydratedTextPointer) -> tuple[str, int, int]:
1953
+ return (p.source_cluster_id, p.start_char, p.end_char)
1954
+
1955
+ def dedupe_children_level(children: List[LLMChildNodeResponse]) -> List[LLMChildNodeResponse]:
1956
+ """
1957
+ Per-level structural dedupe: same node_type + normalized title + identical pointer set.
1958
+ """
1959
+ seen, out = set(), []
1960
+ for ch in children:
1961
+ key = (
1962
+ ch.node_type,
1963
+ _normalize_title(ch.title),
1964
+ tuple(sorted(_pkey(p) for p in ch.pointers)),
1965
+ )
1966
+ if key in seen:
1967
+ continue
1968
+ seen.add(key)
1969
+ out.append(ch)
1970
+ return out
1971
+ # ----- LAYER-AWARE GUARDS -----
1972
+ def build_parent_signatures(parents: List[SemanticNode]) -> List[tuple[str, str, set]]:
1973
+ """
1974
+ For a layer of parents, return [(type, norm_title, span_set)] for each.
1975
+ span_set = set of (cluster, start, end).
1976
+ """
1977
+ sigs: List[tuple[str,str,set]] = []
1978
+ for p in parents:
1979
+ spans = {_pkey(ptr) for ptr in p.total_content_pointers}
1980
+ sigs.append((p.node_type, _normalize_title(p.title), spans))
1981
+ return sigs
1982
+
1983
+ def reject_self_recursion_multi(parent_types: List[str], child: LLMChildNodeResponse) -> bool:
1984
+ """
1985
+ Forbid KEY_VALUE_PAIR directly under any KEY_VALUE_PAIR parent in the layer.
1986
+ Extend with other schema rules if needed.
1987
+ """
1988
+ return child.node_type == "KEY_VALUE_PAIR" and ("KEY_VALUE_PAIR" in parent_types)
1989
+
1990
+ def not_self_like_multi(parent_sigs: List[tuple[str,str,set]], child: LLMChildNodeResponse) -> bool:
1991
+ """
1992
+ Reject if child is a mirror of ANY parent in the layer:
1993
+ same type AND same normalized title AND identical span set.
1994
+ """
1995
+ child_title = _normalize_title(child.title)
1996
+ child_spans = {_pkey(p) for p in child.pointers}
1997
+ for p_type, p_title, p_spans in parent_sigs:
1998
+ if (child.node_type == p_type) and (child_title == p_title) and (child_spans == p_spans):
1999
+ return False
2000
+ return True
2001
+
2002
+ def _validate_child_pointers(child: LLMChildNodeResponse, source_map: Dict) -> Optional[LLMChildNodeResponse]:
2003
+ """
2004
+ Reuse your pointer correction: return a fixed child or None if any pointer can't be validated.
2005
+ Called after ADD/EDIT proposals to ensure trust-but-verify.
2006
+ """
2007
+ fixed_ptrs: List[HydratedTextPointer] = []
2008
+ for p in child.pointers:
2009
+ ok = correct_and_validate_pointer(p, source_map)
2010
+ if not ok:
2011
+ return None
2012
+ fixed_ptrs.append(ok)
2013
+
2014
+ # fixed_values: Optional[List[HydratedTextPointer]] = None
2015
+ # if getattr(child, "value_pointers", None):
2016
+ # fixed_values = []
2017
+ # for p in child.value_pointers:
2018
+ # ok = correct_and_validate_pointer(p, source_map)
2019
+ # if not ok:
2020
+ # return None
2021
+ # fixed_values.append(ok)
2022
+
2023
+ return child.model_copy(update={"pointers": fixed_ptrs,
2024
+ # "value_pointers": fixed_values
2025
+ })
2026
+
2027
+ # ---------- CUD prompting ----------
2028
+
2029
+ """
2030
+
2031
+ - When breaking down, such as breaking down [SCHEDULE 1] into its children, do not just breakdown into [SUB-SCHEDULE], but break into [SCHEDULE1]-[SUB-SCHEDULE],
2032
+ Unless the parent is already containing the overall heading of SCHEDULE 1. in this way the heading/ title for look up will not miss out useful search information.
2033
+ example:
2034
+ `{Item XX section yy section zz}` if it is very light weight item, you may keep it as it is, but if yy and zz are long, you need to break it into
2035
+ `item xx - section yy` and `item xx - section zz`. keep the bracking heading `item xx` when breaking down. It extend to not only item, can be schedule-subschedule, section-paragraph
2036
+ or even other structure substructure.
2037
+ if the previous already has a parent node just Item xx (not item xx, item aa together), in this case, you can omit the bracketing header and just put section yy and section zz in children.
2038
+
2039
+
2040
+ ;
2041
+ or break down emulated by 1. edit the too-broad node to include part of broken down data and 2. generate multiple nodes to contain the remaining break down data.
2042
+ """
2043
+
2044
+ _CUD_PROMPT = _CUDTemplate(r"""
2045
+ ROLE: You are revising ONE layer (the immediate children of a single parent) during an iterative editing process.
2046
+ Some sections might be broken down in other iterations. Do not attempt to add them back. Only consider current given parent nodes should be broken down or not.
2047
+ The child is to destructure any parent node that is too broad in meaning. If a parent is narrow enough, just do not output children of the parent.
2048
+ Allowed edits: ADD_NODE, DELETE_NODE, EDIT_NODE.
2049
+
2050
+ Rules:
2051
+ - Edit ONLY the current layer (no ancestors/descendants).
2052
+ - Pointers must match the source text exactly; the engine will validate.
2053
+ - DELETE only strict duplicates WITHIN this layer or it is duplicating the combined effect of other existing nodes; prefer EDIT if fixable.
2054
+ - Partial duplication within a new node can be corrected by editing to that the edit no longer contain the duplicated information.
2055
+ - Do not replace a existing break down just because of a slightly higher or lower granularity, even though coarser breakdown is preferred. The reasson is that we are iteratively editing in layerwise manner.
2056
+ -- Coarser/less granular parsing in each layer with more iterations will preserve better structure than single flattened parsing.
2057
+ -- For example, if a node contains section-A and section B, each contain sub-section, being coarser is correct and preferred. Because next iteration will further breakdown from section level to subsection level.
2058
+ - If a node in current layer is still too broad, containing multiple children ideas, keep it unchanged.
2059
+ - All current layer nodes at any time must be the result of breaking down `Previous layer`. DO NOT ATTEMPT to break down current layer.
2060
+ - Since you are destructuring. Do NOT combine short verbatims to long even if they form a single sentence. But group them in a list instead. i.e. large groups -> small groups and no recombine.
2061
+ - Make sure the children together convey the parent meaning
2062
+ - If a single node has incorrect data/error, edit it with correction.
2063
+ - When breaking down, all children combined together must preserve their parent's meaning.
2064
+ - Each parent must have more than 1 child or no child. Explanation: Having 1 child mean that child has same meaning and granularity as parent and it is not break down.
2065
+ - If a parent has no children, it just mean it is granular enough. It is still preserved, not discarded.
2066
+
2067
+ - Idealy, you need to avoid the following 2 situations:
2068
+ 1. breakdown too deep, or
2069
+ 2. no breakdown (only duplicate previous layer)
2070
+ - If the current layer is breaking down the last layer in a sweetspot manner, you can choose to return no change proposals to indicate satisfactory evaluation.
2071
+ - If a parent need not broken down, do not keep them in output in next layer. Leave it as a leaf in the data structure.
2072
+ Return JSON that conforms to this schema:
2073
+ {
2074
+ "proposals": [
2075
+ {"edit_type": "ADD_NODE", "add": <LLMChildNodeResponse JSON>},
2076
+ {"edit_type": "DELETE_NODE", "target": {"node_id": "..."} OR {"node_type": "...", "title": "..."}},
2077
+ {"edit_type": "EDIT_NODE", "target": {...}, "patch": {"title": "...", "pointers": [...]} }
2078
+ ]
2079
+ }
2080
+ Full document context:
2081
+ $full_document_json_str
2082
+
2083
+ Previous layer for parent_node_id refernece:
2084
+ $ancestors
2085
+
2086
+ CURRENT LAYER SCHEMA (Not the update/ changes proposal schema, remember):
2087
+ $current_layer_schema
2088
+
2089
+ CURRENT editing/drafting LAYER (children JSON):
2090
+ $current_layer_json
2091
+ """)
2092
+
2093
+ # use when layer first round, less error
2094
+ _C_PROMPT = _CUDTemplate(r"""
2095
+ ROLE: You are revising ONE layer (the immediate children of a single parent).
2096
+ Allowed edits: ADD_NODE
2097
+
2098
+ Rules:
2099
+ - Pointers must match the source text exactly; the engine will validate.
2100
+ - If previous layer is already the most granular, do not force yourself into repeating the same granular result. Just give no edit in this case, leave the current layer empty.
2101
+ - Do not replace a existing break down just because of a slightly higher or lower granularity, even though coarser breakdown is preferred. The reasson is that we are iteratively editing in layerwise manner.
2102
+ -- Coarser/less granular parsing in each layer with more iterations will preserve better structure than single flattened parsing.
2103
+ -- For example, if a node contains section-A and section B, each contain sub-section, being coarser is correct and preferred. Because next iteration will further breakdown from section level to subsection level.
2104
+ - When breaking down, all children combined together must preserve their parent's meaning.
2105
+ - Each parent must have more than 1 child or no child. Explanation: Having 1 child mean that child has same meaning and granularity as parent and it is not break down.
2106
+ - If a parent has no children, it just mean it is granular enough. It is still preserved, not discarded.
2107
+
2108
+ Return JSON that conforms to this schema:
2109
+ {
2110
+ "proposals": [
2111
+ {"edit_type": "ADD_NODE", "add": <LLMChildNodeResponse JSON>, "reasoning_create": <CREATION REASONING>},
2112
+ ]
2113
+ }
2114
+
2115
+ Previous iteration reasoning history:
2116
+ $reasoning_history
2117
+
2118
+ Full document context:
2119
+ $full_document_json_str
2120
+
2121
+ Ancestors for refernece:
2122
+ $ancestors
2123
+
2124
+ CURRENT LAYER (children JSON):
2125
+ $current_layer_json
2126
+ """)
2127
+ # _CUD_PROMPT = _CUDTemplate(r"""
2128
+ # ROLE: You are revising ONE layer (the immediate children of a single parent).
2129
+ # Allowed edits: ADD_NODE, DELETE_NODE, EDIT_NODE.
2130
+
2131
+ # Rules:
2132
+ # - Edit ONLY the current layer (no ancestors/descendants).
2133
+ # - Pointers must match the source text exactly; the engine will validate.
2134
+ # - DELETE only strict duplicates WITHIN this layer; prefer EDIT if fixable.
2135
+
2136
+ # Return JSON that conforms to this schema:
2137
+ # {
2138
+ # "proposals": [
2139
+ # {"edit_type": "ADD_NODE", "child": <LLMChildNodeResponse JSON>},
2140
+ # {"edit_type": "DELETE_NODE", "match": {"node_type": "...", "title": "..."}},
2141
+ # {"edit_type": "EDIT_NODE", "match": {"node_type": "...", "title": "..."}, "child": <LLMChildNodeResponse JSON>}
2142
+ # ]
2143
+ # }
2144
+
2145
+ # Full document context:
2146
+ # $full_document_json_str
2147
+
2148
+ # Ancestors for refernece:
2149
+ # $ancestors
2150
+
2151
+ # CURRENT LAYER (children JSON):
2152
+ # $current_layer_json
2153
+ # """)
2154
+
2155
+ def _serialize_children_for_prompt(children: List[LLMChildNodeResponse]) -> str:
2156
+ slim = []
2157
+ for i, c in enumerate(children):
2158
+ slim.append({
2159
+ "children_local_temporary_node_id": i,
2160
+ "node_type": c.node_type,
2161
+ "title": c.title,
2162
+ "pointers": [{
2163
+ "source_cluster_id": p.source_cluster_id,
2164
+ "start_char": p.start_char,
2165
+ "end_char": p.end_char,
2166
+ "verbatim_text": (p.verbatim_text[:120] + ("…" if len(p.verbatim_text) > 120 else "")),
2167
+ } for p in c.pointers[:2]],
2168
+ })
2169
+ return json.dumps(slim, ensure_ascii=False, indent=2)
2170
+
2171
+ # ---------- CUD_proposal + apply_proposal ----------
2172
+ @memory.cache(ignore = ['model_names', 'source_map'])
2173
+ def CUD_proposal(
2174
+ # parent_id: str,
2175
+ children: List[LLMChildNodeResponse],
2176
+ source_map: Dict,
2177
+ model_names: List[str],
2178
+ full_document_json_str: str,
2179
+ doc_id: str,
2180
+ last_layer: List,
2181
+ attempt : int,
2182
+ reasoning_history: list[str]
2183
+ ) -> CUDResponse|CResponse:
2184
+ """
2185
+ Ask LLM for CUD proposals (single round) for the CURRENT LAYER.
2186
+ Returns a list of CUDProposal, or [] if none.
2187
+ """
2188
+ # build small prompt from current layer only
2189
+ if children:
2190
+ prompt = _CUD_PROMPT.substitute(current_layer_json=_serialize_children_for_prompt(children),
2191
+ full_document_json_str = full_document_json_str,
2192
+ current_layer_schema = LLMChildNodeResponse.model_json_schema(),
2193
+ ancestors = last_layer, reasoning_history = str(reasoning_history))
2194
+ ResponseModel = CUDResponse
2195
+ else:
2196
+ prompt = _C_PROMPT.substitute(current_layer_json=_serialize_children_for_prompt(children),
2197
+ full_document_json_str = full_document_json_str,
2198
+ ancestors = last_layer, reasoning_history = str(reasoning_history))
2199
+ ResponseModel = CResponse
2200
+ # Prefer your structured invoker if available
2201
+
2202
+ try:
2203
+ resp:CUDResponse['llm'] | CResponse['llm'] = _default_call_llm_structured(
2204
+ prompt=prompt,
2205
+ model_names=model_names,
2206
+ schema=ResponseModel,
2207
+ model_json_schema=ResponseModel.model_json_schema(),
2208
+ doc_id = doc_id,
2209
+ event_name = "CUD_proposal",
2210
+ i_attempt=attempt,
2211
+
2212
+ )
2213
+ return ResponseModel.model_validate(resp.model_dump())
2214
+ except Exception as e:
2215
+ print("error " + str(e))
2216
+ # logger.error(e)
2217
+ # fail-safe: no proposals means exit loop on your side
2218
+ raise e
2219
+ return []
2220
+ from typing import Sequence
2221
+ def apply_proposal(
2222
+ proposals: Sequence[CUDProposal | CProposal | UProposal | DProposal],
2223
+ children: List[LLMChildNodeResponse],
2224
+ source_map: Dict,
2225
+ ) -> tuple[List[LLMChildNodeResponse], list[str]]:
2226
+ """
2227
+ Apply proposals to THIS layer only.
2228
+ - ADD_NODE: add typed child (validated pointers)
2229
+ - DELETE_NODE: remove targeted node (by id or (type,title))
2230
+ - EDIT_NODE: patch targeted node (typed partial), then revalidate pointers
2231
+ Returns updated, deduped children list.
2232
+ """
2233
+ out = {k:v for k,v in (enumerate(children))}
2234
+ next_id = len(children)
2235
+ proposal_error_messages = []
2236
+ def _locate_index(tgt: UDTarget) -> Optional[int]:
2237
+ if tgt.node_id: # just the pre-edit index
2238
+ try:
2239
+ lid = int(int(tgt.node_id))
2240
+ except ValueError:
2241
+ return None
2242
+ if lid in out:
2243
+ return lid
2244
+ # else:
2245
+ # raise Exception(f"non existent children local node id {tgt.node_id}")
2246
+ if tgt.node_type and tgt.title is not None:
2247
+ nt = tgt.node_type
2248
+ tt = _normalize_title(tgt.title)
2249
+ for i, c in out.items():
2250
+ if c.node_type == nt and _normalize_title(c.title) == tt:
2251
+ return i
2252
+ return None
2253
+
2254
+ for p in proposals or []:
2255
+ if p.edit_type == "DELETE_NODE":
2256
+ idx = _locate_index(p.target) if p.target else None
2257
+ if idx not in out:
2258
+ continue
2259
+ if idx is not None:
2260
+ out.pop(idx)
2261
+ continue
2262
+
2263
+ if p.edit_type == "ADD_NODE":
2264
+ # Ensure parent id exists in payload
2265
+ if p.add is None:
2266
+ # skip the proposal as if it never return and get detected next round
2267
+ continue
2268
+ add = p.add.model_copy()
2269
+ if not add.parent_node_id:
2270
+ # fall back to first child’s parent if present
2271
+ add.parent_node_id = out[0].parent_node_id if out else add.parent_node_id
2272
+ try:
2273
+ fixed = _validate_child_pointers(LLMChildNodeResponse.model_validate(add.model_dump()), source_map)
2274
+ except Exception as e:
2275
+ proposal_error_messages.append(str(e))
2276
+ fixed = None
2277
+ if fixed:
2278
+ out[next_id] = fixed
2279
+ next_id += 1
2280
+ continue
2281
+
2282
+ if p.edit_type == "EDIT_NODE":
2283
+ idx = _locate_index(p.target) if p.target else None
2284
+ if idx is None:
2285
+ continue
2286
+ if idx not in out:
2287
+ continue
2288
+ base = out[idx].model_dump()
2289
+ # Merge patch fields (only provided)
2290
+ patch = p.patch
2291
+ if patch is None:
2292
+ # skip the proposal as if it never return and get detected next round
2293
+ continue
2294
+
2295
+ if patch.parent_node_id is not None:
2296
+ base["parent_node_id"] = patch.parent_node_id
2297
+ if patch.node_type is not None:
2298
+ base["node_type"] = patch.node_type
2299
+ if patch.title is not None:
2300
+ base["title"] = patch.title
2301
+ if patch.pointers is not None:
2302
+ base["pointers"] = [hp.model_dump() for hp in patch.pointers]
2303
+ # if patch.value_pointers is not None:
2304
+ # base["value_pointers"] = [hp.model_dump() for hp in patch.value_pointers]
2305
+
2306
+ try:
2307
+ draft = LLMChildNodeResponse.model_validate(base)
2308
+ except Exception:
2309
+ continue
2310
+ fixed = _validate_child_pointers(draft, source_map)
2311
+ if fixed:
2312
+ out[idx] = fixed
2313
+
2314
+ return dedupe_children_level(list(out.values())), proposal_error_messages
2315
+
2316
+ # ============================================================================
2317
+ # Convenience: correct one level from your existing `build_document_tree` loop
2318
+ # ============================================================================
2319
+ @memory.cache
2320
+ def correct_level_children_with_iterative_pipeline(
2321
+ level_response_json: dict,
2322
+ source_map: Dict,
2323
+ full_document_json: Dict,
2324
+ doc_id: str,
2325
+ model_names: List[str] | None = None,
2326
+ ) -> ChildrenCorrectionResult:
2327
+ """Helper to be used right after a level LLM call in your BFS.
2328
+ This layer is a cacheable layer
2329
+ Example integration:
2330
+ level_response = LLMLevelResponse.model_validate(llm_response_json)
2331
+ corrected_children = correct_level_children_with_iterative_pipeline(
2332
+ level_response.model_dump(), source_map, llm_input_dict
2333
+ )
2334
+ """
2335
+ level = LLMLevelResponseBE.model_validate(level_response_json)
2336
+ children = [LLMChildNodeResponseBE.model_validate(c.model_dump()) for c in level.children]
2337
+ to_return = iterative_correct_children_for_level(
2338
+ children=children,
2339
+ source_map=source_map,
2340
+ full_document_json=full_document_json,
2341
+ doc_id = doc_id,
2342
+ model_names=model_names,
2343
+ )
2344
+
2345
+ return to_return
2346
+
2347
+ # ==============================================================================
2348
+ # PHASE 4: TREE RECONSTRUCTION & VALIDATION
2349
+ # ==============================================================================
2350
+
2351
+ def _merge_child_ranges_for_cluster(
2352
+ child_ranges: List[Tuple[int, int, str]],
2353
+ allowed_overlap: int = 0,
2354
+ ) -> bool:
2355
+ """
2356
+ child_ranges: list of (start, end_incl, child_id)
2357
+ returns True if OK, False if overlap violation.
2358
+ `allowed_overlap` = number of chars we allow two different children to clash on.
2359
+ """
2360
+ # sort by start
2361
+ child_ranges.sort(key=lambda x: x[0])
2362
+ prev_start, prev_end, prev_child = child_ranges[0]
2363
+
2364
+ for i in range(1, len(child_ranges)):
2365
+ cur_start, cur_end, cur_child = child_ranges[i]
2366
+
2367
+ if cur_start <= prev_end: # overlap
2368
+ # compute actual overlap span
2369
+ overlap_len = min(prev_end, cur_end) - cur_start + 1
2370
+ # if from different children and overlap is too big -> fail
2371
+ if cur_child != prev_child and overlap_len > allowed_overlap:
2372
+ return False
2373
+ # merge for next step
2374
+ prev_end = max(prev_end, cur_end)
2375
+ # if same child, we just widen it
2376
+ else:
2377
+ # no overlap, advance
2378
+ prev_start, prev_end, prev_child = cur_start, cur_end, cur_child
2379
+
2380
+ return True
2381
+
2382
+ from rapidfuzz import fuzz
2383
+
2384
+ from typing import Dict, List, Tuple
2385
+
2386
+ class CoverageResponse(BaseModel):
2387
+ per_cluster: dict[str, float]
2388
+ overall: float
2389
+
2390
+ def compute_pointer_coverage(
2391
+ root_node: SemanticNode,
2392
+ source_map: Dict,
2393
+ *,
2394
+ clamp_to_cluster: bool = True,
2395
+ ) ->CoverageResponse:
2396
+ """
2397
+ Compute how much of each source_cluster_id is covered by pointers in the tree,
2398
+ using range-merging (no per-character sets).
2399
+
2400
+ Returns:
2401
+ {
2402
+ "per_cluster": { "p1_c0": 0.98, "p1_c1": 1.0, ... },
2403
+ "overall": 0.995
2404
+ }
2405
+
2406
+ Notes:
2407
+ - clamp_to_cluster=True: if a pointer uses end_char == -1 or runs past the text,
2408
+ we clamp to the actual cluster text length.
2409
+ - If a cluster has zero length (empty OCR), it is ignored in overall calc.
2410
+ """
2411
+ # 1) collect all pointers in the tree
2412
+ all_pointers: List[HydratedTextPointer] = []
2413
+
2414
+ def _walk(node: SemanticNode):
2415
+ # a node may have multiple pointers
2416
+ if node.node_type != 'DOCUMENT_ROOT':
2417
+ all_pointers.extend(node.total_content_pointers or [])
2418
+ for ch in node.child_nodes or []:
2419
+ _walk(ch)
2420
+
2421
+ _walk(root_node)
2422
+
2423
+ # 2) group by cluster
2424
+ cluster_ranges: Dict[str, List[Tuple[int, int]]] = {}
2425
+ for ptr in all_pointers:
2426
+ cid = ptr.source_cluster_id
2427
+ src = source_map.get(cid)
2428
+ if not src:
2429
+ continue
2430
+ text = src.get("text", "")
2431
+ max_idx = max(0, len(text) - 1)
2432
+ start = max(0, ptr.start_char)
2433
+ end = ptr.end_char
2434
+ if end == -1:
2435
+ end = max_idx
2436
+ if clamp_to_cluster:
2437
+ end = min(end, max_idx)
2438
+ if start > end:
2439
+ continue
2440
+ cluster_ranges.setdefault(cid, []).append((start, end))
2441
+
2442
+ # 3) merge per cluster and compute coverage
2443
+ per_cluster_cov: Dict[str, float] = {}
2444
+ total_len = 0
2445
+ total_covered = 0
2446
+
2447
+ for cid, ranges in cluster_ranges.items():
2448
+ src = source_map.get(cid)
2449
+ if not src:
2450
+ continue
2451
+ text = src.get("text", "")
2452
+ cluster_len = len(text)
2453
+ if cluster_len == 0:
2454
+ continue
2455
+
2456
+ # merge
2457
+ ranges.sort(key=lambda x: x[0])
2458
+ merged: List[Tuple[int, int]] = []
2459
+ cur_s, cur_e = ranges[0]
2460
+ for s, e in ranges[1:]:
2461
+ if s <= cur_e + 1:
2462
+ # overlap or contiguous
2463
+ cur_e = max(cur_e, e)
2464
+ else:
2465
+ merged.append((cur_s, cur_e))
2466
+ cur_s, cur_e = s, e
2467
+ merged.append((cur_s, cur_e))
2468
+
2469
+ covered_len = sum((e - s + 1) for s, e in merged)
2470
+ cov = covered_len / cluster_len
2471
+ per_cluster_cov[cid] = cov
2472
+
2473
+ total_len += cluster_len
2474
+ total_covered += covered_len
2475
+
2476
+ overall = (total_covered / total_len) if total_len else 1.0
2477
+
2478
+ return CoverageResponse.model_validate({
2479
+ "per_cluster": per_cluster_cov,
2480
+ "overall": overall,
2481
+ })
2482
+
2483
+ def analyze_and_validate_tree(
2484
+ root_node: SemanticNode,
2485
+ source_map: Dict,
2486
+ *,
2487
+ allowed_overlap_per_cluster: int = 0, # e.g. 2–5 chars to forgive punctuation/WS
2488
+ completeness_fuzz_threshold: int = 98, # % similarity to still pass completeness
2489
+ ) -> bool:
2490
+ """Performs validation: sibling overlaps (range-based) and leaf completeness (fuzzy)."""
2491
+
2492
+ def check_sibling_overlaps(node: SemanticNode) -> bool:
2493
+ if not node.child_nodes:
2494
+ return True
2495
+
2496
+ # cluster_id -> list[(start, end_incl, child_label)]
2497
+ cluster_ranges: Dict[str, List[Tuple[int, int, str]]] = {}
2498
+
2499
+ for idx, child in enumerate(node.child_nodes):
2500
+ child_label = f"{child.title}-{idx}"
2501
+ for pointer in child.total_content_pointers:
2502
+ cid = pointer.source_cluster_id
2503
+ start = pointer.start_char
2504
+ end = pointer.end_char
2505
+ if end == -1:
2506
+ # expand to cluster length
2507
+ src = source_map.get(cid)
2508
+ if src:
2509
+ end = len(src["text"]) - 1
2510
+ cluster_ranges.setdefault(cid, []).append((start, end, child_label))
2511
+
2512
+ # now check per-cluster
2513
+ for cid, ranges in cluster_ranges.items():
2514
+ if not ranges:
2515
+ continue
2516
+ ok = _merge_child_ranges_for_cluster(
2517
+ ranges,
2518
+ allowed_overlap=allowed_overlap_per_cluster,
2519
+ )
2520
+ if not ok:
2521
+ print(f"🚨 OVERLAP ERROR in node '{node.title}' on cluster '{cid}'")
2522
+ return False
2523
+
2524
+ # recurse
2525
+ return all(check_sibling_overlaps(child) for child in node.child_nodes)
2526
+
2527
+ def check_leaf_completeness(root: SemanticNode, source: Dict) -> bool:
2528
+ leaf_pointers: List[HydratedTextPointer] = []
2529
+
2530
+ def collect_leaves(n: SemanticNode):
2531
+ if not n.child_nodes and n.node_type != "DOCUMENT_ROOT":
2532
+ leaf_pointers.extend(n.total_content_pointers)
2533
+ else:
2534
+ for ch in n.child_nodes:
2535
+ collect_leaves(ch)
2536
+
2537
+ collect_leaves(root)
2538
+
2539
+ reconstructed_text = reconstruct_text_from_pointers(leaf_pointers, source)
2540
+ source_text = "".join(
2541
+ _source_map_entry_text(cluster)
2542
+ for cid, cluster in sorted(source.items())
2543
+ )
2544
+
2545
+ recon_norm = normalize_text(reconstructed_text)
2546
+ source_norm = normalize_text(source_text)
2547
+
2548
+ if recon_norm == source_norm:
2549
+ return True
2550
+
2551
+ # fuzzy allow
2552
+ score = fuzz.partial_ratio(recon_norm, source_norm)
2553
+ if score >= completeness_fuzz_threshold:
2554
+ print(f"⚠️ COMPLETENESS WARN: fuzzy={score} >= {completeness_fuzz_threshold}, accepting.")
2555
+ return True
2556
+ else:
2557
+ print(f"⚠️ INCOMPLETENESS WARN: fuzzy={score} < {completeness_fuzz_threshold}, rejected.")
2558
+
2559
+ print("🚨 COMPLETENESS ERROR: The leaf nodes do not fully cover the source document.")
2560
+ print(f" fuzzy={score} < {completeness_fuzz_threshold}")
2561
+ return False
2562
+
2563
+ print("\n--- Phase 4: Running Tree Validation ---")
2564
+ if not check_sibling_overlaps(root_node):
2565
+ return False
2566
+ if not check_leaf_completeness(root_node, source_map):
2567
+ return False
2568
+
2569
+ print("✅ SUCCESS: Document tree is valid, complete, and has no overlaps.")
2570
+ return True
2571
+
2572
+ # ==============================================================================
2573
+ # PHASE 5: UTILITIES & EXECUTION
2574
+ # ==============================================================================
2575
+ def normalize_text(text: str) -> str:
2576
+ """Removes all whitespace characters for a clean comparison."""
2577
+ return re.sub(r'\s+', '', text)
2578
+
2579
+ def reconstruct_text_from_pointers(pointers: List[HydratedTextPointer], source_map: Dict) -> str:
2580
+ """
2581
+ Reconstructs text from pointers. If `relative_to` is provided, assumes pointers
2582
+ are relative to that text content. Otherwise, assumes they are absolute.
2583
+ """
2584
+ full_text = ""
2585
+ pointers.sort(key=lambda p: (p.source_cluster_id, p.start_char))
2586
+ for pointer in pointers:
2587
+ source_cluster = source_map.get(pointer.source_cluster_id)
2588
+ if not source_cluster: continue
2589
+ source_text = _source_map_entry_text(source_cluster)
2590
+ if not source_text:
2591
+ continue
2592
+ end = len(source_text) if pointer.end_char == -1 else pointer.end_char + 1
2593
+ start = pointer.start_char
2594
+ if start < len(source_text):
2595
+ full_text += source_text[start:end]
2596
+ return full_text
2597
+
2598
+
2599
+ def print_tree(node: SemanticNode, indent=""):
2600
+ """Visualizes the hydrated tree. No longer needs source_map."""
2601
+ reconstructed_text = "".join([(p.verbatim_text or "") for p in node.total_content_pointers])
2602
+ print(f"{indent} L- {node.title} ({node.node_type}) | Text: '{reconstructed_text[:150].strip()}...'")
2603
+ for child in node.child_nodes:
2604
+ print_tree(child, indent + " ")
2605
+ @memory.cache()
2606
+ def parse_doc(
2607
+ doc_id: str,
2608
+ raw_doc_dict,
2609
+ parsing_mode: Literal["snippet", "delimiter"] = "snippet",
2610
+ max_depth: int = 10,
2611
+ model_names: List[str] | None = None,
2612
+ ):
2613
+
2614
+
2615
+ try:
2616
+ print("--- Phase 2: Preparing Document ---")
2617
+ llm_input_dict, source_map = prepare_document_for_llm(raw_doc_dict)
2618
+
2619
+ print("\n--- Phase 3: Building Document Tree (Layer-wise) ---")
2620
+ document_tree = build_document_tree(
2621
+ doc_id,
2622
+ llm_input_dict,
2623
+ source_map,
2624
+ parsing_mode=parsing_mode,
2625
+ max_depth=max_depth,
2626
+ model_names=model_names,
2627
+ )
2628
+ cov: CoverageResponse = compute_pointer_coverage(document_tree, source_map)
2629
+ print("Overall coverage:", cov.overall)
2630
+ for cid, r in cov.per_cluster.items():
2631
+ if r < 0.99:
2632
+ print(f"⚠️ {cid} only {r:.1%} covered")
2633
+
2634
+ print("\n--- Phase 4: Reconstructing and Validating Tree ---")
2635
+
2636
+ # is_valid = analyze_and_validate_tree(
2637
+ # document_tree,
2638
+ # source_map,
2639
+ # allowed_overlap_per_cluster=4, # tolerate small clashes
2640
+ # completeness_fuzz_threshold=97, # tolerate tiny OCR drift
2641
+ # )
2642
+
2643
+ # if is_valid:
2644
+ print("\n--- Phase 5: Visualizing the Reconstructed Tree ---")
2645
+ print_tree(document_tree)
2646
+
2647
+ except (ValidationError, json.JSONDecodeError) as e:
2648
+ print("\n--- ERROR: Failed to parse or validate LLM response. ---")
2649
+ print(f"Details: {e}")
2650
+ raise e
2651
+ except Exception as e:
2652
+ print("\n--- ERROR: A critical error occurred. ---")
2653
+ print(f"Details: {e}")
2654
+ raise e
2655
+ return document_tree, source_map
2656
+
2657
+
2658
+ def semantic_tree_to_kge_payload(
2659
+ root: "SemanticNode",
2660
+ *,
2661
+ doc_id: str | None = None,
2662
+ insertion_method: str = "semantic_document_parser_v1",
2663
+ ) -> Dict[str, Any]:
2664
+ if doc_id is None:
2665
+ doc_id = str(
2666
+ stable_id(
2667
+ "legacy.semantic_tree_doc",
2668
+ str(root.node_id),
2669
+ str(insertion_method),
2670
+ )
2671
+ )
2672
+
2673
+ nodes: List[Dict[str, Any]] = []
2674
+ edges: List[Dict[str, Any]] = []
2675
+
2676
+ def _pointers_to_references(ptrs: List["HydratedTextPointer"]) -> List[Dict[str, Any]]:
2677
+ return [
2678
+ {
2679
+ "doc_id": doc_id,
2680
+ "collection_page_url": f"doc://{doc_id}",
2681
+ "document_page_url": f"doc://{doc_id}#{p.source_cluster_id}",
2682
+ "insertion_method": insertion_method,
2683
+ "start_page": 1,
2684
+ "end_page": 1,
2685
+ "start_char": p.start_char,
2686
+ "end_char": p.end_char,
2687
+ "snippet": p.verbatim_text,
2688
+ "source_cluster_id": p.source_cluster_id
2689
+ }
2690
+ for p in ptrs
2691
+ ]
2692
+ def _spans_to_groundings_to_mentions(spans: list[dict]):
2693
+ groundings = {'spans' : spans}
2694
+ mentions = [groundings]
2695
+ return mentions
2696
+
2697
+ def _pointers_to_spans(ptrs: List["HydratedTextPointer"]) -> list[dict]:
2698
+ return [{
2699
+ "doc_id": doc_id,
2700
+ "collection_page_url": f"doc://{doc_id}",
2701
+ "document_page_url": f"doc://{doc_id}#{p.source_cluster_id}",
2702
+ "insertion_method": insertion_method,
2703
+ "page_number": int(p.source_cluster_id.split('_')[0][1:]),
2704
+ # "end_page": 1,
2705
+ "start_char": p.start_char,
2706
+ "end_char": p.end_char,
2707
+ "excerpt": p.verbatim_text,
2708
+ "context_before": "",
2709
+ "context_after": "",
2710
+ "source_cluster_id": p.source_cluster_id,
2711
+ "verification": None
2712
+ } for p in ptrs]
2713
+ def walk(node: "SemanticNode"):
2714
+ ptrs = list(node.total_content_pointers)
2715
+ pointers_payload = [p.model_dump(field_mode = 'backend') for p in ptrs]
2716
+
2717
+ node_dict = {
2718
+ "id": str(node.node_id),
2719
+ "label": node.title,
2720
+ "type": "entity",
2721
+ "summary": node.title + ":\n" + "\n".join(p.verbatim_text for p in ptrs)[:4000],
2722
+ "metadata": {
2723
+ "semantic_node_type": node.node_type,
2724
+ "doc_id": doc_id,
2725
+ "parent_id": str(node.parent_id) if node.parent_id else None,
2726
+ "pointers": pointers_payload,
2727
+ "insertion_method": insertion_method,
2728
+ "level_from_root": node.level_from_root
2729
+ },
2730
+ "mentions": _spans_to_groundings_to_mentions(_pointers_to_spans(ptrs)),
2731
+
2732
+ # "references": _pointers_to_references(ptrs),
2733
+ }
2734
+ nodes.append(node_dict)
2735
+
2736
+ for child in node.child_nodes:
2737
+ child.level_from_root = node.level_from_root + 1
2738
+ edge_ptr_refs = _pointers_to_references(ptrs) # parent’s pointers as provenance
2739
+ edge_ptr_mentions= _spans_to_groundings_to_mentions(_pointers_to_spans(ptrs))
2740
+ edge_dict = {
2741
+ "id": str(
2742
+ stable_id(
2743
+ "legacy.semantic_tree_edge",
2744
+ "HAS_CHILD",
2745
+ str(node.node_id),
2746
+ str(child.node_id),
2747
+ str(doc_id),
2748
+ str(insertion_method),
2749
+ )
2750
+ ),
2751
+ "label": "parent-child",
2752
+ "type": "relationship",
2753
+ "summary": f"{node.node_id} -> {child.node_id} (HAS_CHILD)",
2754
+ "relation": "HAS_CHILD",
2755
+ "source_ids": [str(node.node_id)],
2756
+ "target_ids": [str(child.node_id)],
2757
+ "source_edge_ids": [],
2758
+ "target_edge_ids": [],
2759
+ "mentions": edge_ptr_mentions,
2760
+ "metadata": {
2761
+ "doc_id": doc_id,
2762
+ "insertion_method": insertion_method,
2763
+ },
2764
+ }
2765
+ edges.append(edge_dict)
2766
+
2767
+ walk(child)
2768
+ root.level_from_root = 0
2769
+ walk(root)
2770
+ nodes[0].update({"properties": {"kind": "document_root"}}) # document root
2771
+ return {
2772
+ "doc_id": doc_id,
2773
+ "insertion_method": insertion_method,
2774
+ "nodes": nodes,
2775
+ "edges": edges,
2776
+ }
2777
+ from collections import defaultdict
2778
+ def _extract_pointers_from_mentions(mentions: List[dict[str, list[dict]]]):
2779
+ if len(mentions) > 1 :
2780
+ raise Exception("unsupported multiple mentions")
2781
+ mention = mentions[0]
2782
+ spans = mention['spans']
2783
+ results = []
2784
+ for span in spans:
2785
+ doc_page = span.get("document_page_url") or ""
2786
+ source_cluster_id = None
2787
+ if "#" in doc_page:
2788
+ source_cluster_id = doc_page.split("#", 1)[1]
2789
+ # fallback
2790
+ if not source_cluster_id:
2791
+ source_cluster_id = "p1_c0"
2792
+ span_verification = span['verification']
2793
+ results.append(
2794
+ HydratedTextPointer(
2795
+ source_cluster_id=source_cluster_id,
2796
+ start_char=span.get("start_char", 0),
2797
+ end_char=( -1 if span.get("end_char") == 10**9 else span.get("end_char", -1) ),
2798
+ verbatim_text=span.get("excerpt", ""),
2799
+ validation_method=span_verification,
2800
+ )
2801
+ )
2802
+ return results
2803
+ def _extract_pointers_from_references(refs: List[Dict[str, Any]]):
2804
+ # turn MCP ref → HydratedTextPointer-like
2805
+ results = []
2806
+ for r in refs or []:
2807
+ # document_page_url: "doc://{doc_id}#{source_cluster_id}"
2808
+ doc_page = r.get("document_page_url") or ""
2809
+ source_cluster_id = None
2810
+ if "#" in doc_page:
2811
+ source_cluster_id = doc_page.split("#", 1)[1]
2812
+ # fallback
2813
+ if not source_cluster_id:
2814
+ source_cluster_id = "p1_c0"
2815
+ r_mentions = r.get('mentions', [])
2816
+ if len(r_mentions) == 0:
2817
+ r_mention_verification = None
2818
+ else:
2819
+ if len(r_mentions) > 1 :
2820
+ # unsupported multiple mentions, possibly try load from wrong doc parsing results
2821
+ raise Exception("UnsupportedDocumentParsingFormat")
2822
+ else:
2823
+ r_mention_verification = r_mentions[0].get('verification')
2824
+
2825
+ results.append(
2826
+ HydratedTextPointer(
2827
+ source_cluster_id=source_cluster_id,
2828
+ start_char=r.get("start_char", 0),
2829
+ end_char=( -1 if r.get("end_char") == 10**9 else r.get("end_char", -1) ),
2830
+ verbatim_text=r.get("snippet", ""),
2831
+ validation_method=r_mention_verification,
2832
+ )
2833
+ )
2834
+ return results
2835
+
2836
+
2837
+ def kge_payload_to_semantic_tree(payload: Dict[str, Any]) -> "SemanticNode":
2838
+ nodes_data: List[Dict[str, Any]] = payload.get("nodes", [])
2839
+ edges_data: List[Dict[str, Any]] = payload.get("edges", [])
2840
+
2841
+ sem_nodes: Dict[str, SemanticNode] = {}
2842
+ for n in nodes_data:
2843
+ md = n.get("metadata") or {}
2844
+ # old path
2845
+ # pointers = _extract_pointers_from_metadata(md)
2846
+ # new path (MCP-style references)
2847
+ # if not pointers and n.get("references"):
2848
+ # pointers = _extract_pointers_from_references(n["references"])
2849
+ level_from_root = md.get("level_from_root")
2850
+ if level_from_root is None:
2851
+ raise Exception("Data corruption error, level_from_root information is lost")
2852
+ mentions = _extract_pointers_from_mentions(n["mentions"])
2853
+ sem = SemanticNode(
2854
+ node_id=UUID(n["id"]),
2855
+ parent_id=UUID(md["parent_id"]) if md.get("parent_id") else None,
2856
+ node_type=md.get("semantic_node_type", "TEXT_FLOW"),
2857
+ title=n.get("label") or "",
2858
+ total_content_pointers=mentions,
2859
+ child_nodes=[],
2860
+ level_from_root=level_from_root
2861
+ )
2862
+ sem_nodes[n["id"]] = sem
2863
+
2864
+ children_by_parent: Dict[str, List[str]] = defaultdict(list)
2865
+ for e in edges_data:
2866
+ relation = e.get("relation") or e.get("predicate")
2867
+ if relation != "HAS_CHILD":
2868
+ continue
2869
+
2870
+ if "source_ids" in e or "target_ids" in e:
2871
+ src_ids = e.get("source_ids") or []
2872
+ tgt_ids = e.get("target_ids") or []
2873
+ if not src_ids or not tgt_ids:
2874
+ continue
2875
+ parent_id = src_ids[0]
2876
+ child_id = tgt_ids[0]
2877
+ else:
2878
+ parent_id = e.get("subject_id")
2879
+ child_id = e.get("object_id")
2880
+ if not parent_id or not child_id:
2881
+ continue
2882
+
2883
+ children_by_parent[parent_id].append(child_id)
2884
+
2885
+ for pid, child_ids in children_by_parent.items():
2886
+ parent_sem = sem_nodes.get(pid)
2887
+ if not parent_sem:
2888
+ continue
2889
+ for cid in child_ids:
2890
+ child_sem = sem_nodes.get(cid)
2891
+ if child_sem:
2892
+ parent_sem.child_nodes.append(child_sem)
2893
+
2894
+ root_candidates = [s for s in sem_nodes.values() if s.parent_id is None]
2895
+ if root_candidates:
2896
+ return root_candidates[0]
2897
+ else:
2898
+ all_child_ids = {cid for cids in children_by_parent.values() for cid in cids}
2899
+ all_ids = set(sem_nodes.keys())
2900
+ root_ids = list(all_ids - all_child_ids)
2901
+ return sem_nodes[root_ids[0]] if root_ids else list(sem_nodes.values())[0]
2902
+
2903
+ def all_child_from_root(root: SemanticNode, results = None):
2904
+ if results is None:
2905
+ results = []
2906
+ results.extend(root.child_nodes)
2907
+ for node in root.child_nodes:
2908
+ all_child_from_root(node, results)
2909
+ return results
2910
+
2911
+ available_node_ids = ContextVar("indexing_nodes", default=set())
2912
+ class IndexingResponse(BaseModel):
2913
+ "Represent a single indexing result of a node"
2914
+ node_id : str = Field(description = "The node id this index result is representing")
2915
+ canonical_title: str = Field(description = "best searchable title")
2916
+ keywords: list[str] = Field(description = "5-12 short keywords")
2917
+ aliases: list[str] = Field(description = "0-5 alternative phrasings")
2918
+ provision: str = Field(description = "clauses, terms, sections, and schedules. Example: 'Schedule 3.1', 'Term 5a', 'Clause 3.12.2(a)'")
2919
+ @model_validator(mode='after')
2920
+ def _check_consistency(self):
2921
+ node_set = available_node_ids.get()
2922
+ try:
2923
+ node_set.remove(str(self.node_id))
2924
+ except KeyError:
2925
+ raise Exception(f'node_id {self.node_id} duplicated or not exist')
2926
+ return self
2927
+ class BatchIndexResponse(BaseModel):
2928
+ "a list of batch run of indexing"
2929
+ index : list[IndexingResponse] = Field(description = "a list of indexing result. Each response member/element must have 1 and only 1 corresponding index. Each key is the input node's id")
2930
+ # @model_validator(mode='after')
2931
+ # def _check_used_all_node_id(self):
2932
+ # node_set = available_node_ids.get()
2933
+ # assert (set(str(i.node_id) for i in self.index) == node_set)
2934
+ # return self
2935
+ class IdMapping:
2936
+ def __init__(self):
2937
+ self.forward_map = {}
2938
+ self.backward_map = {}
2939
+ def to_uuid(self, short_id):
2940
+ return self.backward_map.get(short_id)
2941
+ def to_short_id(self, id, title):
2942
+ if id not in self.forward_map:
2943
+ self.forward_map[id] = f"nid:{len(self.forward_map)}:{title}"
2944
+ self.backward_map[self.forward_map[id]] = id
2945
+
2946
+ return self.forward_map[id]
2947
+ pass
2948
+
2949
+ class IndexMode(str, Enum):
2950
+ FLAT_VERBATIM = "flat_verbatim"
2951
+ BOTTOM_UP_DIGEST = "bottom_up_digest"
2952
+ BOTH = "both"
2953
+
2954
+
2955
+ def _default_token_estimate(text: str) -> int:
2956
+ return max(1, (len(text) + 3) // 4)
2957
+
2958
+
2959
+ def _json_compact(obj: Any) -> str:
2960
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
2961
+
2962
+
2963
+ def _render_nodes_payload(nodes_payload: list[dict[str, Any]] | dict[str, Any]) -> str:
2964
+ return _json_compact({"nodes": nodes_payload})
2965
+
2966
+
2967
+ def batch_nodes(
2968
+ items: list[dict[str, Any]],
2969
+ *,
2970
+ max_nodes: Optional[int] = None,
2971
+ max_input_tokens: Optional[int] = None,
2972
+ token_estimator: Callable[[str], int] = _default_token_estimate,
2973
+ base_prompt_tokens: int = 450,
2974
+ per_node_overhead_tokens: int = 15,
2975
+ render_item_for_estimate: Callable[[dict[str, Any]], str] = _json_compact,
2976
+ ) -> Iterable[list[dict[str, Any]]]:
2977
+ if max_nodes is None and max_input_tokens is None:
2978
+ max_nodes = 25
2979
+
2980
+ batch: list[dict[str, Any]] = []
2981
+ batch_tokens = base_prompt_tokens
2982
+
2983
+ def flush():
2984
+ nonlocal batch, batch_tokens
2985
+ if batch:
2986
+ yield batch
2987
+ batch = []
2988
+ batch_tokens = base_prompt_tokens
2989
+
2990
+ for item in items:
2991
+ item_text = render_item_for_estimate(item)
2992
+ item_tokens = token_estimator(item_text) + per_node_overhead_tokens
2993
+
2994
+ would_exceed_tokens = (
2995
+ max_input_tokens is not None
2996
+ and batch
2997
+ and (batch_tokens + item_tokens) > max_input_tokens
2998
+ )
2999
+ would_exceed_nodes = (
3000
+ max_nodes is not None
3001
+ and batch
3002
+ and (len(batch) + 1) > max_nodes
3003
+ )
3004
+
3005
+ if would_exceed_tokens or would_exceed_nodes:
3006
+ yield from flush()
3007
+
3008
+ # best-effort: allow single oversized item
3009
+ batch.append(item)
3010
+ batch_tokens += item_tokens
3011
+
3012
+ yield from flush()
3013
+
3014
+
3015
+ @dataclass
3016
+ class _Prepared:
3017
+ id_map: IdMapping
3018
+ dumped: list[dict[str, Any]] # full dumped nodes with short ids
3019
+ node_by_id: dict[str, dict[str, Any]] # short_id -> dumped node
3020
+
3021
+
3022
+ def _prepare_nodes(sem_nodes: list["SemanticNode"]) -> _Prepared:
3023
+ id_map = IdMapping()
3024
+ # ensure instance-local maps
3025
+ id_map.forward_map = {}
3026
+ id_map.backward_map = {}
3027
+
3028
+ dumped: list[dict[str, Any]] = []
3029
+ for n in sem_nodes:
3030
+ d = n.model_dump()
3031
+ d["node_id"] = id_map.to_short_id(d["node_id"], d.get("title"))
3032
+ dumped.append(d)
3033
+
3034
+ for d in dumped:
3035
+ pid = d.get("parent_id")
3036
+ if pid is not None:
3037
+ d["parent_id"] = id_map.to_short_id(pid, d.get("title"))
3038
+
3039
+ node_by_id = {str(d["node_id"]): d for d in dumped}
3040
+ return _Prepared(id_map=id_map, dumped=dumped, node_by_id=node_by_id)
3041
+
3042
+
3043
+ def _leaf_payload(node_dict: dict[str, Any]) -> dict[str, Any]:
3044
+ return {
3045
+ "node_id": node_dict["node_id"],
3046
+ "title": node_dict.get("title"),
3047
+ "kind": "LEAF",
3048
+ "contents": [
3049
+ p.get("verbatim_text")
3050
+ for p in (node_dict.get("total_content_pointers") or [])
3051
+ if p and (p.get("verbatim_text") or p.get("start_delimiter"))
3052
+ ],
3053
+ }
3054
+
3055
+ def digest_lite(r: IndexingResponse) -> dict[str, Any]:
3056
+ return {
3057
+ "node_id": str(r.node_id),
3058
+ "t": (r.canonical_title or "")[:120],
3059
+ "k": (r.keywords or [])[:12],
3060
+ # omit aliases/provision by default
3061
+ }
3062
+ def _digest_from_index(r: "IndexingResponse") -> dict[str, Any]:
3063
+ # compact, bounded; used only for parent prompts
3064
+ return {
3065
+ "node_id": str(r.node_id),
3066
+ "canonical_title": (r.canonical_title or "")[:160],
3067
+ "keywords": (r.keywords or [])[:12],
3068
+ "aliases": (r.aliases or [])[:5],
3069
+ "provision": (r.provision or "")[:120],
3070
+ }
3071
+
3072
+
3073
+ def _parent_payload(node_dict: dict[str, Any], child_digests: list[dict[str, Any]]) -> dict[str, Any]:
3074
+ return {
3075
+ "node_id": node_dict["node_id"],
3076
+ "title": node_dict.get("title"),
3077
+ "kind": "PARENT",
3078
+ "children": child_digests,
3079
+ }
3080
+
3081
+ @memory.cache(ignore=["model_names"])
3082
+ def build_index_terms_for_semantic_node(
3083
+ sem_nodes: list["SemanticNode"],
3084
+ doc_id: str,
3085
+ model_names: list[str],
3086
+ *,
3087
+ mode: IndexMode = IndexMode.FLAT_VERBATIM,
3088
+ max_nodes_per_batch: int | None = None,
3089
+ max_input_tokens_per_batch: int | None = 13500,
3090
+ token_estimator: Callable[[str], int] | None = None,
3091
+ ) -> List[IndexingResponse] | Dict[str, List[IndexingResponse]]:
3092
+ from langchain_core.messages import HumanMessage, SystemMessage
3093
+ import inspect
3094
+
3095
+ if token_estimator is None:
3096
+ token_estimator = _default_token_estimate
3097
+
3098
+ prepared = _prepare_nodes(sem_nodes)
3099
+ id_map, dumped, node_by_id = prepared.id_map, prepared.dumped, prepared.node_by_id
3100
+
3101
+ system_flat = SystemMessage(
3102
+ "You will be sent nodes to index as JSON under key 'nodes'.\n"
3103
+ "Each element has node_id, title, kind=LEAF, contents=[verbatim strings].\n"
3104
+ "Return BatchIndexResponse.index with EXACTLY one IndexingResponse per input node_id."
3105
+ )
3106
+
3107
+ system_bottomup = SystemMessage(
3108
+ "You will be sent nodes to index as JSON under key 'nodes'.\n"
3109
+ "Each element has node_id, title, kind and either:\n"
3110
+ " - kind=LEAF: contents=[verbatim strings]\n"
3111
+ " - kind=PARENT: children=[digests of already-indexed child nodes]\n"
3112
+ "Return BatchIndexResponse.index with EXACTLY one IndexingResponse per input node_id.\n"
3113
+ "Rules:\n"
3114
+ " - LEAF: use only verbatim contents.\n"
3115
+ " - PARENT: use only children digests; do not hallucinate verbatim.\n"
3116
+ 'In the payload:\n'
3117
+ '- "t" means canonical title\n'
3118
+ '- "k" means keywords\n'
3119
+
3120
+ )
3121
+
3122
+ @memory.cache(ignore=["model_names"])
3123
+ def get_minibatch_result(messages, doc_id: str, model_names: list[str], all_ids: tuple[str, ...]):
3124
+ retries = 0
3125
+ retry_max = 3
3126
+ i_model = 0
3127
+ cur_messages = list(messages)
3128
+
3129
+ while True:
3130
+ token = available_node_ids.set(set(all_ids))
3131
+ cf = inspect.currentframe()
3132
+ line_no = cf.f_lineno if cf else None
3133
+
3134
+ model_name = model_names[i_model]
3135
+ llm: BaseChatModel = get_llm(model_name)
3136
+
3137
+ try:
3138
+ res: dict = llm.with_structured_output(BatchIndexResponse, include_raw=True).invoke(
3139
+ cur_messages,
3140
+ config={
3141
+ "metadata": {
3142
+ "document_id": doc_id,
3143
+ "event_name": "get_minibatch_result",
3144
+ "source_filename": __file__,
3145
+ "line_number": line_no,
3146
+ "n_try": retries,
3147
+ "model_name": model_name,
3148
+ }
3149
+ },
3150
+ ) # type: ignore
3151
+
3152
+ parsing_error = res.get("parsing_error")
3153
+ if parsing_error:
3154
+ cur_messages = cur_messages + [SystemMessage(f"Result Parsing error: {str(parsing_error)}")]
3155
+ continue
3156
+
3157
+ out_node_set = set(str(i.node_id) for i in res["parsed"].index)
3158
+ in_node_set = set(all_ids)
3159
+ if out_node_set != in_node_set:
3160
+ raise Exception(
3161
+ f"Extra output nodes: {(out_node_set - in_node_set) or None}, "
3162
+ f"unsatisfied input nodes: {(in_node_set - out_node_set) or None}"
3163
+ )
3164
+ return res
3165
+
3166
+ except Exception as e:
3167
+ cur_messages = cur_messages + [SystemMessage(str(e))]
3168
+
3169
+ finally:
3170
+ available_node_ids.reset(token)
3171
+ retries += 1
3172
+ if retries > retry_max:
3173
+ i_model += 1
3174
+ retries = 0
3175
+ cur_messages = cur_messages + [
3176
+ SystemMessage(f"model {model_name} failed too many times, switch to next model")
3177
+ ]
3178
+ if i_model >= len(model_names):
3179
+ raise Exception(f"All models failed; last model={model_name}")
3180
+
3181
+ def run_flat() -> list[IndexingResponse]:
3182
+ payload_nodes = [_leaf_payload(d) for d in dumped] # everything treated as leaf/verbatim
3183
+ out: list[IndexingResponse] = []
3184
+
3185
+ for batch in batch_nodes(
3186
+ payload_nodes,
3187
+ max_nodes=max_nodes_per_batch,
3188
+ max_input_tokens=max_input_tokens_per_batch,
3189
+ token_estimator=token_estimator,
3190
+ render_item_for_estimate=_json_compact,
3191
+ ):
3192
+ all_ids = tuple(str(n["node_id"]) for n in batch)
3193
+ messages = [system_flat, HumanMessage(_render_nodes_payload(batch))]
3194
+ res = get_minibatch_result(messages, doc_id, model_names, all_ids)
3195
+ out.extend(res["parsed"].index)
3196
+
3197
+ for r in out:
3198
+ r.node_id = str(id_map.to_uuid(r.node_id))
3199
+ return out
3200
+
3201
+ def run_bottom_up() -> list[IndexingResponse]:
3202
+ # build adjacency
3203
+ children_by_parent: dict[str, list[str]] = defaultdict(list)
3204
+ parent_by_node: dict[str, str] = {}
3205
+ for nid, d in node_by_id.items():
3206
+ pid = d.get("parent_id")
3207
+ if pid is None:
3208
+ continue
3209
+ pid = str(pid)
3210
+ parent_by_node[nid] = pid
3211
+ children_by_parent[pid].append(nid)
3212
+
3213
+ remaining_children = {nid: 0 for nid in node_by_id}
3214
+ for pid, kids in children_by_parent.items():
3215
+ remaining_children[pid] = len(kids)
3216
+
3217
+ ready = deque([nid for nid in node_by_id if remaining_children.get(nid, 0) == 0])
3218
+
3219
+ digest_by_id: dict[str, dict[str, Any]] = {}
3220
+ index_by_id: dict[str, IndexingResponse] = {}
3221
+ order: list[str] = []
3222
+ seen = 0
3223
+ total = len(node_by_id)
3224
+
3225
+ while ready:
3226
+ wave = []
3227
+ while ready:
3228
+ wave.append(ready.popleft())
3229
+ if not wave and seen < total:
3230
+ raise RuntimeError("Deadlock: no ready nodes but unprocessed nodes remain (cycle/missing parent)")
3231
+ wave_payload: list[dict[str, Any]] = []
3232
+ for nid in wave:
3233
+ kids = children_by_parent.get(nid, [])
3234
+ if not kids:
3235
+ wave_payload.append(_leaf_payload(node_by_id[nid]))
3236
+ else:
3237
+ try:
3238
+ verb_payload = _leaf_payload(node_by_id[nid])
3239
+ child_digests = [digest_by_id[cid] for cid in kids if cid in digest_by_id]
3240
+ digest_payload = _parent_payload(node_by_id[nid], child_digests)
3241
+ if (token_estimator(_render_nodes_payload(verb_payload)) >
3242
+ token_estimator(_render_nodes_payload(digest_payload))):
3243
+ payload = digest_payload
3244
+ else:
3245
+ payload = verb_payload
3246
+
3247
+ wave_payload.append(payload)
3248
+ except Exception as _e:
3249
+ raise
3250
+
3251
+
3252
+ for batch in batch_nodes(
3253
+ wave_payload,
3254
+ max_nodes=max_nodes_per_batch,
3255
+ max_input_tokens=max_input_tokens_per_batch,
3256
+ token_estimator=token_estimator,
3257
+ render_item_for_estimate=_json_compact,
3258
+ ):
3259
+ all_ids = tuple(str(n["node_id"]) for n in batch)
3260
+ messages = [system_bottomup, HumanMessage(_render_nodes_payload(batch))]
3261
+ res = get_minibatch_result(messages, doc_id, model_names, all_ids)
3262
+
3263
+ for r in res["parsed"].index:
3264
+ sid = str(r.node_id)
3265
+ index_by_id[sid] = r
3266
+ digest_by_id[sid] = digest_lite(r)# _digest_from_index(r)
3267
+
3268
+ order.extend([str(n["node_id"]) for n in batch])
3269
+
3270
+ for nid in wave:
3271
+ seen += 1
3272
+ pid = parent_by_node.get(nid)
3273
+ if pid:
3274
+ remaining_children[pid] -= 1
3275
+ if remaining_children[pid] == 0:
3276
+ ready.append(pid)
3277
+
3278
+ if seen > total:
3279
+ raise Exception("Bottom-up indexing did not finish; possible cycle or missing parent nodes.")
3280
+ if seen != total:
3281
+ raise RuntimeError(
3282
+ f"Did not finish topo pass: processed {seen}/{total}. "
3283
+ "Likely cycle or missing parent nodes."
3284
+ )
3285
+ out = []
3286
+ for sid in order:
3287
+ r = index_by_id[sid]
3288
+ r.node_id = str(id_map.to_uuid(r.node_id))
3289
+ out.append(r)
3290
+ return out
3291
+
3292
+ if mode == IndexMode.FLAT_VERBATIM:
3293
+ return run_flat()
3294
+ if mode == IndexMode.BOTTOM_UP_DIGEST:
3295
+ return run_bottom_up()
3296
+ if mode == IndexMode.BOTH:
3297
+ return {
3298
+ "flat_verbatim": run_flat(),
3299
+ "bottom_up_digest": run_bottom_up(),
3300
+ }
3301
+
3302
+ raise ValueError(f"Unknown mode: {mode}")