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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. graph_knowledge_doc_parser-0.1.0.dist-info/METADATA +326 -0
  2. graph_knowledge_doc_parser-0.1.0.dist-info/RECORD +38 -0
  3. graph_knowledge_doc_parser-0.1.0.dist-info/WHEEL +4 -0
  4. graph_knowledge_doc_parser-0.1.0.dist-info/entry_points.txt +3 -0
  5. kg_doc_parser/__init__.py +9 -0
  6. kg_doc_parser/cast_hinting.py +19 -0
  7. kg_doc_parser/document_ingester_logger.py +766 -0
  8. kg_doc_parser/models.py +277 -0
  9. kg_doc_parser/ocr.py +752 -0
  10. kg_doc_parser/pdf2png.py +286 -0
  11. kg_doc_parser/semantic_document_splitting_layerwise_edits.py +3302 -0
  12. kg_doc_parser/text_processing_utils.py +30 -0
  13. kg_doc_parser/utils/__init__.py +0 -0
  14. kg_doc_parser/utils/bounded_threadpool_executor.py +37 -0
  15. kg_doc_parser/utils/file_loaders.py +405 -0
  16. kg_doc_parser/utils/langchain.py +220 -0
  17. kg_doc_parser/utils/log.py +135 -0
  18. kg_doc_parser/utils/version_chaining.py +1278 -0
  19. kg_doc_parser/workflow_ingest/__init__.py +187 -0
  20. kg_doc_parser/workflow_ingest/_kogwistar.py +13 -0
  21. kg_doc_parser/workflow_ingest/adapters.py +212 -0
  22. kg_doc_parser/workflow_ingest/cache.py +63 -0
  23. kg_doc_parser/workflow_ingest/cli.py +324 -0
  24. kg_doc_parser/workflow_ingest/clients.py +444 -0
  25. kg_doc_parser/workflow_ingest/demo_harness.py +427 -0
  26. kg_doc_parser/workflow_ingest/design.py +208 -0
  27. kg_doc_parser/workflow_ingest/handlers.py +617 -0
  28. kg_doc_parser/workflow_ingest/models.py +575 -0
  29. kg_doc_parser/workflow_ingest/ocr_pipeline.py +1581 -0
  30. kg_doc_parser/workflow_ingest/page_index.py +473 -0
  31. kg_doc_parser/workflow_ingest/parser_core.py +862 -0
  32. kg_doc_parser/workflow_ingest/parsing.py +249 -0
  33. kg_doc_parser/workflow_ingest/probe.py +164 -0
  34. kg_doc_parser/workflow_ingest/providers.py +412 -0
  35. kg_doc_parser/workflow_ingest/runners.py +546 -0
  36. kg_doc_parser/workflow_ingest/semantics.py +231 -0
  37. kg_doc_parser/workflow_ingest/service.py +112 -0
  38. kg_doc_parser/workflow_ingest/smoke_assets.py +62 -0
@@ -0,0 +1,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
+ }