wenyi-core 1.4.2__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 (253) hide show
  1. wenyi_core/__init__.py +1 -0
  2. wenyi_core/agents/__init__.py +1 -0
  3. wenyi_core/agents/analyzer.py +109 -0
  4. wenyi_core/agents/annotation_aligner.py +521 -0
  5. wenyi_core/agents/base.py +223 -0
  6. wenyi_core/agents/glossary_extractor.py +358 -0
  7. wenyi_core/agents/polisher.py +98 -0
  8. wenyi_core/agents/precision.py +186 -0
  9. wenyi_core/agents/prompts.py +105 -0
  10. wenyi_core/agents/review_actions.py +389 -0
  11. wenyi_core/agents/review_arbiter.py +211 -0
  12. wenyi_core/agents/review_fixer.py +354 -0
  13. wenyi_core/agents/review_loop.py +268 -0
  14. wenyi_core/agents/reviewer.py +173 -0
  15. wenyi_core/agents/synopsis.py +157 -0
  16. wenyi_core/agents/terminology.py +429 -0
  17. wenyi_core/agents/title_translator.py +41 -0
  18. wenyi_core/agents/translator.py +281 -0
  19. wenyi_core/assemble/__init__.py +1 -0
  20. wenyi_core/assemble/about.py +136 -0
  21. wenyi_core/assemble/docx_blocks.py +354 -0
  22. wenyi_core/assemble/docx_numbering.py +83 -0
  23. wenyi_core/assemble/docx_styles.py +250 -0
  24. wenyi_core/assemble/docx_writer.py +69 -0
  25. wenyi_core/assemble/epub_navigation.py +199 -0
  26. wenyi_core/assemble/epub_presentation.py +152 -0
  27. wenyi_core/assemble/epub_resources.py +197 -0
  28. wenyi_core/assemble/epub_writer.py +388 -0
  29. wenyi_core/assemble/export_view.py +156 -0
  30. wenyi_core/assemble/html_bilingual.py +214 -0
  31. wenyi_core/assemble/html_inline.py +344 -0
  32. wenyi_core/assemble/html_renderer.py +237 -0
  33. wenyi_core/assemble/html_resources.py +230 -0
  34. wenyi_core/assemble/html_writer.py +175 -0
  35. wenyi_core/assemble/pdf_writer.py +18 -0
  36. wenyi_core/assemble/policy.py +93 -0
  37. wenyi_core/assemble/text_writer.py +63 -0
  38. wenyi_core/assemble/writer.py +179 -0
  39. wenyi_core/assemble/writer_common.py +185 -0
  40. wenyi_core/atomicio.py +82 -0
  41. wenyi_core/config.py +405 -0
  42. wenyi_core/document_styles/__init__.py +1 -0
  43. wenyi_core/document_styles/docx.py +147 -0
  44. wenyi_core/errors.py +59 -0
  45. wenyi_core/events.py +67 -0
  46. wenyi_core/glossary/__init__.py +5 -0
  47. wenyi_core/glossary/anki.py +317 -0
  48. wenyi_core/glossary/evidence.py +111 -0
  49. wenyi_core/glossary/evidence_models.py +46 -0
  50. wenyi_core/glossary/resolver.py +18 -0
  51. wenyi_core/glossary/store.py +487 -0
  52. wenyi_core/i18n/__init__.py +5 -0
  53. wenyi_core/i18n/data/export/about.en.xhtml +18 -0
  54. wenyi_core/i18n/data/export/about.zh.xhtml +39 -0
  55. wenyi_core/i18n/data/languages/de.json +10 -0
  56. wenyi_core/i18n/data/languages/en-GB.json +6 -0
  57. wenyi_core/i18n/data/languages/en-US.json +6 -0
  58. wenyi_core/i18n/data/languages/en.json +10 -0
  59. wenyi_core/i18n/data/languages/es.json +10 -0
  60. wenyi_core/i18n/data/languages/fr.json +10 -0
  61. wenyi_core/i18n/data/languages/it.json +10 -0
  62. wenyi_core/i18n/data/languages/ja.json +19 -0
  63. wenyi_core/i18n/data/languages/ko.json +10 -0
  64. wenyi_core/i18n/data/languages/pt-BR.json +6 -0
  65. wenyi_core/i18n/data/languages/pt-PT.json +6 -0
  66. wenyi_core/i18n/data/languages/pt.json +10 -0
  67. wenyi_core/i18n/data/languages/registry.json +97 -0
  68. wenyi_core/i18n/data/languages/ru.json +10 -0
  69. wenyi_core/i18n/data/languages/vi.json +10 -0
  70. wenyi_core/i18n/data/languages/zh-Hant.json +19 -0
  71. wenyi_core/i18n/data/languages/zh.json +24 -0
  72. wenyi_core/i18n/data/pairs/ja__zh.json +5 -0
  73. wenyi_core/i18n/data/pairs/registry.json +3 -0
  74. wenyi_core/i18n/data/shared/guidance.json +4 -0
  75. wenyi_core/i18n/data/shared/honorific.json +5 -0
  76. wenyi_core/i18n/data/shared/metadata_guidance.txt +4 -0
  77. wenyi_core/i18n/data/shared/review_evidence_tools.txt +14 -0
  78. wenyi_core/i18n/data/tasks/analyzer_system.txt +17 -0
  79. wenyi_core/i18n/data/tasks/analyzer_user.txt +5 -0
  80. wenyi_core/i18n/data/tasks/book_synopsis_system.txt +3 -0
  81. wenyi_core/i18n/data/tasks/book_synopsis_user.txt +7 -0
  82. wenyi_core/i18n/data/tasks/chapter_digest_system.txt +2 -0
  83. wenyi_core/i18n/data/tasks/chapter_digest_user.txt +4 -0
  84. wenyi_core/i18n/data/tasks/glossary_extractor_system.txt +16 -0
  85. wenyi_core/i18n/data/tasks/glossary_extractor_user.txt +10 -0
  86. wenyi_core/i18n/data/tasks/glossary_history_system.txt +6 -0
  87. wenyi_core/i18n/data/tasks/glossary_history_user.txt +4 -0
  88. wenyi_core/i18n/data/tasks/language_detector_system.txt +3 -0
  89. wenyi_core/i18n/data/tasks/polisher_continue_user.txt +10 -0
  90. wenyi_core/i18n/data/tasks/polisher_system.txt +6 -0
  91. wenyi_core/i18n/data/tasks/polisher_terminology_user.txt +15 -0
  92. wenyi_core/i18n/data/tasks/polisher_user.txt +13 -0
  93. wenyi_core/i18n/data/tasks/precision_generation_system.txt +9 -0
  94. wenyi_core/i18n/data/tasks/precision_packet.txt +2 -0
  95. wenyi_core/i18n/data/tasks/precision_synthesize.txt +5 -0
  96. wenyi_core/i18n/data/tasks/precision_translate.txt +3 -0
  97. wenyi_core/i18n/data/tasks/review_agent_system.txt +32 -0
  98. wenyi_core/i18n/data/tasks/review_agent_user.txt +12 -0
  99. wenyi_core/i18n/data/tasks/review_arbiter_system.txt +21 -0
  100. wenyi_core/i18n/data/tasks/review_arbiter_user.txt +4 -0
  101. wenyi_core/i18n/data/tasks/review_fixer_system.txt +12 -0
  102. wenyi_core/i18n/data/tasks/review_fixer_user.txt +30 -0
  103. wenyi_core/i18n/data/tasks/reviewer_system.txt +11 -0
  104. wenyi_core/i18n/data/tasks/reviewer_user.txt +7 -0
  105. wenyi_core/i18n/data/tasks/terminology_discover_system.txt +4 -0
  106. wenyi_core/i18n/data/tasks/terminology_discover_user.txt +2 -0
  107. wenyi_core/i18n/data/tasks/terminology_evidence_system.txt +5 -0
  108. wenyi_core/i18n/data/tasks/terminology_evidence_user.txt +2 -0
  109. wenyi_core/i18n/data/tasks/terminology_merge_system.txt +4 -0
  110. wenyi_core/i18n/data/tasks/terminology_merge_user.txt +2 -0
  111. wenyi_core/i18n/data/tasks/title_translator_system.txt +7 -0
  112. wenyi_core/i18n/data/tasks/title_translator_user.txt +7 -0
  113. wenyi_core/i18n/data/tasks/translator_system.txt +11 -0
  114. wenyi_core/i18n/data/tasks/translator_user.txt +25 -0
  115. wenyi_core/i18n/languages.py +96 -0
  116. wenyi_core/i18n/metadata.py +13 -0
  117. wenyi_core/i18n/policy/__init__.py +6 -0
  118. wenyi_core/i18n/policy/models.py +225 -0
  119. wenyi_core/i18n/policy/registry.py +99 -0
  120. wenyi_core/i18n/policy/resolver.py +381 -0
  121. wenyi_core/i18n/policy/tasks.py +40 -0
  122. wenyi_core/i18n/prompts.py +48 -0
  123. wenyi_core/i18n/resources.py +28 -0
  124. wenyi_core/ingest/__init__.py +1 -0
  125. wenyi_core/ingest/docx_reader.py +562 -0
  126. wenyi_core/ingest/encoding.py +84 -0
  127. wenyi_core/ingest/epub_chapters.py +67 -0
  128. wenyi_core/ingest/epub_layout.py +360 -0
  129. wenyi_core/ingest/epub_package.py +126 -0
  130. wenyi_core/ingest/epub_reader.py +120 -0
  131. wenyi_core/ingest/epub_toc.py +205 -0
  132. wenyi_core/ingest/errors.py +15 -0
  133. wenyi_core/ingest/fb2_reader.py +319 -0
  134. wenyi_core/ingest/html_reader.py +182 -0
  135. wenyi_core/ingest/models.py +17 -0
  136. wenyi_core/ingest/pdf_babeldoc.py +368 -0
  137. wenyi_core/ingest/pdf_reader.py +124 -0
  138. wenyi_core/ingest/pdf_to_html.py +365 -0
  139. wenyi_core/ingest/segmenter.py +216 -0
  140. wenyi_core/ingest/source_hash.py +14 -0
  141. wenyi_core/ingest/text_reader.py +98 -0
  142. wenyi_core/ingest/tokens.py +31 -0
  143. wenyi_core/llm/__init__.py +1 -0
  144. wenyi_core/llm/base.py +147 -0
  145. wenyi_core/llm/configuration.py +171 -0
  146. wenyi_core/llm/connectivity.py +215 -0
  147. wenyi_core/llm/credentials.py +22 -0
  148. wenyi_core/llm/errors.py +93 -0
  149. wenyi_core/llm/factory.py +14 -0
  150. wenyi_core/llm/json_parser.py +120 -0
  151. wenyi_core/llm/limits.py +135 -0
  152. wenyi_core/llm/migration.py +84 -0
  153. wenyi_core/llm/models.py +82 -0
  154. wenyi_core/llm/operation_names.py +48 -0
  155. wenyi_core/llm/operations.py +209 -0
  156. wenyi_core/llm/providers/__init__.py +1 -0
  157. wenyi_core/llm/providers/_openai_compatible.py +207 -0
  158. wenyi_core/llm/providers/deepseek.py +108 -0
  159. wenyi_core/llm/providers/fake.py +91 -0
  160. wenyi_core/llm/providers/gemini.py +287 -0
  161. wenyi_core/llm/providers/ollama.py +9 -0
  162. wenyi_core/llm/providers/openai.py +73 -0
  163. wenyi_core/llm/providers/openai_compatible.py +113 -0
  164. wenyi_core/llm/providers/opencode_go.py +102 -0
  165. wenyi_core/llm/providers/openrouter.py +73 -0
  166. wenyi_core/llm/providers/orcarouter.py +12 -0
  167. wenyi_core/llm/providers/vllm.py +9 -0
  168. wenyi_core/llm/registry.py +146 -0
  169. wenyi_core/llm/retrying.py +418 -0
  170. wenyi_core/llm/router.py +286 -0
  171. wenyi_core/llm/routing.py +159 -0
  172. wenyi_core/llm/transport.py +121 -0
  173. wenyi_core/llm/usage.py +325 -0
  174. wenyi_core/markup/__init__.py +1 -0
  175. wenyi_core/markup/anchors.py +108 -0
  176. wenyi_core/markup/annotations.py +454 -0
  177. wenyi_core/markup/contracts.py +56 -0
  178. wenyi_core/markup/ruby.py +20 -0
  179. wenyi_core/markup/segments.py +488 -0
  180. wenyi_core/models/__init__.py +38 -0
  181. wenyi_core/models/document.py +60 -0
  182. wenyi_core/models/errors.py +26 -0
  183. wenyi_core/models/glossary.py +80 -0
  184. wenyi_core/observability.py +119 -0
  185. wenyi_core/pdf_bridge/__init__.py +8 -0
  186. wenyi_core/pdf_bridge/client.py +102 -0
  187. wenyi_core/pdf_bridge/connection.py +35 -0
  188. wenyi_core/pipeline/__init__.py +111 -0
  189. wenyi_core/pipeline/annotate/__init__.py +1 -0
  190. wenyi_core/pipeline/annotate/annotations.py +285 -0
  191. wenyi_core/pipeline/annotate/docx_styles.py +153 -0
  192. wenyi_core/pipeline/autofix/__init__.py +1 -0
  193. wenyi_core/pipeline/autofix/autofix_candidates.py +269 -0
  194. wenyi_core/pipeline/autofix/autofix_plan.py +77 -0
  195. wenyi_core/pipeline/autofix/autofix_publish.py +198 -0
  196. wenyi_core/pipeline/autofix/autofix_verification.py +276 -0
  197. wenyi_core/pipeline/autofix/review_autofix.py +170 -0
  198. wenyi_core/pipeline/finalize/__init__.py +1 -0
  199. wenyi_core/pipeline/finalize/finalization.py +164 -0
  200. wenyi_core/pipeline/orchestrator.py +361 -0
  201. wenyi_core/pipeline/prepare/__init__.py +1 -0
  202. wenyi_core/pipeline/prepare/book_understanding.py +235 -0
  203. wenyi_core/pipeline/prepare/input_preparation.py +138 -0
  204. wenyi_core/pipeline/prepare/preparation.py +397 -0
  205. wenyi_core/pipeline/review/__init__.py +1 -0
  206. wenyi_core/pipeline/review/review_checkpoint.py +169 -0
  207. wenyi_core/pipeline/review/review_chunks.py +500 -0
  208. wenyi_core/pipeline/review/review_results.py +314 -0
  209. wenyi_core/pipeline/review/review_rounds.py +429 -0
  210. wenyi_core/pipeline/review/review_workflow.py +420 -0
  211. wenyi_core/pipeline/run/__init__.py +1 -0
  212. wenyi_core/pipeline/run/language_policies.py +52 -0
  213. wenyi_core/pipeline/run/runtime.py +178 -0
  214. wenyi_core/pipeline/run/runtime_usage.py +81 -0
  215. wenyi_core/pipeline/translate/__init__.py +1 -0
  216. wenyi_core/pipeline/translate/babeldoc_context.py +136 -0
  217. wenyi_core/pipeline/translate/context.py +44 -0
  218. wenyi_core/pipeline/translate/precision.py +427 -0
  219. wenyi_core/pipeline/translate/precision_records.py +198 -0
  220. wenyi_core/pipeline/translate/terminology.py +364 -0
  221. wenyi_core/pipeline/translate/terminology_metadata.py +27 -0
  222. wenyi_core/pipeline/translate/title_translation.py +239 -0
  223. wenyi_core/pipeline/translate/translation.py +637 -0
  224. wenyi_core/pipeline/translate/translation_batch.py +142 -0
  225. wenyi_core/postprocess/__init__.py +0 -0
  226. wenyi_core/postprocess/export_text.py +92 -0
  227. wenyi_core/postprocess/punct.py +155 -0
  228. wenyi_core/review/__init__.py +5 -0
  229. wenyi_core/review/autofix_models.py +111 -0
  230. wenyi_core/review/conflicts.py +173 -0
  231. wenyi_core/review/contracts.py +36 -0
  232. wenyi_core/review/evidence.py +361 -0
  233. wenyi_core/review/models.py +144 -0
  234. wenyi_core/review/run_store.py +463 -0
  235. wenyi_core/review/session.py +396 -0
  236. wenyi_core/storage/__init__.py +20 -0
  237. wenyi_core/storage/artifacts.py +86 -0
  238. wenyi_core/storage/atomic_write.py +134 -0
  239. wenyi_core/storage/file.py +254 -0
  240. wenyi_core/storage/file_migrations.py +38 -0
  241. wenyi_core/storage/io_path.py +68 -0
  242. wenyi_core/storage/language_policies.py +69 -0
  243. wenyi_core/storage/locks.py +350 -0
  244. wenyi_core/storage/names.py +49 -0
  245. wenyi_core/storage/precision_archive.py +605 -0
  246. wenyi_core/storage/precision_drafts.py +203 -0
  247. wenyi_core/storage/protocol.py +98 -0
  248. wenyi_core/storage/run_store.py +606 -0
  249. wenyi_core/storage/sqlite.py +1035 -0
  250. wenyi_core/timing.py +137 -0
  251. wenyi_core-1.4.2.dist-info/METADATA +40 -0
  252. wenyi_core-1.4.2.dist-info/RECORD +253 -0
  253. wenyi_core-1.4.2.dist-info/WHEEL +4 -0
wenyi_core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Wenyi: multilingual long-form fiction translation with cooperating agents."""
@@ -0,0 +1 @@
1
+ """Agents for analysis, translation, review and polishing."""
@@ -0,0 +1,109 @@
1
+ """Global analysis agent using the strong tier.
2
+ Read sample chapters to produce a style guide and character references with gender and
3
+ voice. Name mappings and terminology extraction belong to the translation glossary workflow.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ..i18n.metadata import normalize_gender
11
+ from ..models.glossary import TYPE_PERSON, GlossaryTerm
12
+ from .base import Agent
13
+
14
+
15
+ def _text(value: Any, default: str = "") -> str:
16
+ """Normalize model fields to text; fall back for non-scalar values such as nested objects."""
17
+ if isinstance(value, str):
18
+ return value.strip()
19
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
20
+ return str(value)
21
+ return default
22
+
23
+
24
+ class Analyzer(Agent):
25
+ policy_phase = "analysis"
26
+
27
+ def analyze(self, sample_text: str, chapter_digests: list[str] | None = None) -> dict[str, Any]:
28
+ """Analyze samples and return type-checked style and character guidance."""
29
+ system = self.render("analyzer_system", src=self.src, tgt=self.tgt)
30
+ user = self.render("analyzer_user", src=self.src, tgt=self.tgt, sample=sample_text)
31
+ if chapter_digests:
32
+ user += "\n\n[Draft chapter digests: factual context, not naming authority]\n"
33
+ user += "\n".join(f"[{i}] {digest}" for i, digest in enumerate(chapter_digests))
34
+ # No default: propagate analysis failures for the caller to handle, including preparation failures.
35
+ data = self._ask_json(system, user, operation="analysis.style")
36
+ if not isinstance(data, dict):
37
+ data = {}
38
+ # Accept a list of prose bullets as well as the requested string. Never stringify objects.
39
+ if isinstance(data.get("style_guide"), list):
40
+ data["style_guide"] = "\n".join(
41
+ item.strip()
42
+ for item in data["style_guide"]
43
+ if isinstance(item, str) and item.strip()
44
+ )
45
+ for key in (
46
+ "genre",
47
+ "tone",
48
+ "style_guide",
49
+ "narration",
50
+ "pacing",
51
+ "register",
52
+ "dialogue_style",
53
+ "rhetoric",
54
+ ):
55
+ data[key] = _text(data.get(key))
56
+ data["characters"] = self.dict_items(
57
+ data.get("characters"), operation="analysis.style", field="characters"
58
+ )
59
+ # Ignore unsolicited glossary candidates, including fields from the former schema.
60
+ data.pop("terms", None)
61
+ for character in data["characters"]:
62
+ character["gender"] = normalize_gender(_text(character.get("gender")))
63
+ character.pop("target", None)
64
+ character.pop("reading", None)
65
+ return data
66
+
67
+ def style_brief(self, analysis: dict[str, Any], terms: list[GlossaryTerm] | None = None) -> str:
68
+ """Condense style and character guidance; name mappings belong to the glossary."""
69
+ lines = []
70
+ if analysis.get("genre"):
71
+ lines.append(f"Genre: {analysis['genre']}")
72
+ if analysis.get("tone"):
73
+ lines.append(f"Tone: {analysis['tone']}")
74
+ if analysis.get("style_guide"):
75
+ lines.append(f"Style guide: {analysis['style_guide']}")
76
+ # Include only style dimensions supported by the model's analysis.
77
+ for key, tag in (
78
+ ("narration", "Narration"),
79
+ ("pacing", "Pacing"),
80
+ ("register", "Register"),
81
+ ("dialogue_style", "Dialogue style"),
82
+ ("rhetoric", "Rhetoric"),
83
+ ):
84
+ if analysis.get(key):
85
+ lines.append(f"{tag}: {analysis[key]}")
86
+ chars = (
87
+ [
88
+ {
89
+ "source": term.source,
90
+ "gender": term.gender,
91
+ # Notes belong in the bounded, source-scoped glossary evidence,
92
+ # never in the global style prefix.
93
+ }
94
+ for term in terms
95
+ if term.type == TYPE_PERSON
96
+ ]
97
+ if terms is not None
98
+ else self.dict_items(
99
+ analysis.get("characters"), operation="analysis.style", field="characters"
100
+ )
101
+ )
102
+ if chars:
103
+ lines.append("Characters: ")
104
+ for c in chars:
105
+ gender = normalize_gender(_text(c.get("gender")))
106
+ g = f", {gender}" if gender else ""
107
+ note = f", {c.get('note')}" if c.get("note") else ""
108
+ lines.append(f" - {c.get('source', '')}{g}{note}")
109
+ return "\n".join(lines)
@@ -0,0 +1,521 @@
1
+ """Locate EPUB annotation links in an immutable translated text.
2
+
3
+ The source parser records where a footnote marker or annotated source range
4
+ appeared. Translation changes character offsets, so this agent asks an LLM to
5
+ copy a set of synthetic markers into the already-finished target. The target
6
+ itself is treated as immutable: local validation rejects a response if removing
7
+ the synthetic markers does not reproduce the target byte-for-byte.
8
+
9
+ This module deliberately returns deterministic fallback placements instead of
10
+ raising. Link preservation is useful metadata, but a transient alignment error
11
+ must neither alter nor discard a completed translation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import re
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from ..errors import WenyiError
24
+ from .base import Agent
25
+
26
+ _MARKER_RE = re.compile(r"⟪([^⟪⟫]+)⟫")
27
+
28
+ _SYSTEM_PROMPT = """\
29
+ You align EPUB annotation markers between a source passage and its completed translation.
30
+ The target text is immutable. You may ONLY insert the supplied markers into it:
31
+ - never add, remove, replace, reorder, normalize, or re-punctuate any target character;
32
+ - preserve every whitespace and line-break character exactly;
33
+ - insert every supplied marker exactly once and do not invent markers;
34
+ - a range marker :S must occur before its matching :E marker;
35
+ - ranges must be nested or disjoint, never crossing.
36
+
37
+ Return JSON only. Preserve every input unit_id exactly and return one item per input unit:
38
+ {"items":[{"unit_id":"the unchanged unit id","marked_target":"the exact target with markers inserted"}]}
39
+ """
40
+
41
+
42
+ class AnnotationAlignmentError(WenyiError, ValueError):
43
+ """The model output cannot safely be mapped back to the immutable target."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class AnnotationUnit:
48
+ """One logical EPUB text block and the annotations found in its source."""
49
+
50
+ unit_id: str
51
+ source: str
52
+ target: str
53
+ items: tuple[dict[str, Any], ...]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AnnotationAlignment:
58
+ """Validated target offsets for every annotation in one logical block."""
59
+
60
+ unit_id: str
61
+ target_digest: str
62
+ placements: tuple[dict[str, Any], ...]
63
+ used_fallback: bool = False
64
+
65
+
66
+ def target_digest(text: str) -> str:
67
+ """Return the stable digest used to detect stale alignment metadata."""
68
+
69
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
70
+
71
+
72
+ def _item_id(item: dict[str, Any]) -> str:
73
+ value = item.get("id")
74
+ if not isinstance(value, str) or not value or any(char in value for char in "⟪⟫"):
75
+ raise AnnotationAlignmentError("invalid_annotation_id")
76
+ return value
77
+
78
+
79
+ def _item_mode(item: dict[str, Any]) -> str:
80
+ mode = item.get("mode")
81
+ if mode not in {"point", "range"}:
82
+ raise AnnotationAlignmentError("invalid_annotation_mode")
83
+ return str(mode)
84
+
85
+
86
+ def _source_offset(item: dict[str, Any], field: str, source_length: int) -> int:
87
+ value = item.get(field)
88
+ if not isinstance(value, int) or isinstance(value, bool):
89
+ raise AnnotationAlignmentError(f"invalid_{field}")
90
+ if not 0 <= value <= source_length:
91
+ raise AnnotationAlignmentError(f"out_of_bounds_{field}")
92
+ return value
93
+
94
+
95
+ def marker_tokens(item: dict[str, Any]) -> tuple[str, ...]:
96
+ """Return the immutable marker token(s) expected for an annotation item."""
97
+
98
+ item_id = _item_id(item)
99
+ mode = _item_mode(item)
100
+ if mode == "point":
101
+ return (f"⟪{item_id}⟫",)
102
+ return (f"⟪{item_id}:S⟫", f"⟪{item_id}:E⟫")
103
+
104
+
105
+ def build_marked_source(unit: AnnotationUnit) -> str:
106
+ """Insert synthetic markers at parser-provided source offsets.
107
+
108
+ Insertions at the same boundary use a stable nesting order: range endings,
109
+ point markers, and then range starts. The exact source display is guidance
110
+ for the model; target validation remains the source of truth.
111
+ """
112
+
113
+ source_length = len(unit.source)
114
+ insertions: dict[int, list[tuple[int, str]]] = {}
115
+ seen_ids: set[str] = set()
116
+ for ordinal, item in enumerate(unit.items):
117
+ item_id = _item_id(item)
118
+ if item_id in seen_ids:
119
+ raise AnnotationAlignmentError("duplicate_annotation_id")
120
+ seen_ids.add(item_id)
121
+ mode = _item_mode(item)
122
+ start = _source_offset(item, "source_start", source_length)
123
+ if mode == "point":
124
+ end = _source_offset(item, "source_end", source_length)
125
+ if end != start:
126
+ raise AnnotationAlignmentError("point_has_nonzero_range")
127
+ # Keep the original item order stable for coincident point markers.
128
+ insertions.setdefault(start, []).append((10_000 + ordinal, f"⟪{item_id}⟫"))
129
+ continue
130
+
131
+ end = _source_offset(item, "source_end", source_length)
132
+ if start > end:
133
+ raise AnnotationAlignmentError("reversed_source_range")
134
+ # At a shared boundary, close inner ranges before opening new ranges.
135
+ insertions.setdefault(start, []).append((20_000 - end, f"⟪{item_id}:S⟫"))
136
+ insertions.setdefault(end, []).append((-20_000 - start, f"⟪{item_id}:E⟫"))
137
+
138
+ parts: list[str] = []
139
+ for offset in range(source_length + 1):
140
+ for _, marker in sorted(insertions.get(offset, []), key=lambda value: value[0]):
141
+ parts.append(marker)
142
+ if offset < source_length:
143
+ parts.append(unit.source[offset])
144
+ return "".join(parts)
145
+
146
+
147
+ def _expected_markers(unit: AnnotationUnit) -> tuple[dict[str, str], list[str]]:
148
+ """Map marker payloads to item ids and preserve the expected marker order."""
149
+
150
+ payload_to_id: dict[str, str] = {}
151
+ expected: list[str] = []
152
+ seen_ids: set[str] = set()
153
+ for item in unit.items:
154
+ item_id = _item_id(item)
155
+ if item_id in seen_ids:
156
+ raise AnnotationAlignmentError("duplicate_annotation_id")
157
+ seen_ids.add(item_id)
158
+ for marker in marker_tokens(item):
159
+ payload = marker[1:-1]
160
+ if payload in payload_to_id:
161
+ raise AnnotationAlignmentError("duplicate_marker_payload")
162
+ payload_to_id[payload] = item_id
163
+ expected.append(payload)
164
+ return payload_to_id, expected
165
+
166
+
167
+ def validate_marked_target(
168
+ unit: AnnotationUnit,
169
+ marked_target: str,
170
+ ) -> tuple[dict[str, Any], ...]:
171
+ """Validate model markers and convert them to target character offsets.
172
+
173
+ Validation is intentionally strict. In particular, stripping all markers
174
+ must reproduce ``unit.target`` exactly, including whitespace and Unicode
175
+ punctuation. Invalid output raises :class:`AnnotationAlignmentError`; the
176
+ public agent method catches it and returns deterministic fallbacks.
177
+ """
178
+
179
+ if not isinstance(marked_target, str):
180
+ raise AnnotationAlignmentError("marked_target_not_string")
181
+
182
+ _, expected = _expected_markers(unit)
183
+ matches = list(_MARKER_RE.finditer(marked_target))
184
+ actual = [match.group(1) for match in matches]
185
+ if len(actual) != len(expected):
186
+ raise AnnotationAlignmentError("marker_count_mismatch")
187
+ if set(actual) != set(expected):
188
+ unknown = set(actual) - set(expected)
189
+ missing = set(expected) - set(actual)
190
+ if unknown:
191
+ raise AnnotationAlignmentError("unknown_marker")
192
+ if missing:
193
+ raise AnnotationAlignmentError("missing_marker")
194
+ raise AnnotationAlignmentError("duplicate_marker")
195
+ if any(actual.count(marker) != 1 for marker in expected):
196
+ raise AnnotationAlignmentError("duplicate_marker")
197
+
198
+ stripped = _MARKER_RE.sub("", marked_target)
199
+ if stripped != unit.target:
200
+ raise AnnotationAlignmentError("target_was_modified")
201
+
202
+ offsets: dict[str, int] = {}
203
+ removed_length = 0
204
+ range_stack: list[str] = []
205
+ range_ids = {_item_id(item) for item in unit.items if _item_mode(item) == "range"}
206
+ for match in matches:
207
+ payload = match.group(1)
208
+ offsets[payload] = match.start() - removed_length
209
+ removed_length += len(match.group(0))
210
+ if payload.endswith(":S"):
211
+ item_id = payload[:-2]
212
+ if item_id not in range_ids:
213
+ raise AnnotationAlignmentError("unexpected_range_start")
214
+ range_stack.append(item_id)
215
+ elif payload.endswith(":E"):
216
+ item_id = payload[:-2]
217
+ if not range_stack or range_stack[-1] != item_id:
218
+ raise AnnotationAlignmentError("crossing_or_reversed_range")
219
+ range_stack.pop()
220
+ if range_stack:
221
+ raise AnnotationAlignmentError("unclosed_range")
222
+
223
+ placements: list[dict[str, Any]] = []
224
+ for item in unit.items:
225
+ item_id = _item_id(item)
226
+ mode = _item_mode(item)
227
+ if mode == "point":
228
+ position = offsets[item_id]
229
+ start = end = position
230
+ else:
231
+ start = offsets[f"{item_id}:S"]
232
+ end = offsets[f"{item_id}:E"]
233
+ if start > end:
234
+ raise AnnotationAlignmentError("reversed_target_range")
235
+ placements.append(
236
+ {
237
+ "id": item_id,
238
+ "mode": mode,
239
+ "target_start": start,
240
+ "target_end": end,
241
+ "status": "aligned",
242
+ "method": "llm_markers",
243
+ }
244
+ )
245
+ return tuple(placements)
246
+
247
+
248
+ def fallback_alignment(unit: AnnotationUnit) -> AnnotationAlignment:
249
+ """Return deterministic placements that never depend on model output.
250
+
251
+ Point annotations use their proportional source offset. A source range has
252
+ no safely inferable translated span, so it becomes a zero-width placement at
253
+ the paragraph end; the writer can render its original marker as a link.
254
+ """
255
+
256
+ source_length = len(unit.source)
257
+ target_length = len(unit.target)
258
+ placements: list[dict[str, Any]] = []
259
+ for item in unit.items:
260
+ try:
261
+ item_id = _item_id(item)
262
+ mode = _item_mode(item)
263
+ source_start = _source_offset(item, "source_start", source_length)
264
+ except AnnotationAlignmentError:
265
+ # Parser-produced metadata should always be valid. If persisted
266
+ # state was manually corrupted, omit only that unusable placement.
267
+ continue
268
+ if mode == "point":
269
+ if source_length:
270
+ position = min(
271
+ target_length,
272
+ (source_start * target_length + source_length // 2) // source_length,
273
+ )
274
+ else:
275
+ position = 0
276
+ method = "proportional_source_offset"
277
+ else:
278
+ position = target_length
279
+ method = "paragraph_end"
280
+ placements.append(
281
+ {
282
+ "id": item_id,
283
+ "mode": mode,
284
+ "target_start": position,
285
+ "target_end": position,
286
+ "status": "fallback",
287
+ "method": method,
288
+ }
289
+ )
290
+ return AnnotationAlignment(
291
+ unit_id=unit.unit_id,
292
+ target_digest=target_digest(unit.target),
293
+ placements=tuple(placements),
294
+ used_fallback=True,
295
+ )
296
+
297
+
298
+ class AnnotationAligner(Agent):
299
+ """Ask the cheap model tier to locate annotations without editing text."""
300
+
301
+ @staticmethod
302
+ def _empty_alignment(unit: AnnotationUnit) -> AnnotationAlignment:
303
+ """Return the no-op result for a logical block without annotations."""
304
+
305
+ return AnnotationAlignment(
306
+ unit_id=unit.unit_id,
307
+ target_digest=target_digest(unit.target),
308
+ placements=(),
309
+ )
310
+
311
+ def align_units(self, units: list[AnnotationUnit]) -> list[AnnotationAlignment]:
312
+ """Align multiple logical blocks, splitting multi-annotation blocks per item.
313
+
314
+ A block with N>1 annotations used to go through a single model call
315
+ that had to reproduce the whole immutable target byte-for-byte *and*
316
+ place all N markers correctly at once; any single mistake (even in one
317
+ unrelated marker) invalidated the whole block. That all-or-nothing
318
+ contract is why annotation-dense books (e.g. a heavily footnoted
319
+ classic where nearly every paragraph carries a note) tend to fall back
320
+ to paragraph-end placement far more than the per-annotation model
321
+ error rate would suggest.
322
+
323
+ Blocks with more than one annotation are instead split into one
324
+ independent, concurrently issued request per annotation: each request
325
+ only has to place a single marker, so a bad response only costs that
326
+ one note instead of every note sharing its paragraph. Concurrency
327
+ (bounded by ``pipeline.annotation_alignment_concurrency``) keeps
328
+ wall-clock time from growing with the number of notes per block.
329
+ Results preserve input order regardless of which path a block took.
330
+ """
331
+
332
+ if not units:
333
+ return []
334
+
335
+ results: list[AnnotationAlignment | None] = [None] * len(units)
336
+ direct_indices = [index for index, unit in enumerate(units) if len(unit.items) <= 1]
337
+ split_indices = [index for index, unit in enumerate(units) if len(unit.items) > 1]
338
+
339
+ if direct_indices:
340
+ direct_units = [units[index] for index in direct_indices]
341
+ for index, result in zip(direct_indices, self._align_batch(direct_units)):
342
+ results[index] = result
343
+
344
+ for index in split_indices:
345
+ results[index] = self._align_split_unit(units[index])
346
+
347
+ return [
348
+ result if result is not None else fallback_alignment(units[index])
349
+ for index, result in enumerate(results)
350
+ ]
351
+
352
+ def _align_split_unit(self, unit: AnnotationUnit) -> AnnotationAlignment:
353
+ """Align every annotation of one block through its own concurrent request.
354
+
355
+ Validates ids up front (same integrity check ``build_marked_source``
356
+ would otherwise raise on) so a corrupt item still degrades the whole
357
+ block deterministically instead of partially. Each well-formed item is
358
+ then aligned independently: a mistake in one annotation's response
359
+ never affects its siblings.
360
+ """
361
+
362
+ try:
363
+ _expected_markers(unit)
364
+ except AnnotationAlignmentError:
365
+ return fallback_alignment(unit)
366
+
367
+ workers = max(1, self.config.pipeline.annotation_alignment_concurrency)
368
+ placements_by_id: dict[str, dict[str, Any]] = {}
369
+ used_fallback = False
370
+ with ThreadPoolExecutor(max_workers=min(workers, len(unit.items))) as executor:
371
+ futures = {
372
+ executor.submit(self._align_single_item, unit, item): _item_id(item)
373
+ for item in unit.items
374
+ }
375
+ for future in as_completed(futures):
376
+ item_id = futures[future]
377
+ placement, item_used_fallback = future.result()
378
+ placements_by_id[item_id] = placement
379
+ used_fallback = used_fallback or item_used_fallback
380
+
381
+ placements = tuple(placements_by_id[_item_id(item)] for item in unit.items)
382
+ return AnnotationAlignment(
383
+ unit_id=unit.unit_id,
384
+ target_digest=target_digest(unit.target),
385
+ placements=placements,
386
+ used_fallback=used_fallback,
387
+ )
388
+
389
+ def _align_single_item(
390
+ self, unit: AnnotationUnit, item: dict[str, Any]
391
+ ) -> tuple[dict[str, Any], bool]:
392
+ """Align exactly one annotation against the unchanged immutable target."""
393
+
394
+ pseudo_unit = AnnotationUnit(
395
+ unit_id=unit.unit_id,
396
+ source=unit.source,
397
+ target=unit.target,
398
+ items=(item,),
399
+ )
400
+ [alignment] = self._align_batch([pseudo_unit])
401
+ return alignment.placements[0], alignment.used_fallback
402
+
403
+ def _align_batch(self, units: list[AnnotationUnit]) -> list[AnnotationAlignment]:
404
+ """Align multiple logical blocks in one model call.
405
+
406
+ Results preserve input order. A missing, duplicate, or invalid response
407
+ item falls back only for its corresponding unit. Transport, JSON, or
408
+ outer-schema failures fall back every unit that required alignment.
409
+ """
410
+
411
+ if not units:
412
+ return []
413
+
414
+ unit_ids = [unit.unit_id for unit in units]
415
+ if any(not isinstance(unit_id, str) or not unit_id for unit_id in unit_ids):
416
+ return [fallback_alignment(unit) for unit in units]
417
+ if len(set(unit_ids)) != len(unit_ids):
418
+ # Duplicate ids make response ownership ambiguous; do not call the
419
+ # model and preserve one deterministic result per input unit.
420
+ return [fallback_alignment(unit) for unit in units]
421
+
422
+ results: list[AnnotationAlignment | None] = [None] * len(units)
423
+ request_items: list[dict[str, str]] = []
424
+ requested_indices: list[int] = []
425
+ for index, unit in enumerate(units):
426
+ if not unit.items:
427
+ results[index] = self._empty_alignment(unit)
428
+ continue
429
+ try:
430
+ marked_source = build_marked_source(unit)
431
+ except Exception: # noqa: BLE001 - corrupt metadata affects only this unit
432
+ results[index] = fallback_alignment(unit)
433
+ continue
434
+ request_items.append(
435
+ {
436
+ "unit_id": unit.unit_id,
437
+ "source_with_markers": marked_source,
438
+ "immutable_target": unit.target,
439
+ }
440
+ )
441
+ requested_indices.append(index)
442
+
443
+ if not request_items:
444
+ return [
445
+ result if result is not None else fallback_alignment(units[index])
446
+ for index, result in enumerate(results)
447
+ ]
448
+
449
+ payload = json.dumps({"items": request_items}, ensure_ascii=False)
450
+ try:
451
+ data = self._ask_json(
452
+ _SYSTEM_PROMPT,
453
+ "Insert the source markers at the corresponding positions in each immutable "
454
+ "target. Return exactly one output item for every input item and no other "
455
+ f"content.\n\nINPUT JSON:\n{payload}",
456
+ operation="annotation.align",
457
+ default={},
458
+ )
459
+ if not isinstance(data, dict) or not isinstance(data.get("items"), list):
460
+ raise AnnotationAlignmentError("response_not_items_object")
461
+ response_items = data["items"]
462
+ except Exception: # noqa: BLE001 - an unusable batch response falls back safely
463
+ for index in requested_indices:
464
+ results[index] = fallback_alignment(units[index])
465
+ return [
466
+ result if result is not None else fallback_alignment(units[index])
467
+ for index, result in enumerate(results)
468
+ ]
469
+
470
+ expected_ids = {units[index].unit_id for index in requested_indices}
471
+ responses_by_id: dict[str, list[dict[str, Any]]] = {unit_id: [] for unit_id in expected_ids}
472
+ for response_item in response_items:
473
+ if not isinstance(response_item, dict):
474
+ continue
475
+ response_unit_id = response_item.get("unit_id")
476
+ if isinstance(response_unit_id, str) and response_unit_id in responses_by_id:
477
+ responses_by_id[response_unit_id].append(response_item)
478
+
479
+ for index in requested_indices:
480
+ unit = units[index]
481
+ candidates = responses_by_id[unit.unit_id]
482
+ if len(candidates) != 1:
483
+ results[index] = fallback_alignment(unit)
484
+ continue
485
+ marked_target = candidates[0].get("marked_target")
486
+ if not isinstance(marked_target, str):
487
+ results[index] = fallback_alignment(unit)
488
+ continue
489
+ try:
490
+ placements = validate_marked_target(unit, marked_target)
491
+ except Exception: # noqa: BLE001 - one malformed item must not poison its peers
492
+ results[index] = fallback_alignment(unit)
493
+ continue
494
+ results[index] = AnnotationAlignment(
495
+ unit_id=unit.unit_id,
496
+ target_digest=target_digest(unit.target),
497
+ placements=placements,
498
+ )
499
+
500
+ return [
501
+ result if result is not None else fallback_alignment(units[index])
502
+ for index, result in enumerate(results)
503
+ ]
504
+
505
+ def align_unit(self, unit: AnnotationUnit) -> AnnotationAlignment:
506
+ """Align one logical block through the shared batched implementation."""
507
+
508
+ return self.align_units([unit])[0]
509
+
510
+
511
+ __all__ = [
512
+ "AnnotationAligner",
513
+ "AnnotationAlignment",
514
+ "AnnotationAlignmentError",
515
+ "AnnotationUnit",
516
+ "build_marked_source",
517
+ "fallback_alignment",
518
+ "marker_tokens",
519
+ "target_digest",
520
+ "validate_marked_target",
521
+ ]