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,617 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from kogwistar.runtime import MappingStepResolver
7
+ from kogwistar.runtime.models import RunFailure, RunSuccess, RunSuspended
8
+
9
+ from .adapters import (
10
+ build_authoritative_source_map,
11
+ build_parser_input_dict,
12
+ build_parser_source_map,
13
+ select_primary_collection,
14
+ )
15
+ from .models import (
16
+ CanonicalGraphWriteResult,
17
+ CurrentLayerContext,
18
+ CurrentLayerReview,
19
+ CurrentLayerResult,
20
+ LayerFrontierItem,
21
+ ParseSessionState,
22
+ ValidationReport,
23
+ WorkflowExportBundle,
24
+ WorkflowIngestInput,
25
+ )
26
+ from .parser_core import (
27
+ apply_cud_update,
28
+ check_layer_coverage,
29
+ commit_layer_children,
30
+ dedupe_and_filter_layer,
31
+ default_parse_semantic_fn,
32
+ enqueue_next_layer_frontier,
33
+ finalize_semantic_tree,
34
+ initialize_parse_session,
35
+ prepare_layer_frontier,
36
+ propose_layer_breakdown,
37
+ repair_layer_candidates,
38
+ review_layer,
39
+ switch_split_strategy,
40
+ )
41
+ from .probe import emit_probe_event
42
+ from .semantics import (
43
+ SemanticNode,
44
+ compute_pointer_coverage,
45
+ correct_and_validate_pointer,
46
+ semantic_tree_to_kge_payload,
47
+ )
48
+
49
+ _LOGGER = logging.getLogger(__name__)
50
+
51
+
52
+ def _success(next_step: str | None = None) -> RunSuccess:
53
+ return RunSuccess(
54
+ conversation_node_id=None,
55
+ state_update=[],
56
+ _route_next=[] if next_step is None else [next_step],
57
+ )
58
+
59
+
60
+ def _probe_snapshot(state_view: dict[str, Any]) -> dict[str, Any]:
61
+ snapshot: dict[str, Any] = {
62
+ "state_keys": sorted(k for k, v in state_view.items() if v is not None),
63
+ }
64
+ frontier = state_view.get("layer_frontier_queue")
65
+ if isinstance(frontier, list):
66
+ snapshot["frontier_size"] = len(frontier)
67
+ current_layer_context = state_view.get("current_layer_context")
68
+ if isinstance(current_layer_context, dict):
69
+ snapshot["current_depth"] = current_layer_context.get("depth")
70
+ snapshot["retry_count"] = current_layer_context.get("retry_count")
71
+ snapshot["split_strategy"] = current_layer_context.get("split_strategy")
72
+ parse_session = state_view.get("parse_session")
73
+ if isinstance(parse_session, dict):
74
+ snapshot["parse_mode"] = parse_session.get("mode")
75
+ snapshot["session_depth"] = parse_session.get("current_depth")
76
+ snapshot["session_strategy"] = parse_session.get("split_strategy")
77
+ snapshot["strategy_switch_count"] = parse_session.get("strategy_switch_count")
78
+ return snapshot
79
+
80
+
81
+ def _progress_bar(done: int, total: int, width: int = 20) -> str:
82
+ if total <= 0:
83
+ return "?" * width
84
+ filled = min(width, max(0, round((done / total) * width)))
85
+ return ("█" * filled) + ("░" * (width - filled))
86
+
87
+
88
+ def _log_runtime_progress(*, step_name: str, state_view: dict[str, Any]) -> None:
89
+ current_layer_context = state_view.get("current_layer_context")
90
+ parse_session = state_view.get("parse_session")
91
+ if not isinstance(current_layer_context, dict) or not isinstance(parse_session, dict):
92
+ return
93
+ depth = int(current_layer_context.get("depth", 0))
94
+ max_depth = int(parse_session.get("max_depth", 10) or 10)
95
+ layer_num = depth + 1
96
+ retry_count = int(current_layer_context.get("retry_count", 0))
97
+ max_retries = int(current_layer_context.get("max_retries", 3) or 3)
98
+ strategy = str(current_layer_context.get("split_strategy", "excerpt_first"))
99
+ fallback_strategy = str(parse_session.get("fallback_split_strategy", "boundary_first"))
100
+ strategy_idx = 2 if strategy == fallback_strategy else 1
101
+ strategy_cap = 2
102
+ phase = step_name.replace("_", " ")
103
+ pct = round((layer_num / max_depth) * 100) if max_depth > 0 else 0
104
+ _LOGGER.info(
105
+ "⏳ layer %s/%s | %3s%% | %s | iter %s/%s | strategy %s/%s %s | %s",
106
+ layer_num,
107
+ max_depth,
108
+ pct,
109
+ _progress_bar(layer_num, max_depth),
110
+ retry_count + 1,
111
+ max_retries,
112
+ strategy_idx,
113
+ strategy_cap,
114
+ strategy,
115
+ phase,
116
+ )
117
+
118
+
119
+ def _register_step(
120
+ resolver: MappingStepResolver,
121
+ *,
122
+ step_name: str,
123
+ runtime_deps: dict[str, Any],
124
+ ):
125
+ probe = runtime_deps.get("probe")
126
+
127
+ def decorator(fn):
128
+ @resolver.register(step_name)
129
+ def _wrapped(ctx):
130
+ _log_runtime_progress(step_name=step_name, state_view=dict(ctx.state_view))
131
+ emit_probe_event(
132
+ probe,
133
+ "workflow.step_started",
134
+ step=step_name,
135
+ **_probe_snapshot(dict(ctx.state_view)),
136
+ )
137
+ try:
138
+ result = fn(ctx)
139
+ except Exception as exc:
140
+ emit_probe_event(
141
+ probe,
142
+ "workflow.step_exception",
143
+ step=step_name,
144
+ error=repr(exc),
145
+ **_probe_snapshot(dict(ctx.state_view)),
146
+ )
147
+ raise
148
+ if isinstance(result, RunFailure):
149
+ status = "failure"
150
+ elif isinstance(result, RunSuspended):
151
+ status = "suspended"
152
+ else:
153
+ status = "success"
154
+ emit_probe_event(
155
+ probe,
156
+ "workflow.step_finished",
157
+ step=step_name,
158
+ status=status,
159
+ route_next=list(getattr(result, "_route_next", []) or []),
160
+ **_probe_snapshot(dict(ctx.state_view)),
161
+ )
162
+ return result
163
+
164
+ return _wrapped
165
+
166
+ return decorator
167
+
168
+
169
+ def register_base_ingest_steps(resolver: MappingStepResolver, *, runtime_deps: dict[str, Any]) -> None:
170
+ @_register_step(resolver, step_name="start", runtime_deps=runtime_deps)
171
+ def _start(ctx):
172
+ return _success("normalize_input")
173
+
174
+ @_register_step(resolver, step_name="normalize_input", runtime_deps=runtime_deps)
175
+ def _normalize_input(ctx):
176
+ payload = WorkflowIngestInput.model_validate(ctx.state_view["input"]).model_dump(
177
+ field_mode="backend",
178
+ dump_format="json",
179
+ )
180
+ with ctx.state_write as st:
181
+ st["normalized_input"] = payload
182
+ return _success("build_source_map")
183
+
184
+ @_register_step(resolver, step_name="build_source_map", runtime_deps=runtime_deps)
185
+ def _build_source_map(ctx):
186
+ normalized = WorkflowIngestInput.model_validate(ctx.state_view["normalized_input"])
187
+ authoritative_source_map = build_authoritative_source_map(normalized)
188
+ collection = select_primary_collection(normalized)
189
+ parser_input_dict = build_parser_input_dict(collection)
190
+ parser_source_map = build_parser_source_map(authoritative_source_map)
191
+ with ctx.state_write as st:
192
+ st["authoritative_source_map"] = {
193
+ k: v.model_dump(field_mode="backend", dump_format="json")
194
+ for k, v in authoritative_source_map.items()
195
+ }
196
+ st["parser_input_dict"] = parser_input_dict
197
+ st["parser_source_map"] = parser_source_map
198
+ return _success("init_parse_session")
199
+
200
+ @_register_step(resolver, step_name="init_parse_session", runtime_deps=runtime_deps)
201
+ def _init_parse_session(ctx):
202
+ normalized = WorkflowIngestInput.model_validate(ctx.state_view["normalized_input"])
203
+ collection = select_primary_collection(normalized)
204
+ parse_semantic_fn = runtime_deps.get("parse_semantic_fn", default_parse_semantic_fn)
205
+ propose_layer_fn = runtime_deps.get("propose_layer_fn")
206
+ session, frontier, root = initialize_parse_session(
207
+ collection=collection,
208
+ parser_input_dict=ctx.state_view["parser_input_dict"],
209
+ parser_source_map=ctx.state_view["parser_source_map"],
210
+ max_depth=int(runtime_deps.get("max_depth", 10)),
211
+ allow_review=bool(runtime_deps.get("allow_review", True)),
212
+ split_strategy=str(runtime_deps.get("split_strategy", "excerpt_first")),
213
+ fallback_split_strategy=str(runtime_deps.get("fallback_split_strategy", "boundary_first")),
214
+ parse_semantic_fn=parse_semantic_fn if propose_layer_fn is None else None,
215
+ )
216
+ with ctx.state_write as st:
217
+ st["parse_session"] = session.model_dump(field_mode="backend", dump_format="json")
218
+ st["layer_frontier_queue"] = [
219
+ item.model_dump(field_mode="backend", dump_format="json") for item in frontier
220
+ ]
221
+ st["semantic_tree"] = root.model_dump()
222
+ return _success("check_frontier_remaining")
223
+
224
+
225
+ def register_layerwise_parser_steps(resolver: MappingStepResolver, *, runtime_deps: dict[str, Any]) -> None:
226
+ @_register_step(resolver, step_name="check_frontier_remaining", runtime_deps=runtime_deps)
227
+ def _check_frontier_remaining(ctx):
228
+ queue = ctx.state_view.get("layer_frontier_queue") or []
229
+ if queue:
230
+ return _success("prepare_layer_frontier")
231
+ return _success("finalize_semantic_tree")
232
+
233
+ @_register_step(resolver, step_name="prepare_layer_frontier", runtime_deps=runtime_deps)
234
+ def _prepare_layer_frontier(ctx):
235
+ parse_session = ParseSessionState.model_validate(ctx.state_view["parse_session"])
236
+ semantic_tree = SemanticNode.model_validate(ctx.state_view["semantic_tree"])
237
+ context, remaining, updated_session = prepare_layer_frontier(
238
+ parse_session=parse_session,
239
+ frontier_queue=[
240
+ LayerFrontierItem.model_validate(item)
241
+ for item in ctx.state_view["layer_frontier_queue"]
242
+ ],
243
+ semantic_tree=semantic_tree,
244
+ max_retries=int(runtime_deps.get("max_review_retries", 3)),
245
+ )
246
+ with ctx.state_write as st:
247
+ st["parse_session"] = updated_session.model_dump(field_mode="backend", dump_format="json")
248
+ st["current_layer_context"] = context.model_dump(field_mode="backend", dump_format="json")
249
+ st["layer_frontier_queue"] = [
250
+ item.model_dump(field_mode="backend", dump_format="json") for item in remaining
251
+ ]
252
+ return _success("propose_layer_breakdown")
253
+
254
+ @_register_step(resolver, step_name="propose_layer_breakdown", runtime_deps=runtime_deps)
255
+ def _propose_layer_breakdown(ctx):
256
+ normalized = WorkflowIngestInput.model_validate(ctx.state_view["normalized_input"])
257
+ collection = select_primary_collection(normalized)
258
+ parse_session = ParseSessionState.model_validate(ctx.state_view["parse_session"])
259
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
260
+ semantic_tree = SemanticNode.model_validate(ctx.state_view["semantic_tree"])
261
+ result = propose_layer_breakdown(
262
+ collection=collection,
263
+ parser_input_dict=ctx.state_view["parser_input_dict"],
264
+ parser_source_map=ctx.state_view["parser_source_map"],
265
+ parse_session=parse_session,
266
+ current_layer_context=current_layer_context,
267
+ semantic_tree=semantic_tree,
268
+ propose_layer_fn=runtime_deps.get("propose_layer_fn"),
269
+ llm_cache=runtime_deps.get("llm_cache"),
270
+ )
271
+ with ctx.state_write as st:
272
+ st["current_layer_result"] = result.model_dump(field_mode="backend", dump_format="json")
273
+ return _success("review_cud_proposal")
274
+
275
+ @_register_step(resolver, step_name="review_cud_proposal", runtime_deps=runtime_deps)
276
+ def _review_cud_proposal(ctx):
277
+ parse_session = ParseSessionState.model_validate(ctx.state_view["parse_session"])
278
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
279
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
280
+ reviewed, updated_session = review_layer(
281
+ parse_session=parse_session,
282
+ current_layer_context=current_layer_context,
283
+ current_layer_result=current_layer_result,
284
+ parser_source_map=ctx.state_view["parser_source_map"],
285
+ review_layer_fn=runtime_deps.get("review_layer_fn"),
286
+ llm_cache=runtime_deps.get("llm_cache"),
287
+ )
288
+ updated_context = current_layer_context.model_copy(
289
+ update={"retry_count": current_layer_context.retry_count + 1}
290
+ )
291
+ with ctx.state_write as st:
292
+ st["parse_session"] = updated_session.model_dump(field_mode="backend", dump_format="json")
293
+ st["current_layer_context"] = updated_context.model_dump(
294
+ field_mode="backend",
295
+ dump_format="json",
296
+ )
297
+ st["current_layer_review"] = reviewed.model_dump(field_mode="backend", dump_format="json")
298
+ return _success("apply_cud_update")
299
+
300
+ @_register_step(resolver, step_name="apply_cud_update", runtime_deps=runtime_deps)
301
+ def _apply_cud_update(ctx):
302
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
303
+ current_layer_review = CurrentLayerReview.model_validate(ctx.state_view["current_layer_review"])
304
+ updated_result = apply_cud_update(
305
+ current_layer_result=current_layer_result,
306
+ current_layer_review=current_layer_review,
307
+ )
308
+ with ctx.state_write as st:
309
+ st["current_layer_result"] = updated_result.model_dump(
310
+ field_mode="backend",
311
+ dump_format="json",
312
+ )
313
+ return _success("check_layer_coverage")
314
+
315
+ @_register_step(resolver, step_name="check_layer_coverage", runtime_deps=runtime_deps)
316
+ def _check_layer_coverage(ctx):
317
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
318
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
319
+ current_layer_review = CurrentLayerReview.model_validate(ctx.state_view["current_layer_review"])
320
+ coverage_ok, coverage_notes = check_layer_coverage(
321
+ current_layer_context=current_layer_context,
322
+ current_layer_result=current_layer_result,
323
+ current_layer_review=current_layer_review,
324
+ )
325
+ merged_review = current_layer_review.model_copy(
326
+ update={
327
+ "coverage_ok": coverage_ok,
328
+ "review_notes": list(current_layer_review.review_notes) + [
329
+ note
330
+ for note in coverage_notes
331
+ if note not in current_layer_review.review_notes
332
+ ],
333
+ }
334
+ )
335
+ with ctx.state_write as st:
336
+ st["current_layer_review"] = merged_review.model_dump(
337
+ field_mode="backend",
338
+ dump_format="json",
339
+ )
340
+ return _success("check_layer_satisfaction")
341
+
342
+ @_register_step(resolver, step_name="check_layer_satisfaction", runtime_deps=runtime_deps)
343
+ def _check_layer_satisfaction(ctx):
344
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
345
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
346
+ current_layer_review = CurrentLayerReview.model_validate(ctx.state_view["current_layer_review"])
347
+ coverage_ok = current_layer_review.coverage_ok is not False
348
+ has_conflicts = bool(
349
+ current_layer_review.overlap_conflicts
350
+ or current_layer_review.coverage_gap_notes
351
+ or current_layer_review.duplicate_child_notes
352
+ )
353
+ if current_layer_result.satisfied is False or not coverage_ok or has_conflicts:
354
+ if current_layer_context.retry_count >= current_layer_context.max_retries:
355
+ reasons = list(current_layer_review.review_notes)
356
+ if current_layer_result.satisfied is False:
357
+ reasons.append("layer marked unsatisfied")
358
+ if not coverage_ok:
359
+ reasons.append("layer coverage check failed")
360
+ if has_conflicts:
361
+ reasons.append(
362
+ f"layer has {len(current_layer_review.overlap_conflicts)} overlap conflicts, "
363
+ f"{len(current_layer_review.coverage_gap_notes)} coverage gaps, "
364
+ f"{len(current_layer_review.duplicate_child_notes)} duplicates"
365
+ )
366
+ parse_session = ParseSessionState.model_validate(ctx.state_view["parse_session"])
367
+ if (
368
+ current_layer_context.split_strategy != parse_session.fallback_split_strategy
369
+ and parse_session.strategy_switch_count == 0
370
+ ):
371
+ return _success("switch_split_strategy")
372
+ error_message = (
373
+ f"layer satisfaction retries exhausted at depth {current_layer_context.depth} "
374
+ f"using strategy {current_layer_context.split_strategy}"
375
+ )
376
+ return RunFailure(
377
+ conversation_node_id=None,
378
+ state_update=[],
379
+ update={"workflow_errors": [error_message, *reasons]},
380
+ errors=[error_message],
381
+ )
382
+ return _success("propose_layer_breakdown")
383
+ return _success("repair_layer_pointers")
384
+
385
+ @_register_step(resolver, step_name="switch_split_strategy", runtime_deps=runtime_deps)
386
+ def _switch_split_strategy(ctx):
387
+ parse_session = ParseSessionState.model_validate(ctx.state_view["parse_session"])
388
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
389
+ updated_session, updated_context = switch_split_strategy(
390
+ parse_session=parse_session,
391
+ current_layer_context=current_layer_context,
392
+ )
393
+ with ctx.state_write as st:
394
+ st["parse_session"] = updated_session.model_dump(field_mode="backend", dump_format="json")
395
+ st["current_layer_context"] = updated_context.model_dump(field_mode="backend", dump_format="json")
396
+ st["current_layer_review"] = None
397
+ return _success("propose_layer_breakdown")
398
+
399
+ @_register_step(resolver, step_name="repair_layer_pointers", runtime_deps=runtime_deps)
400
+ def _repair_layer_pointers(ctx):
401
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
402
+ repaired, repaired_count = repair_layer_candidates(
403
+ current_layer_result=current_layer_result,
404
+ parser_source_map=ctx.state_view["parser_source_map"],
405
+ correct_pointer_fn=correct_and_validate_pointer,
406
+ )
407
+ with ctx.state_write as st:
408
+ st["current_layer_result"] = repaired.model_dump(field_mode="backend", dump_format="json")
409
+ st["corrected_pointer_count"] = int(ctx.state_view.get("corrected_pointer_count", 0)) + repaired_count
410
+ return _success("dedupe_and_filter_layer")
411
+
412
+ @_register_step(resolver, step_name="dedupe_and_filter_layer", runtime_deps=runtime_deps)
413
+ def _dedupe_and_filter_layer(ctx):
414
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
415
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
416
+ filtered = dedupe_and_filter_layer(
417
+ current_layer_context=current_layer_context,
418
+ current_layer_result=current_layer_result,
419
+ )
420
+ with ctx.state_write as st:
421
+ st["current_layer_result"] = filtered.model_dump(field_mode="backend", dump_format="json")
422
+ return _success("commit_layer_children")
423
+
424
+ @_register_step(resolver, step_name="commit_layer_children", runtime_deps=runtime_deps)
425
+ def _commit_layer_children(ctx):
426
+ semantic_tree = SemanticNode.model_validate(ctx.state_view["semantic_tree"])
427
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
428
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
429
+ updated_tree = commit_layer_children(
430
+ semantic_tree=semantic_tree,
431
+ current_layer_result=current_layer_result,
432
+ current_depth=current_layer_context.depth,
433
+ )
434
+ with ctx.state_write as st:
435
+ st["semantic_tree"] = updated_tree.model_dump()
436
+ return _success("check_children_expandable")
437
+
438
+ @_register_step(resolver, step_name="check_children_expandable", runtime_deps=runtime_deps)
439
+ def _check_children_expandable(ctx):
440
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
441
+ if any(child.expandable for child in current_layer_result.children):
442
+ return _success("enqueue_next_layer_frontier")
443
+ return _success("check_frontier_remaining")
444
+
445
+ @_register_step(resolver, step_name="enqueue_next_layer_frontier", runtime_deps=runtime_deps)
446
+ def _enqueue_next_layer_frontier(ctx):
447
+ parse_session = ParseSessionState.model_validate(ctx.state_view["parse_session"])
448
+ current_layer_context = CurrentLayerContext.model_validate(ctx.state_view["current_layer_context"])
449
+ current_layer_result = CurrentLayerResult.model_validate(ctx.state_view["current_layer_result"])
450
+ frontier_queue = [
451
+ LayerFrontierItem.model_validate(item)
452
+ for item in ctx.state_view.get("layer_frontier_queue", [])
453
+ ]
454
+ updated_queue = enqueue_next_layer_frontier(
455
+ frontier_queue=frontier_queue,
456
+ current_layer_context=current_layer_context,
457
+ current_layer_result=current_layer_result,
458
+ parse_session=parse_session,
459
+ )
460
+ with ctx.state_write as st:
461
+ st["layer_frontier_queue"] = [
462
+ item.model_dump(field_mode="backend", dump_format="json") for item in updated_queue
463
+ ]
464
+ st["current_layer_context"] = None
465
+ st["current_layer_result"] = None
466
+ st["current_layer_review"] = None
467
+ return _success("check_frontier_remaining")
468
+
469
+ @_register_step(resolver, step_name="finalize_semantic_tree", runtime_deps=runtime_deps)
470
+ def _finalize_semantic_tree(ctx):
471
+ tree = finalize_semantic_tree(SemanticNode.model_validate(ctx.state_view["semantic_tree"]))
472
+ with ctx.state_write as st:
473
+ st["semantic_tree"] = tree.model_dump()
474
+ return _success("validate_tree")
475
+
476
+
477
+ def register_postparse_steps(resolver: MappingStepResolver, *, runtime_deps: dict[str, Any]) -> None:
478
+ @_register_step(resolver, step_name="validate_tree", runtime_deps=runtime_deps)
479
+ def _validate_tree(ctx):
480
+ tree = SemanticNode.model_validate(ctx.state_view["semantic_tree"])
481
+ authoritative_source_map = ctx.state_view["authoritative_source_map"]
482
+ text_only_map = {
483
+ unit_id: {"text": rec["parser_text"], "id": unit_id}
484
+ for unit_id, rec in authoritative_source_map.items()
485
+ if rec.get("participates_in_semantic_text", True)
486
+ }
487
+ coverage = compute_pointer_coverage(tree, text_only_map)
488
+ report = ValidationReport(
489
+ overall_text_coverage=coverage["overall"],
490
+ per_cluster_coverage=coverage["per_cluster"],
491
+ corrected_pointer_count=int(ctx.state_view.get("corrected_pointer_count", 0)),
492
+ validation_notes=[],
493
+ )
494
+ threshold = float(runtime_deps.get("coverage_threshold", 0.99))
495
+ if report.overall_text_coverage < threshold:
496
+ error_message = (
497
+ f"text coverage below threshold: {report.overall_text_coverage:.3f} < {threshold:.3f}"
498
+ )
499
+ return RunFailure(
500
+ conversation_node_id=None,
501
+ state_update=[],
502
+ update={
503
+ "workflow_errors": [error_message],
504
+ "validation_report": report.model_dump(field_mode="backend", dump_format="json"),
505
+ },
506
+ errors=[error_message],
507
+ )
508
+ with ctx.state_write as st:
509
+ st["validation_report"] = report.model_dump(field_mode="backend", dump_format="json")
510
+ return _success("export_graph")
511
+
512
+ @_register_step(resolver, step_name="export_graph", runtime_deps=runtime_deps)
513
+ def _export_graph(ctx):
514
+ normalized = WorkflowIngestInput.model_validate(ctx.state_view["normalized_input"])
515
+ collection = select_primary_collection(normalized)
516
+ tree = SemanticNode.model_validate(ctx.state_view["semantic_tree"])
517
+ graph_payload = semantic_tree_to_kge_payload(tree, doc_id=collection.collection_id)
518
+ persistence_mode = str(runtime_deps.get("persistence_mode", "local_debug"))
519
+ kg_authority = str(runtime_deps.get("kg_authority", "local"))
520
+ bundle = WorkflowExportBundle(
521
+ graph_payload=graph_payload,
522
+ authoritative_source_map=ctx.state_view["authoritative_source_map"],
523
+ embedding_spaces=collection.embedding_spaces,
524
+ consolidation_candidates=[],
525
+ retrieval_metadata={
526
+ "embedding_spaces": collection.embedding_spaces,
527
+ "supports_hybrid_retrieval": True,
528
+ "supports_split_embedding_spaces": True,
529
+ "collection_modality": collection.modality,
530
+ },
531
+ persistence_mode="server_canonical" if persistence_mode == "server_canonical" else "local_debug",
532
+ kg_authority="server" if kg_authority == "server" else "local",
533
+ canonical_write_confirmed=False,
534
+ parser_owner="local",
535
+ server_parser_used=False,
536
+ persisted_to_knowledge_engine=False,
537
+ )
538
+ with ctx.state_write as st:
539
+ st["export_bundle"] = bundle.model_dump(field_mode="backend", dump_format="json")
540
+ return _success("persist_canonical_graph")
541
+
542
+ @_register_step(resolver, step_name="persist_canonical_graph", runtime_deps=runtime_deps)
543
+ def _persist_canonical_graph(ctx):
544
+ bundle = WorkflowExportBundle.model_validate(ctx.state_view["export_bundle"])
545
+ persistence_client = runtime_deps.get("graph_persistence_client")
546
+ if persistence_client is None:
547
+ return RunFailure(
548
+ conversation_node_id=None,
549
+ state_update=[],
550
+ errors=["no graph persistence client configured"],
551
+ )
552
+ write_result = persistence_client.persist_graph_payload(bundle)
553
+ if isinstance(write_result, dict):
554
+ write_result = CanonicalGraphWriteResult.model_validate(write_result)
555
+ emit_probe_event(
556
+ runtime_deps.get("probe"),
557
+ "workflow.persistence_result",
558
+ persistence_mode=write_result.persistence_mode,
559
+ kg_authority=write_result.kg_authority,
560
+ canonical_write_confirmed=write_result.canonical_write_confirmed,
561
+ nodes_written=write_result.nodes_written,
562
+ edges_written=write_result.edges_written,
563
+ transport=write_result.transport,
564
+ )
565
+ updated_bundle = bundle.model_copy(
566
+ update={
567
+ "persistence_mode": write_result.persistence_mode,
568
+ "kg_authority": write_result.kg_authority,
569
+ "canonical_write_confirmed": write_result.canonical_write_confirmed,
570
+ "server_parser_used": write_result.server_parser_used,
571
+ "canonical_write_result": write_result,
572
+ "persisted_to_knowledge_engine": write_result.persistence_mode == "local_debug"
573
+ and (write_result.nodes_written > 0 or write_result.edges_written > 0),
574
+ }
575
+ )
576
+ with ctx.state_write as st:
577
+ st["canonical_write_result"] = write_result.model_dump(
578
+ field_mode="backend",
579
+ dump_format="json",
580
+ )
581
+ st["export_bundle"] = updated_bundle.model_dump(
582
+ field_mode="backend",
583
+ dump_format="json",
584
+ )
585
+ return _success("end")
586
+
587
+ @_register_step(resolver, step_name="end", runtime_deps=runtime_deps)
588
+ def _end(ctx):
589
+ return _success(None)
590
+
591
+
592
+ def build_ingest_step_resolver(*, deps: dict[str, Any] | None = None) -> MappingStepResolver:
593
+ runtime_deps = dict(deps or {})
594
+ resolver = MappingStepResolver()
595
+ resolver.set_state_schema(
596
+ {
597
+ "normalized_input": "u",
598
+ "authoritative_source_map": "u",
599
+ "parser_input_dict": "u",
600
+ "parser_source_map": "u",
601
+ "parse_session": "u",
602
+ "layer_frontier_queue": "u",
603
+ "current_layer_context": "u",
604
+ "current_layer_result": "u",
605
+ "current_layer_review": "u",
606
+ "semantic_tree": "u",
607
+ "corrected_pointer_count": "u",
608
+ "validation_report": "u",
609
+ "export_bundle": "u",
610
+ "canonical_write_result": "u",
611
+ "workflow_errors": "a",
612
+ }
613
+ )
614
+ register_base_ingest_steps(resolver, runtime_deps=runtime_deps)
615
+ register_layerwise_parser_steps(resolver, runtime_deps=runtime_deps)
616
+ register_postparse_steps(resolver, runtime_deps=runtime_deps)
617
+ return resolver