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.
- graph_knowledge_doc_parser-0.1.0.dist-info/METADATA +326 -0
- graph_knowledge_doc_parser-0.1.0.dist-info/RECORD +38 -0
- graph_knowledge_doc_parser-0.1.0.dist-info/WHEEL +4 -0
- graph_knowledge_doc_parser-0.1.0.dist-info/entry_points.txt +3 -0
- kg_doc_parser/__init__.py +9 -0
- kg_doc_parser/cast_hinting.py +19 -0
- kg_doc_parser/document_ingester_logger.py +766 -0
- kg_doc_parser/models.py +277 -0
- kg_doc_parser/ocr.py +752 -0
- kg_doc_parser/pdf2png.py +286 -0
- kg_doc_parser/semantic_document_splitting_layerwise_edits.py +3302 -0
- kg_doc_parser/text_processing_utils.py +30 -0
- kg_doc_parser/utils/__init__.py +0 -0
- kg_doc_parser/utils/bounded_threadpool_executor.py +37 -0
- kg_doc_parser/utils/file_loaders.py +405 -0
- kg_doc_parser/utils/langchain.py +220 -0
- kg_doc_parser/utils/log.py +135 -0
- kg_doc_parser/utils/version_chaining.py +1278 -0
- kg_doc_parser/workflow_ingest/__init__.py +187 -0
- kg_doc_parser/workflow_ingest/_kogwistar.py +13 -0
- kg_doc_parser/workflow_ingest/adapters.py +212 -0
- kg_doc_parser/workflow_ingest/cache.py +63 -0
- kg_doc_parser/workflow_ingest/cli.py +324 -0
- kg_doc_parser/workflow_ingest/clients.py +444 -0
- kg_doc_parser/workflow_ingest/demo_harness.py +427 -0
- kg_doc_parser/workflow_ingest/design.py +208 -0
- kg_doc_parser/workflow_ingest/handlers.py +617 -0
- kg_doc_parser/workflow_ingest/models.py +575 -0
- kg_doc_parser/workflow_ingest/ocr_pipeline.py +1581 -0
- kg_doc_parser/workflow_ingest/page_index.py +473 -0
- kg_doc_parser/workflow_ingest/parser_core.py +862 -0
- kg_doc_parser/workflow_ingest/parsing.py +249 -0
- kg_doc_parser/workflow_ingest/probe.py +164 -0
- kg_doc_parser/workflow_ingest/providers.py +412 -0
- kg_doc_parser/workflow_ingest/runners.py +546 -0
- kg_doc_parser/workflow_ingest/semantics.py +231 -0
- kg_doc_parser/workflow_ingest/service.py +112 -0
- 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}")
|