deeptutor-knowledge 1.7.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 (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,132 @@
1
+ """Resolve native LightRAG role choices inside the initiating user's scope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from deeptutor.multi_user.context import get_current_user
9
+ from deeptutor.multi_user.model_access import allowed_llm_options, apply_allowed_llm_selection
10
+ from deeptutor.multi_user.models import CurrentUser
11
+ from deeptutor.services.config.lightrag_roles import LightRagRoleModels
12
+ from deeptutor.services.llm.capabilities import supports_vision
13
+ from deeptutor.services.llm.config import LLMConfig
14
+ from deeptutor.services.model_selection.llm import LLMSelection
15
+ from deeptutor.services.model_selection.reasoning import supported_reasoning_efforts
16
+ from deeptutor.services.model_selection.runtime import resolve_llm_config_for_selection
17
+
18
+ ROLES = ("extract", "keyword", "query", "vlm")
19
+
20
+
21
+ def model_options() -> dict[str, Any]:
22
+ """Access-filtered choices shared by the editor and runtime validator."""
23
+ result = allowed_llm_options()
24
+ options = []
25
+ for row in result["options"]:
26
+ levels = row.get("supported_reasoning_efforts")
27
+ metadata = {"codex_supported_reasoning_levels": levels} if levels is not None else {}
28
+ metadata["capabilities"] = {"reasoning": row.get("declared_reasoning")}
29
+ options.append(
30
+ {
31
+ **row,
32
+ "supported_reasoning_efforts": supported_reasoning_efforts(
33
+ row["provider"], row["model"], metadata=metadata
34
+ ),
35
+ "supports_vision": row.get(
36
+ "declared_vision", supports_vision(row["provider"], row["model"])
37
+ ),
38
+ }
39
+ )
40
+ return {**result, "options": options}
41
+
42
+
43
+ def resolve_selection(
44
+ selection: LLMSelection, *, vision: bool = False, provider_default: bool = True
45
+ ) -> LLMConfig:
46
+ """Resolve an accessible model and validate its reasoning and vision support."""
47
+ apply_allowed_llm_selection(selection.to_dict())
48
+ config = resolve_llm_config_for_selection(selection)
49
+ if provider_default and selection.reasoning_effort is None:
50
+ # Empty explicitly omits provider reasoning controls; None retains the
51
+ # legacy resolver defaults when restoring an older pinned index.
52
+ config = config.model_copy(update={"reasoning_effort": ""})
53
+ option = next(
54
+ (
55
+ item
56
+ for item in model_options()["options"]
57
+ if item["profile_id"] == selection.profile_id and item["model_id"] == selection.model_id
58
+ ),
59
+ None,
60
+ )
61
+ if option is None:
62
+ raise ValueError("The selected model is unavailable.")
63
+ if config.reasoning_effort:
64
+ if config.reasoning_effort not in option["supported_reasoning_efforts"]:
65
+ raise ValueError("The selected model does not support this reasoning effort.")
66
+ if vision and not option["supports_vision"]:
67
+ raise ValueError("The selected VLM model does not support image inputs.")
68
+ return config
69
+
70
+
71
+ def settings_models(settings: dict[str, Any]) -> LightRagRoleModels | None:
72
+ """Parse configured role models, returning None for legacy settings."""
73
+ value = settings.get("role_models")
74
+ return LightRagRoleModels.model_validate(value) if "role_models" in settings else None
75
+
76
+
77
+ def validate_models(models: LightRagRoleModels) -> None:
78
+ """Validate access and capabilities for the base and all enabled roles."""
79
+ resolve_selection(LLMSelection(**models.base.model_dump()))
80
+ for role in ROLES:
81
+ selection = models.selection_for(role)
82
+ if selection is not None:
83
+ resolve_selection(LLMSelection(**selection.model_dump()), vision=role == "vlm")
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class RoleCall:
88
+ """Carry a resolved role model, initiating user, and execution limits."""
89
+
90
+ config: LLMConfig
91
+ owner: CurrentUser
92
+ max_async: int
93
+ timeout: int
94
+
95
+
96
+ def runtime_limits(settings: dict[str, Any]) -> dict[str, dict[str, int]]:
97
+ """Resolve per-role concurrency and timeouts, including legacy defaults."""
98
+ models = settings_models(settings)
99
+ return {
100
+ role: {
101
+ "max_async": getattr(models, role).max_async
102
+ if models
103
+ else int(settings.get("llm_model_max_async", 4)),
104
+ "timeout": getattr(models, role).timeout if models else 240,
105
+ }
106
+ for role in ROLES
107
+ }
108
+
109
+
110
+ def resolve_query_roles() -> dict[str, RoleCall]:
111
+ """Resolve keyword and query models within the initiating user scope."""
112
+ from deeptutor.services.config import load_lightrag_settings
113
+
114
+ from .config import resolve_lightrag_query_llm_config
115
+
116
+ settings = load_lightrag_settings()
117
+ models = settings_models(settings)
118
+ if models is None and settings.get("version") == 2:
119
+ raise ValueError("Choose and save a LightRAG base model before querying.")
120
+ limits = runtime_limits(settings)
121
+ owner = get_current_user()
122
+ result = {}
123
+ # The legacy resolver is intentionally used only for old-format settings.
124
+ legacy = resolve_lightrag_query_llm_config() if models is None else None
125
+ for role in ("keyword", "query"):
126
+ config = (
127
+ legacy
128
+ if models is None
129
+ else resolve_selection(LLMSelection(**models.selection_for(role).model_dump()))
130
+ )
131
+ result[role] = RoleCall(config=config, owner=owner, **limits[role])
132
+ return result
@@ -0,0 +1,320 @@
1
+ """Map frozen DeepTutor MinerU blocks to LightRAG's public Sidecar IR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Any
9
+
10
+
11
+ class SidecarMappingError(ValueError):
12
+ """Raised when a structured block cannot be represented without data loss."""
13
+
14
+
15
+ def _text(item: dict[str, Any]) -> str:
16
+ for key in ("text", "content", "body", "code_body"):
17
+ value = item.get(key)
18
+ if isinstance(value, str) and value.strip():
19
+ return value.strip()
20
+ return ""
21
+
22
+
23
+ def _strings(value: Any) -> list[str]:
24
+ if isinstance(value, list):
25
+ return [str(item).strip() for item in value if str(item).strip()]
26
+ value = str(value or "").strip()
27
+ return [value] if value else []
28
+
29
+
30
+ def _position(item: dict[str, Any]):
31
+ from lightrag.sidecar.ir import IRPosition
32
+
33
+ page = item.get("page_idx", item.get("page"))
34
+ if isinstance(page, bool):
35
+ page = None
36
+ elif isinstance(page, int):
37
+ page = str(page + 1) if page >= 0 else str(page)
38
+ elif page is not None:
39
+ page = str(page).strip() or None
40
+ bbox = item.get("bbox")
41
+ if isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
42
+ try:
43
+ return IRPosition(type="bbox", anchor=page, range=[float(value) for value in bbox[:4]])
44
+ except (TypeError, ValueError):
45
+ pass
46
+ return IRPosition(type="bbox", anchor=page) if page is not None else None
47
+
48
+
49
+ def _heading(item: dict[str, Any], kind: str) -> tuple[str, int]:
50
+ level = item.get("text_level", item.get("level", 0))
51
+ try:
52
+ level = int(level or 0)
53
+ except (TypeError, ValueError):
54
+ level = 0
55
+ if kind in {"title", "section_header"}:
56
+ return _text(item), max(level, 1)
57
+ if kind == "text" and level > 0:
58
+ return _text(item), level
59
+ return "", 0
60
+
61
+
62
+ def _rows(value: Any) -> list[list[str]] | None:
63
+ if isinstance(value, str):
64
+ stripped = value.strip()
65
+ if stripped.startswith("["):
66
+ try:
67
+ value = json.loads(stripped)
68
+ except json.JSONDecodeError:
69
+ return None
70
+ else:
71
+ return None
72
+ if not isinstance(value, list):
73
+ return None
74
+ rows: list[list[str]] = []
75
+ for raw_row in value:
76
+ if not isinstance(raw_row, list):
77
+ continue
78
+ rows.append(
79
+ [
80
+ str(cell.get("text", "")).strip() if isinstance(cell, dict) else str(cell).strip()
81
+ for cell in raw_row
82
+ ]
83
+ )
84
+ return rows or None
85
+
86
+
87
+ def _caption(item: dict[str, Any], *keys: str) -> str:
88
+ direct = str(item.get("caption") or "").strip()
89
+ if direct:
90
+ return direct
91
+ for key in keys:
92
+ values = _strings(item.get(key))
93
+ if values:
94
+ return values[0]
95
+ return ""
96
+
97
+
98
+ def _resolve_asset(bundle: Path, manifest: dict[str, Any], raw: str) -> Path | None:
99
+ if not raw:
100
+ return None
101
+ relative = Path(raw)
102
+ if relative.is_absolute() or ".." in relative.parts:
103
+ raise SidecarMappingError(f"Asset path escapes the frozen bundle: {raw}")
104
+ candidates: list[Path] = []
105
+ direct = bundle / "assets" / relative
106
+ if direct.is_file():
107
+ candidates.append(direct)
108
+ basename = relative.name
109
+ for record in manifest.get("assets", []):
110
+ path = bundle / str(record.get("path") or "")
111
+ if path.name == basename and path.is_file() and path not in candidates:
112
+ candidates.append(path)
113
+ if len(candidates) > 1:
114
+ raise SidecarMappingError(f"Asset basename is ambiguous in the frozen bundle: {raw}")
115
+ return candidates[0] if candidates else None
116
+
117
+
118
+ def build_ir(manifest: dict[str, Any], bundle: Path):
119
+ """Return an ``IRDoc`` using only LightRAG's public Sidecar types."""
120
+ from lightrag.sidecar.ir import (
121
+ AssetSpec,
122
+ IRBlock,
123
+ IRDoc,
124
+ IRDrawing,
125
+ IREquation,
126
+ IRTable,
127
+ )
128
+
129
+ blocks_record = manifest.get("blocks")
130
+ if not isinstance(blocks_record, dict):
131
+ raise SidecarMappingError("Sidecar mapping requires a blocks payload")
132
+ raw_blocks = json.loads((bundle / blocks_record["path"]).read_text(encoding="utf-8"))
133
+ if not isinstance(raw_blocks, list):
134
+ raise SidecarMappingError("Frozen blocks payload is not an array")
135
+
136
+ output: list[Any] = []
137
+ assets: list[Any] = []
138
+ asset_refs: dict[Path, str] = {}
139
+ heading_stack: list[str] = []
140
+ current_lines: list[str] = []
141
+ current_tables: list[Any] = []
142
+ current_drawings: list[Any] = []
143
+ current_equations: list[Any] = []
144
+ current_positions: list[Any] = []
145
+ current_heading = "Preface/Uncategorized"
146
+ current_level = 0
147
+ current_parents: list[str] = []
148
+ sequence = 0
149
+ document_title = ""
150
+
151
+ def next_key(prefix: str) -> str:
152
+ nonlocal sequence
153
+ sequence += 1
154
+ return f"{prefix}{sequence}"
155
+
156
+ def flush() -> None:
157
+ nonlocal \
158
+ current_lines, \
159
+ current_tables, \
160
+ current_drawings, \
161
+ current_equations, \
162
+ current_positions
163
+ content = "\n".join(line for line in current_lines if line).strip()
164
+ if not content and not (current_tables or current_drawings or current_equations):
165
+ current_lines = []
166
+ current_positions = []
167
+ return
168
+ output.append(
169
+ IRBlock(
170
+ content_template=content,
171
+ heading=current_heading,
172
+ level=current_level,
173
+ parent_headings=list(current_parents),
174
+ positions=list(current_positions),
175
+ tables=list(current_tables),
176
+ drawings=list(current_drawings),
177
+ equations=list(current_equations),
178
+ )
179
+ )
180
+ current_lines = []
181
+ current_tables = []
182
+ current_drawings = []
183
+ current_equations = []
184
+ current_positions = []
185
+
186
+ for index, item in enumerate(raw_blocks):
187
+ if not isinstance(item, dict):
188
+ raise SidecarMappingError(f"Structured block {index} is not an object")
189
+ kind = str(item.get("type") or item.get("label") or "").strip().lower()
190
+ heading, level = _heading(item, kind)
191
+ position = _position(item)
192
+ if heading:
193
+ flush()
194
+ heading = re.sub(r"^#{1,6}\s+", "", heading).strip()
195
+ heading_stack = heading_stack[: max(level - 1, 0)]
196
+ current_parents = [value for value in heading_stack if value]
197
+ heading_stack.append(heading)
198
+ current_heading = heading
199
+ current_level = level
200
+ current_lines.append(f"{'#' * min(level, 6)} {heading}")
201
+ if position is not None:
202
+ current_positions.append(position)
203
+ if not document_title and level == 1:
204
+ document_title = heading
205
+ continue
206
+
207
+ if kind in {"text", "aside_text", "page_footnote"}:
208
+ value = _text(item)
209
+ if value:
210
+ current_lines.append(value)
211
+ elif kind == "list":
212
+ values = item.get("list_items")
213
+ value = "\n".join(_strings(values)) if isinstance(values, list) else _text(item)
214
+ if value:
215
+ current_lines.append(value)
216
+ elif kind == "code":
217
+ value = str(item.get("code_body") or _text(item)).strip()
218
+ if value:
219
+ current_lines.extend(_strings(item.get("code_caption")))
220
+ current_lines.append(value)
221
+ current_lines.extend(_strings(item.get("code_footnote")))
222
+ elif kind == "table":
223
+ body = item.get("rows", item.get("table_body"))
224
+ table_rows = _rows(body)
225
+ table_html = (
226
+ body.strip() if isinstance(body, str) and "<table" in body.lower() else None
227
+ )
228
+ if not table_rows and not table_html:
229
+ raise SidecarMappingError(f"Structured table {index} has no usable body")
230
+ key = next_key("tb")
231
+ current_tables.append(
232
+ IRTable(
233
+ placeholder_key=key,
234
+ rows=table_rows,
235
+ html=table_html,
236
+ num_rows=int(item.get("num_rows") or (len(table_rows) if table_rows else 0)),
237
+ num_cols=int(
238
+ item.get("num_cols") or (max(map(len, table_rows)) if table_rows else 0)
239
+ ),
240
+ caption=_caption(item, "table_caption"),
241
+ footnotes=_strings(item.get("table_footnote") or item.get("footnotes")),
242
+ table_header=_rows(item.get("header")),
243
+ self_ref=f"blocks.json#/{index}",
244
+ )
245
+ )
246
+ current_lines.append(f"{{{{TBL:{key}}}}}")
247
+ elif kind in {"image", "picture", "drawing", "chart"}:
248
+ raw_path = str(item.get("img_path") or item.get("path") or "")
249
+ asset_path = _resolve_asset(bundle, manifest, raw_path)
250
+ if asset_path is None:
251
+ raise SidecarMappingError(f"Structured {kind} {index} has no verified bundle asset")
252
+ ref = ""
253
+ ref = asset_refs.get(asset_path, "")
254
+ if not ref:
255
+ ref = f"asset-{len(asset_refs) + 1}"
256
+ asset_refs[asset_path] = ref
257
+ assets.append(AssetSpec(ref=ref, suggested_name=asset_path.name, source=asset_path))
258
+ key = next_key("im")
259
+ current_drawings.append(
260
+ IRDrawing(
261
+ placeholder_key=key,
262
+ asset_ref=ref,
263
+ fmt=Path(raw_path).suffix.lower().lstrip(".") or str(item.get("format") or ""),
264
+ caption=_caption(item, "image_caption", "chart_caption", "captions"),
265
+ footnotes=_strings(
266
+ item.get("image_footnote")
267
+ or item.get("chart_footnote")
268
+ or item.get("footnotes")
269
+ ),
270
+ src=str(item.get("src") or ""),
271
+ self_ref=f"blocks.json#/{index}",
272
+ )
273
+ )
274
+ current_lines.append(f"{{{{IMG:{key}}}}}")
275
+ elif kind in {"equation", "formula"}:
276
+ latex = _text(item)
277
+ if not latex:
278
+ raise SidecarMappingError(f"Structured equation {index} has no LaTeX")
279
+ key = next_key("eq")
280
+ inline = str(item.get("text_format") or "").lower() in {"inline", "inline_equation"}
281
+ current_equations.append(
282
+ IREquation(
283
+ placeholder_key=key,
284
+ latex=latex,
285
+ is_block=not inline,
286
+ caption=_caption(item, "equation_caption"),
287
+ footnotes=_strings(item.get("equation_footnote") or item.get("footnotes")),
288
+ self_ref=f"blocks.json#/{index}" if not inline else "",
289
+ )
290
+ )
291
+ current_lines.append(f"{{{{{'EQI' if inline else 'EQ'}:{key}}}}}")
292
+ else:
293
+ value = _text(item)
294
+ if not value:
295
+ raise SidecarMappingError(
296
+ f"Unknown structured block type {kind or '<missing>'!r} at {index} has no preservable text"
297
+ )
298
+ current_lines.append(value)
299
+ if position is not None:
300
+ current_positions.append(position)
301
+
302
+ flush()
303
+ if not output:
304
+ raise SidecarMappingError("Structured document produced no Sidecar blocks")
305
+ name = str(manifest.get("canonical_filename") or "document")
306
+ return IRDoc(
307
+ document_name=name,
308
+ document_format=Path(name).suffix.lower().lstrip("."),
309
+ doc_title=document_title or Path(name).stem,
310
+ split_option={
311
+ "parser": str((manifest.get("parser") or {}).get("engine") or ""),
312
+ "parser_signature": str((manifest.get("parser") or {}).get("parser_signature") or ""),
313
+ },
314
+ blocks=output,
315
+ assets=assets,
316
+ bbox_attributes={"origin": "LEFTTOP", "max": 1000},
317
+ )
318
+
319
+
320
+ __all__ = ["SidecarMappingError", "build_ir"]