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,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from pydantic import model_validator
|
|
8
|
+
|
|
9
|
+
from kogwistar.id_provider import stable_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_text(text: str) -> str:
|
|
13
|
+
return re.sub(r"\s+", "", text or "")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HydratedTextPointer(BaseModel):
|
|
17
|
+
source_cluster_id: str
|
|
18
|
+
start_char: int
|
|
19
|
+
end_char: int
|
|
20
|
+
verbatim_text: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SemanticNode(BaseModel):
|
|
24
|
+
node_id: str | None = None
|
|
25
|
+
parent_id: str | None = None
|
|
26
|
+
node_type: str = "TEXT_FLOW"
|
|
27
|
+
title: str
|
|
28
|
+
total_content_pointers: list[HydratedTextPointer] = Field(default_factory=list)
|
|
29
|
+
child_nodes: list["SemanticNode"] = Field(default_factory=list)
|
|
30
|
+
level_from_root: int = 0
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _ensure_stable_node_id(self) -> "SemanticNode":
|
|
34
|
+
if self.node_id:
|
|
35
|
+
return self
|
|
36
|
+
pointer_fp = "|".join(
|
|
37
|
+
f"{ptr.source_cluster_id}:{ptr.start_char}:{ptr.end_char}:{ptr.verbatim_text}"
|
|
38
|
+
for ptr in self.total_content_pointers
|
|
39
|
+
)
|
|
40
|
+
self.node_id = str(
|
|
41
|
+
stable_id(
|
|
42
|
+
"workflow_ingest.semantic_node",
|
|
43
|
+
str(self.parent_id or "root"),
|
|
44
|
+
str(self.node_type),
|
|
45
|
+
str(self.title),
|
|
46
|
+
str(self.level_from_root),
|
|
47
|
+
pointer_fp,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
SemanticNode.model_rebuild()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def correct_and_validate_pointer(
|
|
57
|
+
pointer: HydratedTextPointer,
|
|
58
|
+
source_map: dict[str, dict[str, Any]],
|
|
59
|
+
) -> HydratedTextPointer | None:
|
|
60
|
+
source = source_map.get(pointer.source_cluster_id)
|
|
61
|
+
if source is None:
|
|
62
|
+
return None
|
|
63
|
+
text = source.get("text", "")
|
|
64
|
+
end_exclusive = len(text) if pointer.end_char == -1 else pointer.end_char + 1
|
|
65
|
+
actual = text[max(pointer.start_char, 0):max(end_exclusive, 0)]
|
|
66
|
+
if _normalize_text(actual) == _normalize_text(pointer.verbatim_text):
|
|
67
|
+
return pointer
|
|
68
|
+
if actual and (
|
|
69
|
+
_normalize_text(actual) in _normalize_text(pointer.verbatim_text)
|
|
70
|
+
or _normalize_text(pointer.verbatim_text) in _normalize_text(actual)
|
|
71
|
+
):
|
|
72
|
+
return HydratedTextPointer(
|
|
73
|
+
source_cluster_id=pointer.source_cluster_id,
|
|
74
|
+
start_char=max(pointer.start_char, 0),
|
|
75
|
+
end_char=max(end_exclusive - 1, -1),
|
|
76
|
+
verbatim_text=actual,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
occurrences = []
|
|
80
|
+
start = 0
|
|
81
|
+
needle = pointer.verbatim_text or ""
|
|
82
|
+
while needle:
|
|
83
|
+
idx = text.find(needle, start)
|
|
84
|
+
if idx == -1:
|
|
85
|
+
break
|
|
86
|
+
occurrences.append((idx, idx + len(needle) - 1))
|
|
87
|
+
start = idx + max(1, len(needle))
|
|
88
|
+
if not occurrences:
|
|
89
|
+
return None
|
|
90
|
+
best_start, best_end = min(occurrences, key=lambda item: abs(item[0] - pointer.start_char))
|
|
91
|
+
return HydratedTextPointer(
|
|
92
|
+
source_cluster_id=pointer.source_cluster_id,
|
|
93
|
+
start_char=best_start,
|
|
94
|
+
end_char=best_end,
|
|
95
|
+
verbatim_text=text[best_start:best_end + 1],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_pointer_coverage(
|
|
100
|
+
root_node: SemanticNode,
|
|
101
|
+
source_map: dict[str, dict[str, Any]],
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
ranges: dict[str, list[tuple[int, int]]] = {}
|
|
104
|
+
|
|
105
|
+
def walk(node: SemanticNode) -> None:
|
|
106
|
+
if node.node_type != "DOCUMENT_ROOT":
|
|
107
|
+
for ptr in node.total_content_pointers:
|
|
108
|
+
end = ptr.end_char
|
|
109
|
+
if end == -1:
|
|
110
|
+
end = max(0, len(source_map.get(ptr.source_cluster_id, {}).get("text", "")) - 1)
|
|
111
|
+
ranges.setdefault(ptr.source_cluster_id, []).append((ptr.start_char, end))
|
|
112
|
+
for child in node.child_nodes:
|
|
113
|
+
walk(child)
|
|
114
|
+
|
|
115
|
+
walk(root_node)
|
|
116
|
+
per_cluster: dict[str, float] = {}
|
|
117
|
+
total_len = 0
|
|
118
|
+
total_covered = 0
|
|
119
|
+
for cluster_id, cluster_ranges in ranges.items():
|
|
120
|
+
text = source_map.get(cluster_id, {}).get("text", "")
|
|
121
|
+
if not text:
|
|
122
|
+
continue
|
|
123
|
+
cluster_ranges.sort()
|
|
124
|
+
merged: list[tuple[int, int]] = []
|
|
125
|
+
cur_s, cur_e = cluster_ranges[0]
|
|
126
|
+
for s, e in cluster_ranges[1:]:
|
|
127
|
+
if s <= cur_e + 1:
|
|
128
|
+
cur_e = max(cur_e, e)
|
|
129
|
+
else:
|
|
130
|
+
merged.append((cur_s, cur_e))
|
|
131
|
+
cur_s, cur_e = s, e
|
|
132
|
+
merged.append((cur_s, cur_e))
|
|
133
|
+
covered = sum((e - s + 1) for s, e in merged)
|
|
134
|
+
per_cluster[cluster_id] = covered / len(text)
|
|
135
|
+
total_len += len(text)
|
|
136
|
+
total_covered += covered
|
|
137
|
+
overall = total_covered / total_len if total_len else 1.0
|
|
138
|
+
return {"per_cluster": per_cluster, "overall": overall}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def semantic_tree_to_kge_payload(root: SemanticNode, *, doc_id: str) -> dict[str, Any]:
|
|
142
|
+
nodes: list[dict[str, Any]] = []
|
|
143
|
+
edges: list[dict[str, Any]] = []
|
|
144
|
+
|
|
145
|
+
def spans(ptrs: list[HydratedTextPointer]) -> list[dict[str, Any]]:
|
|
146
|
+
if not ptrs:
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
"doc_id": doc_id,
|
|
150
|
+
"collection_page_url": f"doc://{doc_id}",
|
|
151
|
+
"document_page_url": f"doc://{doc_id}#synthetic",
|
|
152
|
+
"insertion_method": "workflow_ingest",
|
|
153
|
+
"page_number": 1,
|
|
154
|
+
"start_char": 0,
|
|
155
|
+
"end_char": 1,
|
|
156
|
+
"excerpt": " ",
|
|
157
|
+
"context_before": "",
|
|
158
|
+
"context_after": "",
|
|
159
|
+
"chunk_id": None,
|
|
160
|
+
"source_cluster_id": None,
|
|
161
|
+
"verification": None,
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
"doc_id": doc_id,
|
|
167
|
+
"collection_page_url": f"doc://{doc_id}",
|
|
168
|
+
"document_page_url": f"doc://{doc_id}#{p.source_cluster_id}",
|
|
169
|
+
"insertion_method": "workflow_ingest",
|
|
170
|
+
"page_number": 1,
|
|
171
|
+
"start_char": p.start_char,
|
|
172
|
+
"end_char": max(p.end_char + 1, p.start_char + max(len(p.verbatim_text), 1)),
|
|
173
|
+
"excerpt": p.verbatim_text,
|
|
174
|
+
"context_before": "",
|
|
175
|
+
"context_after": "",
|
|
176
|
+
"source_cluster_id": p.source_cluster_id,
|
|
177
|
+
"verification": None,
|
|
178
|
+
}
|
|
179
|
+
for p in ptrs
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
def walk(node: SemanticNode) -> None:
|
|
183
|
+
nodes.append(
|
|
184
|
+
{
|
|
185
|
+
"id": node.node_id,
|
|
186
|
+
"label": node.title,
|
|
187
|
+
"type": "entity",
|
|
188
|
+
"summary": node.title,
|
|
189
|
+
"metadata": {
|
|
190
|
+
"semantic_node_type": node.node_type,
|
|
191
|
+
"doc_id": doc_id,
|
|
192
|
+
"parent_id": node.parent_id,
|
|
193
|
+
"level_from_root": node.level_from_root,
|
|
194
|
+
},
|
|
195
|
+
"mentions": [{"spans": spans(node.total_content_pointers)}],
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
for child in node.child_nodes:
|
|
199
|
+
edges.append(
|
|
200
|
+
{
|
|
201
|
+
"id": str(
|
|
202
|
+
stable_id(
|
|
203
|
+
"workflow_ingest.edge",
|
|
204
|
+
"HAS_CHILD",
|
|
205
|
+
str(node.node_id),
|
|
206
|
+
str(child.node_id),
|
|
207
|
+
str(doc_id),
|
|
208
|
+
)
|
|
209
|
+
),
|
|
210
|
+
"label": "parent-child",
|
|
211
|
+
"type": "relationship",
|
|
212
|
+
"summary": f"{node.node_id}->{child.node_id}",
|
|
213
|
+
"relation": "HAS_CHILD",
|
|
214
|
+
"source_ids": [node.node_id],
|
|
215
|
+
"target_ids": [child.node_id],
|
|
216
|
+
"source_edge_ids": [],
|
|
217
|
+
"target_edge_ids": [],
|
|
218
|
+
"mentions": [
|
|
219
|
+
{
|
|
220
|
+
"spans": spans(
|
|
221
|
+
child.total_content_pointers or node.total_content_pointers
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
],
|
|
225
|
+
"metadata": {"doc_id": doc_id, "insertion_method": "workflow_ingest"},
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
walk(child)
|
|
229
|
+
|
|
230
|
+
walk(root)
|
|
231
|
+
return {"doc_id": doc_id, "insertion_method": "workflow_ingest", "nodes": nodes, "edges": edges}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from kogwistar.engine_core.engine import GraphKnowledgeEngine
|
|
7
|
+
from kogwistar.runtime.runtime import WorkflowRuntime
|
|
8
|
+
|
|
9
|
+
from .clients import DirectRuntimeIngestClient
|
|
10
|
+
from .design import DEFAULT_WORKFLOW_ID
|
|
11
|
+
from .handlers import build_ingest_step_resolver
|
|
12
|
+
from .models import IngestRunResult, WorkflowIngestInput
|
|
13
|
+
from .providers import WorkflowProviderSettings, build_embedding_function
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _TinyEmbeddingFunction:
|
|
17
|
+
_name = "kg-doc-parser-workflow-embedding-v1"
|
|
18
|
+
|
|
19
|
+
def name(self):
|
|
20
|
+
return self._name
|
|
21
|
+
|
|
22
|
+
def __call__(self, input):
|
|
23
|
+
vectors = []
|
|
24
|
+
for value in input:
|
|
25
|
+
text = str(value or "")
|
|
26
|
+
checksum = float((sum(ord(ch) for ch in text) % 97) + 1)
|
|
27
|
+
vectors.append([float(len(text) + 1), checksum])
|
|
28
|
+
return vectors
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_default_engines(
|
|
32
|
+
base_dir: str | Path,
|
|
33
|
+
*,
|
|
34
|
+
embedding_function=None,
|
|
35
|
+
backend_factory=None,
|
|
36
|
+
provider_settings: WorkflowProviderSettings | None = None,
|
|
37
|
+
):
|
|
38
|
+
base_dir = Path(base_dir)
|
|
39
|
+
provider_settings = provider_settings or WorkflowProviderSettings.from_env()
|
|
40
|
+
# One embedding function is still wired per engine instance. The workflow
|
|
41
|
+
# can carry embedding-space metadata, but engine-level routing is a future
|
|
42
|
+
# Kogwistar concern.
|
|
43
|
+
embedding = embedding_function or build_embedding_function(provider_settings.embedding)
|
|
44
|
+
workflow_engine = GraphKnowledgeEngine(
|
|
45
|
+
persist_directory=str(base_dir / "workflow"),
|
|
46
|
+
kg_graph_type="workflow",
|
|
47
|
+
embedding_function=embedding,
|
|
48
|
+
backend_factory=backend_factory,
|
|
49
|
+
)
|
|
50
|
+
conversation_engine = GraphKnowledgeEngine(
|
|
51
|
+
persist_directory=str(base_dir / "conversation"),
|
|
52
|
+
kg_graph_type="conversation",
|
|
53
|
+
embedding_function=embedding,
|
|
54
|
+
backend_factory=backend_factory,
|
|
55
|
+
)
|
|
56
|
+
knowledge_engine = GraphKnowledgeEngine(
|
|
57
|
+
persist_directory=str(base_dir / "knowledge"),
|
|
58
|
+
kg_graph_type="knowledge",
|
|
59
|
+
embedding_function=embedding,
|
|
60
|
+
backend_factory=backend_factory,
|
|
61
|
+
)
|
|
62
|
+
return workflow_engine, conversation_engine, knowledge_engine
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_runtime(*, workflow_engine, conversation_engine, deps: dict[str, Any] | None = None):
|
|
66
|
+
resolver = build_ingest_step_resolver(deps=deps)
|
|
67
|
+
return WorkflowRuntime(
|
|
68
|
+
workflow_engine=workflow_engine,
|
|
69
|
+
conversation_engine=conversation_engine,
|
|
70
|
+
step_resolver=resolver.resolve,
|
|
71
|
+
predicate_registry={},
|
|
72
|
+
trace=False,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_ingest_workflow(
|
|
77
|
+
*,
|
|
78
|
+
inp: WorkflowIngestInput,
|
|
79
|
+
workflow_engine,
|
|
80
|
+
conversation_engine,
|
|
81
|
+
knowledge_engine=None,
|
|
82
|
+
workflow_id: str = DEFAULT_WORKFLOW_ID,
|
|
83
|
+
deps: dict[str, Any] | None = None,
|
|
84
|
+
):
|
|
85
|
+
client = DirectRuntimeIngestClient(
|
|
86
|
+
workflow_engine=workflow_engine,
|
|
87
|
+
conversation_engine=conversation_engine,
|
|
88
|
+
knowledge_engine=knowledge_engine,
|
|
89
|
+
)
|
|
90
|
+
result = client.run_ingest(
|
|
91
|
+
inp=inp,
|
|
92
|
+
workflow_id=workflow_id,
|
|
93
|
+
deps=deps,
|
|
94
|
+
)
|
|
95
|
+
return _legacy_run_result(result)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _legacy_run_result(result: IngestRunResult):
|
|
99
|
+
class _RunCompat:
|
|
100
|
+
def __init__(self, *, run_id: str, final_state: dict[str, Any], status: str) -> None:
|
|
101
|
+
self.run_id = run_id
|
|
102
|
+
self.final_state = final_state
|
|
103
|
+
self.status = status
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
_RunCompat(
|
|
107
|
+
run_id=result.handle.run_id,
|
|
108
|
+
final_state=result.final_state,
|
|
109
|
+
status=result.status,
|
|
110
|
+
),
|
|
111
|
+
result.bundle,
|
|
112
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Reusable OCR smoke asset generation for manual and CLI workflows."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image, ImageDraw
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _draw_page(path: Path, *, title: str, lines: list[str]) -> None:
|
|
11
|
+
image = Image.new("RGB", (1400, 1000), "white")
|
|
12
|
+
draw = ImageDraw.Draw(image)
|
|
13
|
+
draw.text((80, 70), title, fill="black")
|
|
14
|
+
y = 160
|
|
15
|
+
for line in lines:
|
|
16
|
+
draw.text((80, y), line, fill="black")
|
|
17
|
+
y += 90
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
image.save(path, "PNG")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_pdf(image_paths: list[Path], pdf_path: Path) -> None:
|
|
23
|
+
images = [Image.open(path).convert("RGB") for path in image_paths]
|
|
24
|
+
first, rest = images[0], images[1:]
|
|
25
|
+
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
first.save(pdf_path, "PDF", save_all=True, append_images=rest)
|
|
27
|
+
for image in images:
|
|
28
|
+
image.close()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_ocr_smoke_assets(output_dir: Path) -> dict[str, str]:
|
|
32
|
+
"""Generate inspectable OCR smoke assets and return their file paths."""
|
|
33
|
+
|
|
34
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
page_1 = output_dir / "ocr_smoke_page_1.png"
|
|
36
|
+
page_2 = output_dir / "ocr_smoke_page_2.png"
|
|
37
|
+
pdf_path = output_dir / "ocr_smoke_document.pdf"
|
|
38
|
+
|
|
39
|
+
_draw_page(
|
|
40
|
+
page_1,
|
|
41
|
+
title="OCR Workflow Smoke Page 1",
|
|
42
|
+
lines=[
|
|
43
|
+
"Section 1. Overview",
|
|
44
|
+
"Alpha clause: the workflow should preserve page order.",
|
|
45
|
+
"Beta clause: per-page OCR artifacts should remain inspectable.",
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
_draw_page(
|
|
49
|
+
page_2,
|
|
50
|
+
title="OCR Workflow Smoke Page 2",
|
|
51
|
+
lines=[
|
|
52
|
+
"Section 2. Review",
|
|
53
|
+
"Gamma clause: downstream source maps should include OCR text.",
|
|
54
|
+
"Delta clause: resume state should survive reruns.",
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
_build_pdf([page_1, page_2], pdf_path)
|
|
58
|
+
return {
|
|
59
|
+
"page_1_png": str(page_1),
|
|
60
|
+
"page_2_png": str(page_2),
|
|
61
|
+
"pdf": str(pdf_path),
|
|
62
|
+
}
|