mednotes-opencode 0.1.0

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 (430) hide show
  1. package/.opencode/agents/med-chat-triager.md +204 -0
  2. package/.opencode/agents/med-flashcard-maker.md +63 -0
  3. package/.opencode/agents/med-knowledge-architect.md +230 -0
  4. package/.opencode/agents/med-link-graph-curator.md +177 -0
  5. package/.opencode/agents/med-publish-guard.md +62 -0
  6. package/.opencode/commands/flashcards.md +25 -0
  7. package/.opencode/commands/mednotes/create.md +25 -0
  8. package/.opencode/commands/mednotes/enrich.md +27 -0
  9. package/.opencode/commands/mednotes/fix-wiki.md +27 -0
  10. package/.opencode/commands/mednotes/history.md +22 -0
  11. package/.opencode/commands/mednotes/link-body.md +25 -0
  12. package/.opencode/commands/mednotes/link-related.md +27 -0
  13. package/.opencode/commands/mednotes/link.md +27 -0
  14. package/.opencode/commands/mednotes/pdf-library.md +27 -0
  15. package/.opencode/commands/mednotes/process-chats.md +23 -0
  16. package/.opencode/commands/mednotes/setup.md +21 -0
  17. package/.opencode/commands/mednotes/status.md +27 -0
  18. package/.opencode/commands/mednotes/telemetry.md +27 -0
  19. package/.opencode/commands/report.md +26 -0
  20. package/.opencode/mednotes/AGENTS.md +57 -0
  21. package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
  22. package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
  23. package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
  24. package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
  25. package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
  26. package/.opencode/mednotes/contracts/.gitkeep +1 -0
  27. package/.opencode/mednotes/contracts/agents.json +116 -0
  28. package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
  29. package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
  30. package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
  31. package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
  32. package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
  33. package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
  34. package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
  35. package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
  36. package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
  37. package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
  38. package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
  39. package/.opencode/mednotes/docs/extension-docs.md +40 -0
  40. package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
  41. package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
  42. package/.opencode/mednotes/docs/merge-policy.md +110 -0
  43. package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
  44. package/.opencode/mednotes/docs/semantic-linker.md +141 -0
  45. package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
  46. package/.opencode/mednotes/docs/triage-policy.md +187 -0
  47. package/.opencode/mednotes/docs/vault-version-control.md +758 -0
  48. package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
  49. package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
  50. package/.opencode/mednotes/hooks/hooks.json +79 -0
  51. package/.opencode/mednotes/package-lock.json +6361 -0
  52. package/.opencode/mednotes/package.json +15 -0
  53. package/.opencode/mednotes/pyproject.toml +48 -0
  54. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
  55. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
  56. package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
  57. package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
  58. package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
  59. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
  60. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
  61. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  62. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  63. package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
  64. package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
  65. package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
  66. package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  67. package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
  68. package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
  69. package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
  70. package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
  71. package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
  72. package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
  73. package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
  74. package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
  75. package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
  76. package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
  77. package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
  78. package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
  79. package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
  80. package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
  81. package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
  82. package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
  83. package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
  84. package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
  85. package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
  86. package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
  87. package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
  88. package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
  89. package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
  90. package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
  91. package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
  92. package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
  93. package/.opencode/mednotes/scripts/run_python.mjs +109 -0
  94. package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
  95. package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
  96. package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
  97. package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
  98. package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
  99. package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
  100. package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
  101. package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
  102. package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
  103. package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
  104. package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
  105. package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
  106. package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
  107. package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
  108. package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
  109. package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  110. package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  111. package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  112. package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
  113. package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
  114. package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
  115. package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
  116. package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
  117. package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
  118. package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
  119. package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
  120. package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
  121. package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
  122. package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
  123. package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
  124. package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
  125. package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
  126. package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
  127. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
  128. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
  129. package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
  130. package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
  131. package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
  132. package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
  133. package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
  134. package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
  135. package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
  136. package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
  137. package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
  138. package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
  139. package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
  140. package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
  141. package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
  142. package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
  143. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
  144. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
  145. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
  146. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
  147. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
  148. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
  149. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
  150. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
  151. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
  152. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
  153. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
  154. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
  155. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
  156. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
  157. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
  158. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
  159. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
  160. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
  161. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
  162. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
  163. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
  164. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
  165. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
  166. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
  167. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
  168. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
  169. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
  170. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
  171. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
  172. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
  173. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
  174. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
  175. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
  176. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
  177. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
  178. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
  179. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
  180. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
  181. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
  182. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
  183. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
  184. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
  185. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
  186. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
  187. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
  188. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
  189. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
  190. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
  191. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
  192. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
  193. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
  194. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
  195. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
  196. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
  197. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
  198. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
  199. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
  200. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
  201. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
  202. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
  203. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
  204. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
  205. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
  206. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
  207. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
  208. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
  209. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
  210. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
  211. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
  212. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
  213. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
  214. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
  215. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
  216. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
  217. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
  218. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
  219. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
  220. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
  221. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
  222. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
  223. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
  224. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
  225. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
  226. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
  227. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
  228. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
  229. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
  230. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
  231. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
  232. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
  233. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
  234. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
  235. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
  236. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
  237. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
  238. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
  239. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
  240. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
  241. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
  242. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
  243. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
  244. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
  245. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
  246. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
  247. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
  248. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
  249. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
  250. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
  251. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
  252. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
  253. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
  254. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
  255. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
  256. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
  257. package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
  258. package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
  259. package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
  260. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
  261. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
  262. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
  263. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
  264. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
  265. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
  266. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
  267. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
  268. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
  269. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
  270. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
  271. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
  272. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
  273. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
  274. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
  275. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
  276. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
  277. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
  278. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
  279. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
  280. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
  281. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
  282. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
  283. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
  284. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
  285. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
  286. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
  287. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
  288. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
  289. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
  290. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
  291. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
  292. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
  293. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
  294. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
  295. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
  296. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
  297. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
  298. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
  299. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
  300. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
  301. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
  302. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
  303. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
  304. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
  305. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
  306. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
  307. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
  308. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
  309. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
  310. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
  311. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
  312. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
  313. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
  314. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
  315. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
  316. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
  317. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
  318. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
  319. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
  320. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
  321. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
  322. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
  323. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
  324. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
  325. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
  326. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
  327. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
  328. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
  329. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
  330. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
  331. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
  332. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
  333. package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
  334. package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
  335. package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
  336. package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
  337. package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
  338. package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
  339. package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
  340. package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
  341. package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
  342. package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
  343. package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
  344. package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
  345. package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
  346. package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
  347. package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
  348. package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
  349. package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
  350. package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
  351. package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
  352. package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
  353. package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
  354. package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
  355. package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
  356. package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
  357. package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
  358. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
  359. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
  360. package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
  361. package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
  362. package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
  363. package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
  364. package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
  365. package/.opencode/mednotes/uv.lock +932 -0
  366. package/.opencode/mednotes.generated.json +395 -0
  367. package/.opencode/opencode.json +31 -0
  368. package/.opencode/plugins/mednotes-fsm.mjs +7 -0
  369. package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
  370. package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
  371. package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  372. package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  373. package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
  374. package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
  375. package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
  376. package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  377. package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
  378. package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
  379. package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
  380. package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
  381. package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
  382. package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
  383. package/AGENTS.md +57 -0
  384. package/README.md +194 -0
  385. package/adapters/antigravity/agents.json +80 -0
  386. package/adapters/antigravity/templates/med-chat-triager.md +214 -0
  387. package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
  388. package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
  389. package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
  390. package/adapters/antigravity/templates/med-publish-guard.md +71 -0
  391. package/adapters/gemini-cli/gemini-extension.json +14 -0
  392. package/adapters/gemini-cli/package.json +15 -0
  393. package/adapters/gemini-cli/pyproject.toml +48 -0
  394. package/bin/mednotes-opencode.mjs +155 -0
  395. package/contracts/agents.json +116 -0
  396. package/core/agents/med-chat-triager.md +197 -0
  397. package/core/agents/med-flashcard-maker.md +56 -0
  398. package/core/agents/med-knowledge-architect.md +224 -0
  399. package/core/agents/med-link-graph-curator.md +171 -0
  400. package/core/agents/med-publish-guard.md +55 -0
  401. package/core/commands/flashcards.toml +22 -0
  402. package/core/commands/mednotes/create.toml +22 -0
  403. package/core/commands/mednotes/enrich.toml +24 -0
  404. package/core/commands/mednotes/fix-wiki.toml +24 -0
  405. package/core/commands/mednotes/history.toml +19 -0
  406. package/core/commands/mednotes/link-body.toml +22 -0
  407. package/core/commands/mednotes/link-related.toml +24 -0
  408. package/core/commands/mednotes/link.toml +24 -0
  409. package/core/commands/mednotes/pdf-library.toml +24 -0
  410. package/core/commands/mednotes/process-chats.toml +20 -0
  411. package/core/commands/mednotes/setup.toml +18 -0
  412. package/core/commands/mednotes/status.toml +24 -0
  413. package/core/commands/mednotes/telemetry.toml +24 -0
  414. package/core/commands/report.toml +23 -0
  415. package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
  416. package/core/skills/create-medical-flashcards/SKILL.md +113 -0
  417. package/core/skills/create-medical-note/SKILL.md +90 -0
  418. package/core/skills/enrich-medical-note/SKILL.md +120 -0
  419. package/core/skills/fix-medical-wiki/SKILL.md +559 -0
  420. package/core/skills/link-medical-wiki/SKILL.md +224 -0
  421. package/core/skills/obsidian-cli/SKILL.md +118 -0
  422. package/core/skills/obsidian-markdown/SKILL.md +207 -0
  423. package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  424. package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  425. package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  426. package/core/skills/obsidian-ops/SKILL.md +136 -0
  427. package/core/skills/pdf-library/SKILL.md +45 -0
  428. package/core/skills/process-medical-chats/SKILL.md +246 -0
  429. package/core/skills/workflow-report/SKILL.md +100 -0
  430. package/package.json +45 -0
@@ -0,0 +1,1562 @@
1
+ """DB-backed body term linker diagnosis and application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from collections import deque
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+ from hashlib import sha256
12
+ from pathlib import Path
13
+ from typing import cast
14
+
15
+ from pydantic import Field, StrictBool, StrictFloat, StrictInt, StrictStr
16
+ from pydantic import ValidationError as PydanticValidationError
17
+
18
+ from mednotes.domains.wiki.capabilities.notes.markdown_zones import protected_markdown_zones
19
+ from mednotes.domains.wiki.capabilities.notes.note_iter import iter_notes
20
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
21
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import (
22
+ is_index_note_content,
23
+ is_index_target,
24
+ normalize_key,
25
+ obsidian_target_name,
26
+ )
27
+ from mednotes.domains.wiki.capabilities.vocabulary.llm_disambiguation import (
28
+ LinkDisambiguationRequiresOrchestrator,
29
+ call_contextual_alias_disambiguator,
30
+ )
31
+ from mednotes.domains.wiki.performance import cooperative_cpu_yield
32
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter, JsonValue, contract_error
33
+
34
+ CONTEXTUAL_ALIAS_SCHEMA = "medical-notes-workbench.contextual-alias-disambiguation.v1"
35
+ DEFAULT_LLM_DISAMBIGUATION_MODEL = "antigravity/gemini-3.5-flash"
36
+ DEFAULT_LLM_TIMEOUT_SECONDS = 60
37
+ LLM_CONFIDENCE_THRESHOLD = 0.82
38
+ _GOTA_DISEASE_ALLOW_RE = re.compile(
39
+ r"\b("
40
+ r"crise de gota|gota aguda|gota cronica|gota tofacea|"
41
+ r"artrite gotosa|podagra|hiperuricemia|urato|urico|tofo|tofos"
42
+ r")\b"
43
+ )
44
+ _GOTA_COMMON_NOUN_DENY_RE = re.compile(
45
+ r"\b("
46
+ r"sinal da gota|sinal de gota|gota de orvalho|[0-9]+ gotas?|uma gota|gotas? em cada olho"
47
+ r")\b"
48
+ )
49
+
50
+ ContextualAliasDisambiguator = Callable[[list[JsonObject]], object]
51
+
52
+
53
+ class _BodyLinkerApplyAction(ContractModel):
54
+ start: StrictInt = Field(ge=0)
55
+ end: StrictInt = Field(ge=0)
56
+ replacement: StrictStr
57
+ term: StrictStr = ""
58
+ raw: StrictStr = ""
59
+ matched_text: StrictStr = ""
60
+ target: StrictStr = ""
61
+ old_target: StrictStr = ""
62
+ new_target: StrictStr = ""
63
+ display_text: StrictStr = ""
64
+ source: StrictStr = ""
65
+ occurrence_id: StrictStr = ""
66
+ context_hash: StrictStr = ""
67
+ reason_code: StrictStr = ""
68
+ confidence: JsonValue = None
69
+
70
+
71
+ class _BodyLinkerApplyPlan(ContractModel):
72
+ file: StrictStr = Field(min_length=1)
73
+ changed: StrictBool = False
74
+ insertions: list[_BodyLinkerApplyAction] = Field(default_factory=list)
75
+ rewrites: list[_BodyLinkerApplyAction] = Field(default_factory=list)
76
+ skipped: list[JsonObject] = Field(default_factory=list)
77
+ index_updated: StrictBool = False
78
+ index_entries: StrictInt = Field(default=0, ge=0)
79
+
80
+
81
+ class _BodyLinkerApplyFields(ContractModel):
82
+ blocked: StrictBool = False
83
+ plans: list[_BodyLinkerApplyPlan] = Field(default_factory=list)
84
+
85
+
86
+ class _ContextualAliasDecisionInput(ContractModel):
87
+ occurrence_id: StrictStr = Field(min_length=1)
88
+ action: StrictStr = Field(min_length=1)
89
+ chosen_meaning_id: StrictStr = ""
90
+ chosen_target: StrictStr = ""
91
+ confidence: StrictFloat = Field(ge=0.0, le=1.0)
92
+ reason_code: StrictStr = ""
93
+ rationale_summary: StrictStr = ""
94
+
95
+
96
+ class _SafeContextualDecision(ContractModel):
97
+ occurrence_id: StrictStr = Field(min_length=1)
98
+ file: StrictStr = Field(min_length=1)
99
+ surface: StrictStr = Field(min_length=1)
100
+ matched_text: StrictStr = Field(min_length=1)
101
+ start: StrictInt = Field(ge=0)
102
+ end: StrictInt = Field(ge=0)
103
+ context_hash: StrictStr = Field(min_length=1)
104
+ candidate_targets: list[StrictStr] = Field(default_factory=list)
105
+ action: StrictStr = Field(min_length=1)
106
+ chosen_meaning_id: StrictStr = ""
107
+ chosen_target: StrictStr = ""
108
+ confidence: StrictFloat = Field(ge=0.0, le=1.0)
109
+ reason_code: StrictStr = ""
110
+ rationale_summary: StrictStr = ""
111
+ safe_auto_apply: StrictBool = False
112
+ rejected: StrictBool = False
113
+ source: StrictStr = ""
114
+
115
+
116
+ class _ContextualAliasResponse(ContractModel):
117
+ schema_: StrictStr = Field(alias="schema", serialization_alias="schema")
118
+ model: StrictStr = ""
119
+ decisions: list[_ContextualAliasDecisionInput] = Field(default_factory=list)
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class BodyLinkCandidate:
124
+ source_path: str
125
+ surface: str
126
+ matched_text: str
127
+ target: str
128
+ replacement: str
129
+ start: int
130
+ end: int
131
+ link_policy: str
132
+ meaning_count: int
133
+ canonical_note_count: int
134
+ intrinsically_ambiguous: bool
135
+ in_protected_markdown_zone: bool
136
+ stale_snapshot: bool = False
137
+ occurrence_id: str = ""
138
+ meaning_id: str = ""
139
+ context_hash: str = ""
140
+ decision_action: str = ""
141
+ confidence: float = 0.0
142
+ reason_code: str = ""
143
+ rationale_summary: str = ""
144
+ source: str = "vocabulary_db"
145
+
146
+ @property
147
+ def automatic(self) -> bool:
148
+ if self.link_policy == "requires_context":
149
+ return (
150
+ self.decision_action == "link"
151
+ and self.confidence >= LLM_CONFIDENCE_THRESHOLD
152
+ and bool(self.target)
153
+ and not self.in_protected_markdown_zone
154
+ and not self.stale_snapshot
155
+ )
156
+ return (
157
+ self.link_policy == "direct"
158
+ and self.meaning_count == 1
159
+ and self.canonical_note_count == 1
160
+ and not self.intrinsically_ambiguous
161
+ and not self.in_protected_markdown_zone
162
+ and not self.stale_snapshot
163
+ )
164
+
165
+
166
+ @dataclass
167
+ class BodyLinkDiagnosis:
168
+ wiki_dir: Path
169
+ db_path: Path
170
+ candidates: list[BodyLinkCandidate] = field(default_factory=list)
171
+ rewrites: list[BodyLinkCandidate] = field(default_factory=list)
172
+ skipped: list[JsonObject] = field(default_factory=list)
173
+ contextual_alias_disambiguation: JsonObject = field(default_factory=dict)
174
+ skipped_reason: str = ""
175
+
176
+ def as_diagnosis_payload(self) -> JsonObject:
177
+ automatic = [item for item in self.candidates if item.automatic]
178
+ automatic_rewrites = [item for item in self.rewrites if item.automatic]
179
+ blockers: list[JsonObject] = []
180
+ contextual = self.contextual_alias_disambiguation or _empty_contextual_alias_payload(
181
+ "skipped", "no_contextual_aliases"
182
+ )
183
+ if contextual.get("status") == "blocked":
184
+ blockers.append(
185
+ {
186
+ "code": "body_linker.contextual_alias_disambiguation_failed",
187
+ "message": str(contextual.get("blocked_reason") or "Falha na desambiguação contextual."),
188
+ }
189
+ )
190
+ return _json_object(
191
+ {
192
+ "ok": not blockers,
193
+ "blocked": bool(blockers),
194
+ "phase": "body_term_linker_diagnosis",
195
+ "status": "blocked" if blockers else "diagnosis_ready",
196
+ "blocked_reason": "contextual_alias_disambiguation_failed" if blockers else "",
197
+ "next_action": "Resolver decisões contextuais pela orquestração oficial de agente; se não houver orquestrador, pular aliases contextuais por segurança."
198
+ if blockers
199
+ else "",
200
+ "body_linker_mode": "vocabulary_db",
201
+ "vocabulary_db_path": str(self.db_path),
202
+ "vocabulary_count": len(_load_policies(self.db_path)) if self.db_path.exists() else 0,
203
+ "files_scanned": len({item.source_path for item in [*self.candidates, *self.rewrites]}),
204
+ "files_changed": 0,
205
+ "links_planned": len(automatic),
206
+ "links_rewritten": len(automatic_rewrites),
207
+ "contextual_alias_disambiguation": contextual,
208
+ "blocker_count": len(blockers),
209
+ "blockers": blockers,
210
+ "plans": _plans_from_candidates(automatic, automatic_rewrites, self.skipped),
211
+ }
212
+ )
213
+
214
+ def as_linker_payload(self, *, dry_run: bool) -> JsonObject:
215
+ automatic = [item for item in self.candidates if item.automatic]
216
+ automatic_rewrites = [item for item in self.rewrites if item.automatic]
217
+ blockers: list[JsonObject] = []
218
+ contextual = self.contextual_alias_disambiguation or _empty_contextual_alias_payload(
219
+ "skipped", "no_contextual_aliases"
220
+ )
221
+ if contextual.get("status") == "blocked":
222
+ blockers.append(
223
+ {
224
+ "code": "body_linker.contextual_alias_disambiguation_failed",
225
+ "message": str(contextual.get("blocked_reason") or "Falha na desambiguação contextual."),
226
+ }
227
+ )
228
+ return _json_object(
229
+ {
230
+ "ok": not blockers,
231
+ "blocked": bool(blockers),
232
+ "dry_run": dry_run,
233
+ "phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
234
+ "status": "blocked" if blockers else "preview_ready" if dry_run else "completed",
235
+ "blocked_reason": "contextual_alias_disambiguation_failed" if blockers else "",
236
+ "next_action": "Resolver decisões contextuais pela orquestração oficial de agente; se não houver orquestrador, pular aliases contextuais por segurança."
237
+ if blockers
238
+ else "",
239
+ "body_linker_mode": "vocabulary_db",
240
+ "vocabulary_db_path": str(self.db_path),
241
+ "vocabulary_count": len(_load_policies(self.db_path)) if self.db_path.exists() else 0,
242
+ "files_scanned": len({item.source_path for item in [*self.candidates, *self.rewrites]}),
243
+ "files_changed": len({item.source_path for item in [*automatic, *automatic_rewrites]})
244
+ if not dry_run
245
+ else 0,
246
+ "links_planned": len(automatic),
247
+ "links_rewritten": len(automatic_rewrites),
248
+ "contextual_alias_disambiguation": contextual,
249
+ "blocker_count": len(blockers),
250
+ "blockers": blockers,
251
+ "plans": _plans_from_candidates(automatic, automatic_rewrites, self.skipped),
252
+ }
253
+ )
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class SurfacePolicy:
258
+ surface: str
259
+ normalized_surface: str
260
+ target: str
261
+ link_policy: str
262
+ meaning_count: int
263
+ canonical_note_count: int
264
+ intrinsically_ambiguous: bool
265
+ meaning_id: str = ""
266
+ meaning_label: str = ""
267
+ target_path: str = ""
268
+
269
+
270
+ @dataclass(frozen=True)
271
+ class ContextualOccurrence:
272
+ source_path: str
273
+ surface: str
274
+ normalized_surface: str
275
+ matched_text: str
276
+ start: int
277
+ end: int
278
+ context_preview: str
279
+ context_hash: str
280
+ occurrence_id: str
281
+ candidates: tuple[SurfacePolicy, ...]
282
+ in_table: bool
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class SurfaceSearchTerm:
287
+ normalized_surface: str
288
+ display: str
289
+ policies: tuple[SurfacePolicy, ...]
290
+ contextual: bool
291
+ pattern: str
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class SurfaceMatch:
296
+ start: int
297
+ end: int
298
+ matched_text: str
299
+ term: SurfaceSearchTerm
300
+
301
+
302
+ @dataclass
303
+ class _AhoNode:
304
+ transitions: dict[str, int] = field(default_factory=dict)
305
+ failure: int = 0
306
+ outputs: list[SurfaceSearchTerm] = field(default_factory=list)
307
+
308
+
309
+ def diagnose_body_links(
310
+ *,
311
+ wiki_dir: Path,
312
+ db_path: Path,
313
+ llm_mode: str = "off",
314
+ llm_model: str | None = None,
315
+ llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
316
+ llm_disambiguator: Callable[..., object] | None = None,
317
+ ) -> BodyLinkDiagnosis:
318
+ if llm_mode not in {"auto", "off", "required"}:
319
+ raise ValueError("llm_mode must be one of: auto, off, required")
320
+ policies = _load_policies(db_path)
321
+ diagnosis = BodyLinkDiagnosis(wiki_dir=wiki_dir, db_path=db_path)
322
+ contextual_occurrences: list[ContextualOccurrence] = []
323
+ policies_by_surface: dict[str, list[SurfacePolicy]] = {}
324
+ for policy in policies:
325
+ policies_by_surface.setdefault(policy.normalized_surface, []).append(policy)
326
+ search_terms = _search_terms_from_policies(policies_by_surface)
327
+ surface_automaton = _SurfaceAutomaton(search_terms) if search_terms else None
328
+ for index, path in enumerate(iter_notes(wiki_dir) if wiki_dir.exists() else [], start=1):
329
+ cooperative_cpu_yield(index)
330
+ text = path.read_text(encoding="utf-8")
331
+ if is_index_target(path.stem) or is_index_note_content(text):
332
+ continue
333
+ diagnosis.rewrites.extend(_rewrite_candidates(path, text, policies))
334
+ protected = _protected_spans(text)
335
+ used: list[tuple[int, int]] = []
336
+ source_title = normalize_key(path.stem)
337
+ matches = surface_automaton.finditer(text) if surface_automaton is not None else []
338
+ for match in matches:
339
+ start, end = match.start, match.end
340
+ usable = _usable_policies_for_source(match.term.policies, source_title)
341
+ if not usable:
342
+ continue
343
+ protected_match = _inside_spans(start, end, protected) or _inside_spans(start, end, used)
344
+ if protected_match:
345
+ continue
346
+ display = _best_policy_display(usable)
347
+ guardrail_skip = _surface_context_guardrail_skip(
348
+ path=path,
349
+ text=text,
350
+ normalized_surface=match.term.normalized_surface,
351
+ matched_text=match.matched_text,
352
+ start=start,
353
+ end=end,
354
+ )
355
+ if guardrail_skip is not None:
356
+ diagnosis.skipped.append(guardrail_skip)
357
+ used.append((start, end))
358
+ continue
359
+ if _requires_context(usable):
360
+ occurrence = _contextual_occurrence(
361
+ path=path,
362
+ text=text,
363
+ surface=display,
364
+ normalized_surface=match.term.normalized_surface,
365
+ matched_text=match.matched_text,
366
+ start=start,
367
+ end=end,
368
+ candidates=usable,
369
+ )
370
+ contextual_occurrences.append(occurrence)
371
+ diagnosis.candidates.append(_placeholder_contextual_candidate(occurrence))
372
+ used.append((start, end))
373
+ continue
374
+ policy = usable[0]
375
+ candidate = BodyLinkCandidate(
376
+ source_path=str(path),
377
+ surface=display,
378
+ matched_text=match.matched_text,
379
+ target=policy.target,
380
+ replacement=_replacement(policy.target, match.matched_text, in_table=_is_table_match(text, start)),
381
+ start=start,
382
+ end=end,
383
+ link_policy=policy.link_policy,
384
+ meaning_count=policy.meaning_count,
385
+ canonical_note_count=policy.canonical_note_count,
386
+ intrinsically_ambiguous=policy.intrinsically_ambiguous,
387
+ in_protected_markdown_zone=False,
388
+ meaning_id=policy.meaning_id,
389
+ )
390
+ diagnosis.candidates.append(candidate)
391
+ used.append((start, end))
392
+ _resolve_contextual_occurrences(
393
+ diagnosis,
394
+ contextual_occurrences,
395
+ llm_mode=llm_mode,
396
+ llm_model=llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
397
+ llm_timeout=llm_timeout,
398
+ llm_disambiguator=llm_disambiguator,
399
+ )
400
+ return diagnosis
401
+
402
+
403
+ def run_body_linker(
404
+ *,
405
+ wiki_dir: Path,
406
+ db_path: Path,
407
+ dry_run: bool,
408
+ llm_mode: str = "off",
409
+ llm_model: str | None = None,
410
+ llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
411
+ llm_disambiguator: Callable[..., object] | None = None,
412
+ ) -> JsonObject:
413
+ diagnosis = diagnose_body_links(
414
+ wiki_dir=wiki_dir,
415
+ db_path=db_path,
416
+ llm_mode=llm_mode if dry_run else "off",
417
+ llm_model=llm_model,
418
+ llm_timeout=llm_timeout,
419
+ llm_disambiguator=llm_disambiguator,
420
+ )
421
+ payload = diagnosis.as_linker_payload(dry_run=dry_run)
422
+ fields = _body_linker_apply_fields(payload)
423
+ if not dry_run and not fields.blocked:
424
+ payload = apply_body_linker_plan(wiki_dir=wiki_dir, body_linker_payload=payload)
425
+ return payload
426
+
427
+
428
+ def _load_policies(db_path: Path) -> list[SurfacePolicy]:
429
+ if not db_path.exists():
430
+ return []
431
+ with sqlite3.connect(db_path) as conn:
432
+ rows = conn.execute(
433
+ """
434
+ WITH surface_counts AS (
435
+ SELECT surface_id,
436
+ COUNT(DISTINCT meaning_id) AS meaning_count
437
+ FROM surface_meaning_policy
438
+ WHERE visible_in_yaml = 1
439
+ GROUP BY surface_id
440
+ ),
441
+ canonical_counts AS (
442
+ SELECT p.surface_id,
443
+ COUNT(DISTINCT l.note_id) AS canonical_note_count
444
+ FROM surface_meaning_policy p
445
+ JOIN meaning_note_links l ON l.meaning_id = p.meaning_id
446
+ AND l.role = 'canonical'
447
+ AND l.status = 'active'
448
+ GROUP BY p.surface_id
449
+ )
450
+ SELECT p.display_text,
451
+ s.normalized_surface,
452
+ n.title,
453
+ p.link_policy,
454
+ COALESCE(sc.meaning_count, 0),
455
+ COALESCE(cc.canonical_note_count, 0),
456
+ s.intrinsically_ambiguous,
457
+ p.meaning_id,
458
+ m.label,
459
+ n.path
460
+ FROM surface_meaning_policy p
461
+ JOIN surfaces s ON s.id = p.surface_id
462
+ JOIN meanings m ON m.id = p.meaning_id AND m.status = 'active'
463
+ JOIN meaning_note_links l ON l.meaning_id = p.meaning_id
464
+ AND l.role = 'canonical'
465
+ AND l.status = 'active'
466
+ JOIN notes n ON n.id = l.note_id AND n.status = 'active'
467
+ LEFT JOIN surface_counts sc ON sc.surface_id = p.surface_id
468
+ LEFT JOIN canonical_counts cc ON cc.surface_id = p.surface_id
469
+ WHERE p.visible_in_yaml = 1
470
+ ORDER BY length(s.normalized_surface) DESC, p.display_text
471
+ """
472
+ ).fetchall()
473
+ policies: list[SurfacePolicy] = []
474
+ seen: set[tuple[str, str, str]] = set()
475
+ for (
476
+ surface,
477
+ normalized,
478
+ target,
479
+ policy,
480
+ meaning_count,
481
+ canonical_count,
482
+ ambiguous,
483
+ meaning_id,
484
+ label,
485
+ target_path,
486
+ ) in rows:
487
+ link_target = _obsidian_target_from_note_path(str(target_path), fallback=str(target))
488
+ key = (str(normalized), link_target, str(meaning_id))
489
+ if key in seen:
490
+ continue
491
+ seen.add(key)
492
+ policies.append(
493
+ SurfacePolicy(
494
+ surface=str(surface),
495
+ normalized_surface=str(normalized),
496
+ target=link_target,
497
+ link_policy=str(policy),
498
+ meaning_count=int(meaning_count or 0),
499
+ canonical_note_count=int(canonical_count or 0),
500
+ intrinsically_ambiguous=bool(ambiguous),
501
+ meaning_id=str(meaning_id),
502
+ meaning_label=str(label),
503
+ target_path=str(target_path),
504
+ )
505
+ )
506
+ return policies
507
+
508
+
509
+ def _obsidian_target_from_note_path(note_path: str, *, fallback: str) -> str:
510
+ normalized = note_path.replace("\\", "/").strip()
511
+ if normalized:
512
+ return obsidian_target_name(normalized)
513
+ return fallback
514
+
515
+
516
+ def _plans_from_candidates(
517
+ candidates: list[BodyLinkCandidate],
518
+ rewrites: list[BodyLinkCandidate] | None = None,
519
+ skipped: list[JsonObject] | None = None,
520
+ ) -> list[JsonObject]:
521
+ by_file: dict[str, list[BodyLinkCandidate]] = {}
522
+ rewrites_by_file: dict[str, list[BodyLinkCandidate]] = {}
523
+ skipped_by_file: dict[str, list[JsonObject]] = {}
524
+ for candidate in candidates:
525
+ by_file.setdefault(candidate.source_path, []).append(candidate)
526
+ for rewrite in rewrites or []:
527
+ rewrites_by_file.setdefault(rewrite.source_path, []).append(rewrite)
528
+ for item in skipped or []:
529
+ file = item.get("file")
530
+ if isinstance(file, str):
531
+ skipped_by_file.setdefault(file, []).append(item)
532
+ files = sorted(set(by_file) | set(rewrites_by_file) | set(skipped_by_file))
533
+ plans: list[JsonObject] = []
534
+ for file in files:
535
+ plans.append(
536
+ _json_object(
537
+ {
538
+ "file": file,
539
+ "changed": bool(by_file.get(file) or rewrites_by_file.get(file)),
540
+ "insertions": [
541
+ {
542
+ "term": item.surface,
543
+ "matched_text": item.matched_text,
544
+ "target": item.target,
545
+ "replacement": item.replacement,
546
+ "start": item.start,
547
+ "end": item.end,
548
+ "source": item.source,
549
+ "occurrence_id": item.occurrence_id,
550
+ "context_hash": item.context_hash,
551
+ "confidence": item.confidence,
552
+ "reason_code": item.reason_code,
553
+ }
554
+ for item in by_file.get(file, [])
555
+ ],
556
+ "rewrites": [
557
+ {
558
+ "raw": item.matched_text,
559
+ "old_target": item.matched_text.split("|", 1)[0],
560
+ "new_target": item.target,
561
+ "display_text": item.surface,
562
+ "replacement": item.replacement,
563
+ "start": item.start,
564
+ "end": item.end,
565
+ "source": item.source,
566
+ }
567
+ for item in rewrites_by_file.get(file, [])
568
+ ],
569
+ "skipped": [
570
+ _json_object({key: value for key, value in item.items() if key != "file"})
571
+ for item in skipped_by_file.get(file, [])
572
+ ],
573
+ "index_updated": False,
574
+ "index_entries": 0,
575
+ }
576
+ )
577
+ )
578
+ return plans
579
+
580
+
581
+ def _apply_candidates(candidates: list[BodyLinkCandidate]) -> None:
582
+ by_file: dict[Path, list[BodyLinkCandidate]] = {}
583
+ for candidate in candidates:
584
+ by_file.setdefault(Path(candidate.source_path), []).append(candidate)
585
+ for path, items in by_file.items():
586
+ text = path.read_text(encoding="utf-8")
587
+ updated = text
588
+ for item in sorted(items, key=lambda candidate: candidate.start, reverse=True):
589
+ updated = updated[: item.start] + item.replacement + updated[item.end :]
590
+ if updated != text:
591
+ atomic_write_text(path, updated)
592
+
593
+
594
+ def apply_body_linker_plan(*, wiki_dir: Path, body_linker_payload: JsonObject) -> JsonObject:
595
+ payload = JsonObjectAdapter.validate_python(body_linker_payload)
596
+ fields = _body_linker_apply_fields(payload)
597
+ changed_files: set[str] = set()
598
+ for plan in fields.plans:
599
+ if not plan.changed:
600
+ continue
601
+ path = Path(plan.file)
602
+ if not path.is_absolute():
603
+ path = wiki_dir / path
604
+ text = path.read_text(encoding="utf-8")
605
+ updated = text
606
+ actions = [*plan.insertions, *plan.rewrites]
607
+ for action in sorted(actions, key=lambda item: item.start, reverse=True):
608
+ start = action.start
609
+ end = action.end
610
+ replacement = action.replacement
611
+ updated = updated[:start] + replacement + updated[end:]
612
+ if updated != text:
613
+ atomic_write_text(path, updated)
614
+ changed_files.add(str(path))
615
+ result = dict(payload)
616
+ result["dry_run"] = False
617
+ result["phase"] = "run_linker_apply"
618
+ result["status"] = "completed" if not fields.blocked else "blocked"
619
+ result["files_changed"] = len(changed_files)
620
+ changed_file_values: list[JsonValue] = cast(list[JsonValue], sorted(changed_files))
621
+ result["changed_files"] = changed_file_values
622
+ result["returncode"] = 3 if fields.blocked else 0
623
+ return JsonObjectAdapter.validate_python(result)
624
+
625
+
626
+ def _body_linker_apply_fields(payload: JsonObject) -> _BodyLinkerApplyFields:
627
+ raw_fields: JsonObject = {}
628
+ for name in ("blocked", "plans"):
629
+ if name in payload:
630
+ raw_fields[name] = payload[name]
631
+ try:
632
+ return _BodyLinkerApplyFields.model_validate(raw_fields)
633
+ except PydanticValidationError as exc:
634
+ raise contract_error(exc, prefix="body linker apply payload invalid") from exc
635
+
636
+
637
+ def _json_object(payload: object) -> JsonObject:
638
+ if isinstance(payload, dict):
639
+ return cast(JsonObject, payload)
640
+ return JsonObjectAdapter.validate_python(payload)
641
+
642
+
643
+ def _empty_contextual_alias_payload(status: str, reason: str = "") -> JsonObject:
644
+ return _json_object(
645
+ {
646
+ "schema": CONTEXTUAL_ALIAS_SCHEMA,
647
+ "phase": "contextual_alias_disambiguation",
648
+ "status": status,
649
+ "mode": "off",
650
+ "skipped_reason": reason,
651
+ "candidate_count": 0,
652
+ "decision_count": 0,
653
+ "linked_count": 0,
654
+ "deferred_count": 0,
655
+ "no_link_count": 0,
656
+ "rejected_count": 0,
657
+ "llm_error": "",
658
+ "decisions": [],
659
+ }
660
+ )
661
+
662
+
663
+ def _best_policy_display(policies: list[SurfacePolicy]) -> str:
664
+ return max((policy.surface for policy in policies), key=lambda value: (len(normalize_key(value)), len(value)))
665
+
666
+
667
+ def _requires_context(policies: list[SurfacePolicy]) -> bool:
668
+ if len({policy.meaning_id for policy in policies}) > 1:
669
+ return True
670
+ if len({normalize_key(policy.target) for policy in policies}) > 1:
671
+ return True
672
+ return any(
673
+ policy.link_policy == "requires_context"
674
+ or policy.intrinsically_ambiguous
675
+ or policy.meaning_count != 1
676
+ or policy.canonical_note_count != 1
677
+ for policy in policies
678
+ )
679
+
680
+
681
+ def _search_terms_for_path(path: Path, policies_by_surface: dict[str, list[SurfacePolicy]]) -> list[SurfaceSearchTerm]:
682
+ terms: list[SurfaceSearchTerm] = []
683
+ source_title = normalize_key(path.stem)
684
+ for normalized_surface, surface_policies in policies_by_surface.items():
685
+ usable = _usable_policies_for_source(surface_policies, source_title)
686
+ if not usable:
687
+ continue
688
+ display = _best_policy_display(usable)
689
+ pattern = _fold_for_search(display)
690
+ if not pattern:
691
+ continue
692
+ terms.append(
693
+ SurfaceSearchTerm(
694
+ normalized_surface=normalized_surface,
695
+ display=display,
696
+ policies=tuple(usable),
697
+ contextual=_requires_context(usable),
698
+ pattern=pattern,
699
+ )
700
+ )
701
+ return terms
702
+
703
+
704
+ def _search_terms_from_policies(policies_by_surface: dict[str, list[SurfacePolicy]]) -> list[SurfaceSearchTerm]:
705
+ terms: list[SurfaceSearchTerm] = []
706
+ for normalized_surface, surface_policies in policies_by_surface.items():
707
+ display = _best_policy_display(surface_policies)
708
+ pattern = _fold_for_search(display)
709
+ if not pattern:
710
+ continue
711
+ terms.append(
712
+ SurfaceSearchTerm(
713
+ normalized_surface=normalized_surface,
714
+ display=display,
715
+ policies=tuple(surface_policies),
716
+ contextual=_requires_context(surface_policies),
717
+ pattern=pattern,
718
+ )
719
+ )
720
+ return terms
721
+
722
+
723
+ def _usable_policies_for_source(policies: tuple[SurfacePolicy, ...] | list[SurfacePolicy], source_title: str) -> list[SurfacePolicy]:
724
+ return [policy for policy in policies if normalize_key(policy.target) != source_title]
725
+
726
+
727
+ class _SurfaceAutomaton:
728
+ def __init__(self, terms: list[SurfaceSearchTerm]):
729
+ self._nodes = [_AhoNode()]
730
+ for term in terms:
731
+ self._add(term)
732
+ self._build_failures()
733
+
734
+ def _add(self, term: SurfaceSearchTerm) -> None:
735
+ state = 0
736
+ for char in term.pattern:
737
+ if char not in self._nodes[state].transitions:
738
+ self._nodes[state].transitions[char] = self._new_node()
739
+ state = self._nodes[state].transitions[char]
740
+ self._nodes[state].outputs.append(term)
741
+
742
+ def _new_node(self) -> int:
743
+ self._nodes.append(_AhoNode())
744
+ return len(self._nodes) - 1
745
+
746
+ def _build_failures(self) -> None:
747
+ queue: deque[int] = deque()
748
+ for next_state in self._nodes[0].transitions.values():
749
+ self._nodes[next_state].failure = 0
750
+ queue.append(next_state)
751
+
752
+ while queue:
753
+ state = queue.popleft()
754
+ for char, next_state in self._nodes[state].transitions.items():
755
+ queue.append(next_state)
756
+ failure = self._nodes[state].failure
757
+ while failure and char not in self._nodes[failure].transitions:
758
+ failure = self._nodes[failure].failure
759
+ self._nodes[next_state].failure = self._nodes[failure].transitions.get(char, 0)
760
+ self._nodes[next_state].outputs.extend(self._nodes[self._nodes[next_state].failure].outputs)
761
+
762
+ def finditer(self, text: str) -> list[SurfaceMatch]:
763
+ matches: list[SurfaceMatch] = []
764
+ state = 0
765
+ searchable = _fold_for_search(text)
766
+ for index, char in enumerate(searchable):
767
+ while state and char not in self._nodes[state].transitions:
768
+ state = self._nodes[state].failure
769
+ state = self._nodes[state].transitions.get(char, 0)
770
+ for term in self._nodes[state].outputs:
771
+ end = index + 1
772
+ start = end - len(term.pattern)
773
+ if start < 0 or not _has_term_boundaries(text, start, end):
774
+ continue
775
+ matches.append(
776
+ SurfaceMatch(
777
+ start=start,
778
+ end=end,
779
+ matched_text=text[start:end],
780
+ term=term,
781
+ )
782
+ )
783
+ return sorted(
784
+ matches,
785
+ key=lambda match: (
786
+ match.start,
787
+ -(match.end - match.start),
788
+ match.term.display.casefold(),
789
+ match.term.normalized_surface,
790
+ ),
791
+ )
792
+
793
+
794
+ def _surface_matches(text: str, terms: list[SurfaceSearchTerm]) -> list[SurfaceMatch]:
795
+ if not terms:
796
+ return []
797
+ return _SurfaceAutomaton(terms).finditer(text)
798
+
799
+
800
+ def _fold_for_search(value: str) -> str:
801
+ lowered = value.lower()
802
+ if len(lowered) == len(value):
803
+ return lowered
804
+ return "".join(_fold_char(char) for char in value)
805
+
806
+
807
+ def _fold_char(value: str) -> str:
808
+ folded = value.lower()
809
+ return folded if len(folded) == 1 else value
810
+
811
+
812
+ def _contextual_occurrence(
813
+ *,
814
+ path: Path,
815
+ text: str,
816
+ surface: str,
817
+ normalized_surface: str,
818
+ matched_text: str,
819
+ start: int,
820
+ end: int,
821
+ candidates: list[SurfacePolicy],
822
+ ) -> ContextualOccurrence:
823
+ context_start, context_end = _line_bounds(text, start)
824
+ context_preview = text[context_start:context_end]
825
+ context_hash = "sha256:" + sha256(context_preview.encode("utf-8")).hexdigest()
826
+ occurrence_key = json.dumps(
827
+ {
828
+ "path": str(path),
829
+ "surface": normalized_surface,
830
+ "matched_text": matched_text,
831
+ "start": start,
832
+ "end": end,
833
+ "context_hash": context_hash,
834
+ },
835
+ ensure_ascii=False,
836
+ sort_keys=True,
837
+ )
838
+ occurrence_id = "ctx:" + sha256(occurrence_key.encode("utf-8")).hexdigest()
839
+ return ContextualOccurrence(
840
+ source_path=str(path),
841
+ surface=surface,
842
+ normalized_surface=normalized_surface,
843
+ matched_text=matched_text,
844
+ start=start,
845
+ end=end,
846
+ context_preview=context_preview,
847
+ context_hash=context_hash,
848
+ occurrence_id=occurrence_id,
849
+ candidates=tuple(candidates),
850
+ in_table=_is_table_match(text, start),
851
+ )
852
+
853
+
854
+ def _surface_context_guardrail_skip(
855
+ *,
856
+ path: Path,
857
+ text: str,
858
+ normalized_surface: str,
859
+ matched_text: str,
860
+ start: int,
861
+ end: int,
862
+ ) -> JsonObject | None:
863
+ if normalized_surface != "gota":
864
+ return None
865
+ context_start, context_end = _line_bounds(text, start)
866
+ context = normalize_key(text[context_start:context_end])
867
+ if _GOTA_DISEASE_ALLOW_RE.search(context):
868
+ return None
869
+ if not _GOTA_COMMON_NOUN_DENY_RE.search(context):
870
+ return None
871
+ context_hash = "sha256:" + sha256(text[context_start:context_end].encode("utf-8")).hexdigest()
872
+ return _json_object(
873
+ {
874
+ "file": str(path),
875
+ "term": "Gota",
876
+ "matched_text": matched_text,
877
+ "start": start,
878
+ "end": end,
879
+ "occurrence_id": "",
880
+ "context_hash": context_hash,
881
+ "action": "no_link",
882
+ "reason_code": "surface_context_guardrail_no_link",
883
+ "confidence": 1.0,
884
+ "source": "surface_context_guardrail",
885
+ }
886
+ )
887
+
888
+
889
+ def _placeholder_contextual_candidate(occurrence: ContextualOccurrence) -> BodyLinkCandidate:
890
+ first = occurrence.candidates[0]
891
+ return BodyLinkCandidate(
892
+ source_path=occurrence.source_path,
893
+ surface=occurrence.surface,
894
+ matched_text=occurrence.matched_text,
895
+ target=first.target,
896
+ replacement=_replacement(first.target, occurrence.matched_text, in_table=occurrence.in_table),
897
+ start=occurrence.start,
898
+ end=occurrence.end,
899
+ link_policy="requires_context",
900
+ meaning_count=first.meaning_count,
901
+ canonical_note_count=first.canonical_note_count,
902
+ intrinsically_ambiguous=True,
903
+ in_protected_markdown_zone=False,
904
+ occurrence_id=occurrence.occurrence_id,
905
+ meaning_id=first.meaning_id,
906
+ context_hash=occurrence.context_hash,
907
+ )
908
+
909
+
910
+ def _request_from_occurrence(occurrence: ContextualOccurrence) -> JsonObject:
911
+ return _json_object(
912
+ {
913
+ "occurrence_id": occurrence.occurrence_id,
914
+ "file": occurrence.source_path,
915
+ "surface": occurrence.surface,
916
+ "normalized_surface": occurrence.normalized_surface,
917
+ "matched_text": occurrence.matched_text,
918
+ "start": occurrence.start,
919
+ "end": occurrence.end,
920
+ "context_preview": occurrence.context_preview,
921
+ "context_hash": occurrence.context_hash,
922
+ "candidates": [
923
+ {
924
+ "meaning_id": candidate.meaning_id,
925
+ "meaning_label": candidate.meaning_label,
926
+ "target": candidate.target,
927
+ "target_path": candidate.target_path,
928
+ "link_policy": candidate.link_policy,
929
+ }
930
+ for candidate in occurrence.candidates
931
+ ],
932
+ }
933
+ )
934
+
935
+
936
+ def _safe_decision_payload(
937
+ *,
938
+ occurrence: ContextualOccurrence,
939
+ action: str,
940
+ chosen: SurfacePolicy | None,
941
+ confidence: float,
942
+ reason_code: str,
943
+ rationale_summary: str,
944
+ rejected: bool = False,
945
+ ) -> JsonObject:
946
+ return _json_object(
947
+ {
948
+ "occurrence_id": occurrence.occurrence_id,
949
+ "file": occurrence.source_path,
950
+ "surface": occurrence.surface,
951
+ "matched_text": occurrence.matched_text,
952
+ "start": occurrence.start,
953
+ "end": occurrence.end,
954
+ "context_hash": occurrence.context_hash,
955
+ "candidate_targets": [candidate.target for candidate in occurrence.candidates],
956
+ "action": action,
957
+ "chosen_meaning_id": chosen.meaning_id if chosen else "",
958
+ "chosen_target": chosen.target if chosen else "",
959
+ "confidence": confidence,
960
+ "reason_code": reason_code,
961
+ "rationale_summary": rationale_summary[:180],
962
+ "safe_auto_apply": action == "link" and chosen is not None and confidence >= LLM_CONFIDENCE_THRESHOLD,
963
+ "rejected": rejected,
964
+ }
965
+ )
966
+
967
+
968
+ def _skip_from_decision(decision: JsonObject) -> JsonObject:
969
+ return _json_object(
970
+ {
971
+ "file": decision["file"],
972
+ "term": decision["surface"],
973
+ "matched_text": decision["matched_text"],
974
+ "start": decision["start"],
975
+ "end": decision["end"],
976
+ "occurrence_id": decision["occurrence_id"],
977
+ "context_hash": decision["context_hash"],
978
+ "action": decision["action"],
979
+ "reason_code": decision["reason_code"],
980
+ "confidence": decision["confidence"],
981
+ "source": "contextual_alias_disambiguation",
982
+ }
983
+ )
984
+
985
+
986
+ def _resolve_contextual_occurrences(
987
+ diagnosis: BodyLinkDiagnosis,
988
+ occurrences: list[ContextualOccurrence],
989
+ *,
990
+ llm_mode: str,
991
+ llm_model: str,
992
+ llm_timeout: int,
993
+ llm_disambiguator: Callable[..., object] | None,
994
+ ) -> None:
995
+ if not occurrences:
996
+ diagnosis.contextual_alias_disambiguation = _empty_contextual_alias_payload("skipped", "no_contextual_aliases")
997
+ return
998
+ deterministic_occurrences: list[ContextualOccurrence] = []
999
+ unresolved_occurrences: list[ContextualOccurrence] = []
1000
+ for occurrence in occurrences:
1001
+ if _is_deterministic_contextual_occurrence(occurrence):
1002
+ deterministic_occurrences.append(occurrence)
1003
+ else:
1004
+ unresolved_occurrences.append(occurrence)
1005
+ resolved_decisions: list[JsonObject] = []
1006
+ linked_candidates: list[BodyLinkCandidate] = []
1007
+ for occurrence in deterministic_occurrences:
1008
+ chosen = occurrence.candidates[0]
1009
+ decision = _safe_decision_payload(
1010
+ occurrence=occurrence,
1011
+ action="link",
1012
+ chosen=chosen,
1013
+ confidence=1.0,
1014
+ reason_code="exact_canonical_surface",
1015
+ rationale_summary="Termo igual ao único alvo canônico; LLM não é necessário.",
1016
+ )
1017
+ decision["source"] = "deterministic_contextual_alias"
1018
+ resolved_decisions.append(decision)
1019
+ linked_candidates.append(
1020
+ _contextual_link_candidate(
1021
+ occurrence=occurrence,
1022
+ chosen=chosen,
1023
+ confidence=1.0,
1024
+ reason_code=str(decision["reason_code"]),
1025
+ rationale_summary=str(decision["rationale_summary"]),
1026
+ source="deterministic_contextual_alias",
1027
+ )
1028
+ )
1029
+ if deterministic_occurrences:
1030
+ deterministic_ids = {occurrence.occurrence_id for occurrence in deterministic_occurrences}
1031
+ diagnosis.candidates = [
1032
+ item
1033
+ for item in diagnosis.candidates
1034
+ if item.link_policy != "requires_context" or item.occurrence_id not in deterministic_ids
1035
+ ]
1036
+ diagnosis.candidates.extend(linked_candidates)
1037
+ if not unresolved_occurrences:
1038
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1039
+ mode="deterministic",
1040
+ status="completed",
1041
+ decisions=resolved_decisions,
1042
+ )
1043
+ return
1044
+ if llm_mode == "off":
1045
+ decisions = [
1046
+ _safe_decision_payload(
1047
+ occurrence=occurrence,
1048
+ action="defer",
1049
+ chosen=None,
1050
+ confidence=0.0,
1051
+ reason_code="llm_disabled",
1052
+ rationale_summary="Desambiguação LLM desativada.",
1053
+ )
1054
+ for occurrence in unresolved_occurrences
1055
+ ]
1056
+ diagnosis.skipped.extend(_skip_from_decision(decision) for decision in decisions)
1057
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1058
+ mode=llm_mode,
1059
+ status="skipped",
1060
+ decisions=[*resolved_decisions, *decisions],
1061
+ skipped_reason="llm_disabled",
1062
+ )
1063
+ return
1064
+
1065
+ requests = [_request_from_occurrence(occurrence) for occurrence in unresolved_occurrences]
1066
+ provider = llm_disambiguator or call_contextual_alias_disambiguator
1067
+ try:
1068
+ raw = provider(requests, model=llm_model, timeout_seconds=llm_timeout)
1069
+ except LinkDisambiguationRequiresOrchestrator as exc:
1070
+ if llm_mode == "required":
1071
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1072
+ mode=llm_mode,
1073
+ status="blocked",
1074
+ decisions=resolved_decisions,
1075
+ blocked_reason="orchestrator_required",
1076
+ llm_error=str(exc),
1077
+ )
1078
+ return
1079
+ decisions = [
1080
+ _safe_decision_payload(
1081
+ occurrence=occurrence,
1082
+ action="defer",
1083
+ chosen=None,
1084
+ confidence=0.0,
1085
+ reason_code="orchestrator_required",
1086
+ rationale_summary="Desambiguação contextual exige orquestração por agente; ocorrência pulada por segurança.",
1087
+ )
1088
+ for occurrence in unresolved_occurrences
1089
+ ]
1090
+ diagnosis.skipped.extend(_skip_from_decision(decision) for decision in decisions)
1091
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1092
+ mode=llm_mode,
1093
+ status="skipped",
1094
+ decisions=[*resolved_decisions, *decisions],
1095
+ skipped_reason="orchestrator_required",
1096
+ llm_error=str(exc),
1097
+ )
1098
+ return
1099
+ except Exception as exc: # pragma: no cover - exercised through required mode in integration
1100
+ if llm_mode == "required":
1101
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1102
+ mode=llm_mode,
1103
+ status="blocked",
1104
+ decisions=[],
1105
+ blocked_reason="llm_disambiguation_failed",
1106
+ llm_error=str(exc),
1107
+ )
1108
+ return
1109
+ decisions = [
1110
+ _safe_decision_payload(
1111
+ occurrence=occurrence,
1112
+ action="defer",
1113
+ chosen=None,
1114
+ confidence=0.0,
1115
+ reason_code="llm_unavailable",
1116
+ rationale_summary="Gemini indisponível; ocorrência contextual pulada.",
1117
+ )
1118
+ for occurrence in unresolved_occurrences
1119
+ ]
1120
+ diagnosis.skipped.extend(_skip_from_decision(decision) for decision in decisions)
1121
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1122
+ mode=llm_mode,
1123
+ status="skipped",
1124
+ decisions=[*resolved_decisions, *decisions],
1125
+ skipped_reason="llm_unavailable",
1126
+ llm_error=str(exc),
1127
+ )
1128
+ return
1129
+
1130
+ try:
1131
+ decisions_by_id, response_payload = _decisions_by_occurrence(raw)
1132
+ except Exception as exc:
1133
+ if llm_mode == "required":
1134
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1135
+ mode=llm_mode,
1136
+ status="blocked",
1137
+ decisions=[],
1138
+ blocked_reason="llm_disambiguation_invalid_response",
1139
+ llm_error=str(exc),
1140
+ )
1141
+ return
1142
+ decisions = [
1143
+ _safe_decision_payload(
1144
+ occurrence=occurrence,
1145
+ action="defer",
1146
+ chosen=None,
1147
+ confidence=0.0,
1148
+ reason_code="llm_invalid_response",
1149
+ rationale_summary="Gemini retornou JSON inválido para desambiguação contextual.",
1150
+ rejected=True,
1151
+ )
1152
+ for occurrence in unresolved_occurrences
1153
+ ]
1154
+ diagnosis.skipped.extend(_skip_from_decision(decision) for decision in decisions)
1155
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1156
+ mode=llm_mode,
1157
+ status="skipped",
1158
+ decisions=[*resolved_decisions, *decisions],
1159
+ skipped_reason="llm_invalid_response",
1160
+ llm_error=str(exc),
1161
+ )
1162
+ return
1163
+ decisions: list[JsonObject] = [*resolved_decisions]
1164
+ llm_linked_candidates: list[BodyLinkCandidate] = []
1165
+ skipped: list[JsonObject] = []
1166
+ for occurrence in unresolved_occurrences:
1167
+ decision = decisions_by_id.get(occurrence.occurrence_id)
1168
+ safe_decision, candidate = _validate_llm_decision(occurrence, decision)
1169
+ decisions.append(safe_decision)
1170
+ if candidate is not None:
1171
+ llm_linked_candidates.append(candidate)
1172
+ else:
1173
+ skipped.append(_skip_from_decision(safe_decision))
1174
+ unresolved_ids = {occurrence.occurrence_id for occurrence in unresolved_occurrences}
1175
+ diagnosis.candidates = [
1176
+ item
1177
+ for item in diagnosis.candidates
1178
+ if item.link_policy != "requires_context" or item.occurrence_id not in unresolved_ids
1179
+ ]
1180
+ diagnosis.candidates.extend(llm_linked_candidates)
1181
+ diagnosis.skipped.extend(skipped)
1182
+ diagnosis.contextual_alias_disambiguation = _contextual_summary(
1183
+ mode=llm_mode,
1184
+ status="completed",
1185
+ decisions=decisions,
1186
+ model=llm_model,
1187
+ )
1188
+ _persist_contextual_decisions(diagnosis.db_path, decisions, model=llm_model, response_payload=response_payload)
1189
+
1190
+
1191
+ def _is_deterministic_contextual_occurrence(occurrence: ContextualOccurrence) -> bool:
1192
+ if len(occurrence.candidates) != 1:
1193
+ return False
1194
+ candidate = occurrence.candidates[0]
1195
+ return (
1196
+ candidate.meaning_count == 1
1197
+ and candidate.canonical_note_count == 1
1198
+ and normalize_key(candidate.target) == occurrence.normalized_surface
1199
+ and normalize_key(occurrence.matched_text) == occurrence.normalized_surface
1200
+ )
1201
+
1202
+
1203
+ def _decisions_by_occurrence(raw: object) -> tuple[dict[str, _ContextualAliasDecisionInput], JsonObject]:
1204
+ payload = JsonObjectAdapter.validate_python(raw)
1205
+ try:
1206
+ response = _ContextualAliasResponse.model_validate(payload)
1207
+ except PydanticValidationError as exc:
1208
+ raise contract_error(exc, prefix="contextual alias disambiguation response invalid") from exc
1209
+ if response.schema_ != CONTEXTUAL_ALIAS_SCHEMA:
1210
+ raise ValueError(f"LLM response must use schema {CONTEXTUAL_ALIAS_SCHEMA}")
1211
+ result: dict[str, _ContextualAliasDecisionInput] = {}
1212
+ for item in response.decisions:
1213
+ result[item.occurrence_id] = item
1214
+ return result, payload
1215
+
1216
+
1217
+ def _validate_llm_decision(
1218
+ occurrence: ContextualOccurrence,
1219
+ decision: _ContextualAliasDecisionInput | None,
1220
+ ) -> tuple[JsonObject, BodyLinkCandidate | None]:
1221
+ if decision is None:
1222
+ return (
1223
+ _safe_decision_payload(
1224
+ occurrence=occurrence,
1225
+ action="defer",
1226
+ chosen=None,
1227
+ confidence=0.0,
1228
+ reason_code="missing_decision",
1229
+ rationale_summary="LLM não retornou decisão para a ocorrência.",
1230
+ rejected=True,
1231
+ ),
1232
+ None,
1233
+ )
1234
+ action = decision.action
1235
+ confidence = decision.confidence
1236
+ reason_code = decision.reason_code
1237
+ rationale = decision.rationale_summary
1238
+ chosen_target = decision.chosen_target
1239
+ chosen_meaning = decision.chosen_meaning_id
1240
+ candidates = list(occurrence.candidates)
1241
+ chosen = next(
1242
+ (
1243
+ candidate
1244
+ for candidate in candidates
1245
+ if (chosen_target and normalize_key(candidate.target) == normalize_key(chosen_target))
1246
+ or (chosen_meaning and candidate.meaning_id == chosen_meaning)
1247
+ ),
1248
+ None,
1249
+ )
1250
+ if action != "link":
1251
+ safe = _safe_decision_payload(
1252
+ occurrence=occurrence,
1253
+ action="defer" if action not in {"defer", "no_link"} else action,
1254
+ chosen=None,
1255
+ confidence=confidence,
1256
+ reason_code=reason_code or "not_linked",
1257
+ rationale_summary=rationale,
1258
+ )
1259
+ return safe, None
1260
+ if chosen is None:
1261
+ safe = _safe_decision_payload(
1262
+ occurrence=occurrence,
1263
+ action="defer",
1264
+ chosen=None,
1265
+ confidence=confidence,
1266
+ reason_code="invalid_target",
1267
+ rationale_summary="LLM escolheu alvo fora da lista fechada.",
1268
+ rejected=True,
1269
+ )
1270
+ return safe, None
1271
+ if confidence < LLM_CONFIDENCE_THRESHOLD:
1272
+ safe = _safe_decision_payload(
1273
+ occurrence=occurrence,
1274
+ action="defer",
1275
+ chosen=chosen,
1276
+ confidence=confidence,
1277
+ reason_code="confidence_below_threshold",
1278
+ rationale_summary=rationale,
1279
+ )
1280
+ return safe, None
1281
+ safe = _safe_decision_payload(
1282
+ occurrence=occurrence,
1283
+ action="link",
1284
+ chosen=chosen,
1285
+ confidence=confidence,
1286
+ reason_code=reason_code or "context_match",
1287
+ rationale_summary=rationale,
1288
+ )
1289
+ candidate = _contextual_link_candidate(
1290
+ occurrence=occurrence,
1291
+ chosen=chosen,
1292
+ confidence=confidence,
1293
+ reason_code=str(safe["reason_code"]),
1294
+ rationale_summary=rationale,
1295
+ source="contextual_alias_disambiguation",
1296
+ )
1297
+ return safe, candidate
1298
+
1299
+
1300
+ def _contextual_link_candidate(
1301
+ *,
1302
+ occurrence: ContextualOccurrence,
1303
+ chosen: SurfacePolicy,
1304
+ confidence: float,
1305
+ reason_code: str,
1306
+ rationale_summary: str,
1307
+ source: str,
1308
+ ) -> BodyLinkCandidate:
1309
+ return BodyLinkCandidate(
1310
+ source_path=occurrence.source_path,
1311
+ surface=occurrence.surface,
1312
+ matched_text=occurrence.matched_text,
1313
+ target=chosen.target,
1314
+ replacement=_replacement(chosen.target, occurrence.matched_text, in_table=occurrence.in_table),
1315
+ start=occurrence.start,
1316
+ end=occurrence.end,
1317
+ link_policy="requires_context",
1318
+ meaning_count=chosen.meaning_count,
1319
+ canonical_note_count=chosen.canonical_note_count,
1320
+ intrinsically_ambiguous=chosen.intrinsically_ambiguous,
1321
+ in_protected_markdown_zone=False,
1322
+ occurrence_id=occurrence.occurrence_id,
1323
+ meaning_id=chosen.meaning_id,
1324
+ context_hash=occurrence.context_hash,
1325
+ decision_action="link",
1326
+ confidence=confidence,
1327
+ reason_code=reason_code,
1328
+ rationale_summary=rationale_summary,
1329
+ source=source,
1330
+ )
1331
+
1332
+
1333
+ def _contextual_summary(
1334
+ *,
1335
+ mode: str,
1336
+ status: str,
1337
+ decisions: list[JsonObject],
1338
+ model: str = "",
1339
+ skipped_reason: str = "",
1340
+ blocked_reason: str = "",
1341
+ llm_error: str = "",
1342
+ ) -> JsonObject:
1343
+ safe_decisions = [_SafeContextualDecision.model_validate(item) for item in decisions]
1344
+ return _json_object(
1345
+ {
1346
+ "schema": CONTEXTUAL_ALIAS_SCHEMA,
1347
+ "phase": "contextual_alias_disambiguation",
1348
+ "status": status,
1349
+ "mode": mode,
1350
+ "model": model,
1351
+ "skipped_reason": skipped_reason,
1352
+ "blocked_reason": blocked_reason,
1353
+ "candidate_count": len(safe_decisions),
1354
+ "decision_count": len(safe_decisions),
1355
+ "linked_count": sum(1 for item in safe_decisions if item.action == "link" and item.safe_auto_apply),
1356
+ "deferred_count": sum(1 for item in safe_decisions if item.action == "defer"),
1357
+ "no_link_count": sum(1 for item in safe_decisions if item.action == "no_link"),
1358
+ "rejected_count": sum(1 for item in safe_decisions if item.rejected),
1359
+ "llm_error": llm_error,
1360
+ "decisions": decisions,
1361
+ }
1362
+ )
1363
+
1364
+
1365
+ def _persist_contextual_decisions(
1366
+ db_path: Path,
1367
+ decisions: list[JsonObject],
1368
+ *,
1369
+ model: str,
1370
+ response_payload: JsonObject,
1371
+ ) -> None:
1372
+ if not db_path.exists() or not decisions:
1373
+ return
1374
+ response_hash = (
1375
+ "sha256:" + sha256(json.dumps(response_payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
1376
+ )
1377
+ with sqlite3.connect(db_path) as conn:
1378
+ conn.execute(
1379
+ """
1380
+ CREATE TABLE IF NOT EXISTS contextual_alias_decisions (
1381
+ occurrence_id TEXT PRIMARY KEY,
1382
+ note_path TEXT NOT NULL,
1383
+ normalized_surface TEXT NOT NULL,
1384
+ matched_text TEXT NOT NULL,
1385
+ context_hash TEXT NOT NULL,
1386
+ candidate_targets_json TEXT NOT NULL,
1387
+ action TEXT NOT NULL CHECK (action IN ('link', 'no_link', 'defer')),
1388
+ chosen_meaning_id TEXT NOT NULL DEFAULT '',
1389
+ chosen_target_path TEXT NOT NULL DEFAULT '',
1390
+ chosen_target TEXT NOT NULL DEFAULT '',
1391
+ confidence REAL NOT NULL DEFAULT 0,
1392
+ model TEXT NOT NULL DEFAULT '',
1393
+ response_hash TEXT NOT NULL DEFAULT '',
1394
+ reason_code TEXT NOT NULL DEFAULT '',
1395
+ rationale_summary TEXT NOT NULL DEFAULT '',
1396
+ status TEXT NOT NULL CHECK (status IN ('active', 'rejected', 'stale')),
1397
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
1398
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
1399
+ )
1400
+ """
1401
+ )
1402
+ for decision in decisions:
1403
+ safe_decision = _SafeContextualDecision.model_validate(decision)
1404
+ conn.execute(
1405
+ """
1406
+ INSERT INTO contextual_alias_decisions(
1407
+ occurrence_id, note_path, normalized_surface, matched_text, context_hash,
1408
+ candidate_targets_json, action, chosen_meaning_id, chosen_target_path,
1409
+ chosen_target, confidence, model, response_hash, reason_code,
1410
+ rationale_summary, status
1411
+ )
1412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1413
+ ON CONFLICT(occurrence_id) DO UPDATE SET
1414
+ action=excluded.action,
1415
+ chosen_meaning_id=excluded.chosen_meaning_id,
1416
+ chosen_target_path=excluded.chosen_target_path,
1417
+ chosen_target=excluded.chosen_target,
1418
+ confidence=excluded.confidence,
1419
+ model=excluded.model,
1420
+ response_hash=excluded.response_hash,
1421
+ reason_code=excluded.reason_code,
1422
+ rationale_summary=excluded.rationale_summary,
1423
+ status=excluded.status,
1424
+ updated_at=CURRENT_TIMESTAMP
1425
+ """,
1426
+ (
1427
+ safe_decision.occurrence_id,
1428
+ safe_decision.file,
1429
+ normalize_key(safe_decision.surface),
1430
+ safe_decision.matched_text,
1431
+ safe_decision.context_hash,
1432
+ json.dumps(safe_decision.candidate_targets, ensure_ascii=False, sort_keys=True),
1433
+ safe_decision.action,
1434
+ safe_decision.chosen_meaning_id,
1435
+ "",
1436
+ safe_decision.chosen_target,
1437
+ safe_decision.confidence,
1438
+ model,
1439
+ response_hash,
1440
+ safe_decision.reason_code,
1441
+ safe_decision.rationale_summary,
1442
+ "rejected" if safe_decision.rejected else "active",
1443
+ ),
1444
+ )
1445
+
1446
+
1447
+ _EXISTING_WIKILINK_RE = re.compile(r"(?<!!)\[\[([^\]]+)\]\]")
1448
+
1449
+
1450
+ def _rewrite_candidates(path: Path, text: str, policies: list[SurfacePolicy]) -> list[BodyLinkCandidate]:
1451
+ candidates: list[BodyLinkCandidate] = []
1452
+ protected = _rewrite_protected_spans(text)
1453
+ by_surface = {policy.normalized_surface: policy for policy in policies}
1454
+ for match in _EXISTING_WIKILINK_RE.finditer(text):
1455
+ if _inside_spans(match.start(), match.end(), protected):
1456
+ continue
1457
+ raw = match.group(1).strip()
1458
+ old_target = obsidian_target_name(raw.split("|", 1)[0].split("#", 1)[0].strip())
1459
+ policy = by_surface.get(normalize_key(old_target))
1460
+ if policy is None or normalize_key(policy.target) in {normalize_key(old_target), normalize_key(path.stem)}:
1461
+ continue
1462
+ display = raw.rsplit("|", 1)[1].strip() if "|" in raw else old_target
1463
+ candidates.append(
1464
+ BodyLinkCandidate(
1465
+ source_path=str(path),
1466
+ surface=display,
1467
+ matched_text=raw,
1468
+ target=policy.target,
1469
+ replacement=_replacement(policy.target, display, in_table=_is_table_match(text, match.start())),
1470
+ start=match.start(),
1471
+ end=match.end(),
1472
+ link_policy=policy.link_policy,
1473
+ meaning_count=policy.meaning_count,
1474
+ canonical_note_count=policy.canonical_note_count,
1475
+ intrinsically_ambiguous=policy.intrinsically_ambiguous,
1476
+ in_protected_markdown_zone=False,
1477
+ )
1478
+ )
1479
+ return candidates
1480
+
1481
+
1482
+ _WORD_CHAR_RE = re.compile(r"[\wÀ-ÖØ-öø-ÿ]")
1483
+
1484
+
1485
+ def _has_term_boundaries(text: str, start: int, end: int) -> bool:
1486
+ left_ok = start == 0 or not _is_word_char(text[start - 1])
1487
+ right_ok = end >= len(text) or not _is_word_char(text[end])
1488
+ return left_ok and right_ok
1489
+
1490
+
1491
+ def _is_word_char(value: str) -> bool:
1492
+ return bool(_WORD_CHAR_RE.fullmatch(value))
1493
+
1494
+
1495
+ def _replacement(target: str, matched_text: str, *, in_table: bool) -> str:
1496
+ if matched_text == target:
1497
+ return f"[[{target}]]"
1498
+ separator = r"\|" if in_table else "|"
1499
+ return f"[[{target}{separator}{matched_text}]]"
1500
+
1501
+
1502
+ def _protected_spans(text: str) -> list[tuple[int, int]]:
1503
+ spans: list[tuple[int, int]] = _markdown_zone_spans(text)
1504
+ for pattern in (
1505
+ r"```.*?```",
1506
+ r"`[^`\n]+`",
1507
+ r"https?://\S+",
1508
+ r"!\[[^\]]*\]\([^)]+\)",
1509
+ r"!\[\[[^\]]+\]\]",
1510
+ r"\[[^\]]+\]\([^)]+\)",
1511
+ r"(?<!!)\[\[.*?\]\]",
1512
+ ):
1513
+ spans.extend((m.start(), m.end()) for m in re.finditer(pattern, text, re.DOTALL))
1514
+ spans.extend(_section_spans(text, "Notas Relacionadas"))
1515
+ spans.extend((m.start(), m.end()) for m in re.finditer(r"(?m)^#.*$", text))
1516
+ return sorted(spans)
1517
+
1518
+
1519
+ def _rewrite_protected_spans(text: str) -> list[tuple[int, int]]:
1520
+ spans: list[tuple[int, int]] = _markdown_zone_spans(text)
1521
+ for pattern in (
1522
+ r"```.*?```",
1523
+ r"`[^`\n]+`",
1524
+ r"!\[[^\]]*\]\([^)]+\)",
1525
+ r"!\[\[[^\]]+\]\]",
1526
+ ):
1527
+ spans.extend((m.start(), m.end()) for m in re.finditer(pattern, text, re.DOTALL))
1528
+ spans.extend(_section_spans(text, "Notas Relacionadas"))
1529
+ spans.extend((m.start(), m.end()) for m in re.finditer(r"(?m)^#.*$", text))
1530
+ return sorted(spans)
1531
+
1532
+
1533
+ def _markdown_zone_spans(text: str) -> list[tuple[int, int]]:
1534
+ return [(zone.start, zone.end) for zone in protected_markdown_zones(text)]
1535
+
1536
+
1537
+ def _section_spans(text: str, heading_text: str) -> list[tuple[int, int]]:
1538
+ spans: list[tuple[int, int]] = []
1539
+ pattern = re.compile(rf"(?m)^##\s+(?:🔗\s+)?{re.escape(heading_text)}\s*$")
1540
+ next_h2 = re.compile(r"(?m)^##\s+")
1541
+ for match in pattern.finditer(text):
1542
+ next_match = next_h2.search(text, match.end())
1543
+ spans.append((match.start(), next_match.start() if next_match else len(text)))
1544
+ return spans
1545
+
1546
+
1547
+ def _inside_spans(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
1548
+ return any(start < span_end and end > span_start for span_start, span_end in spans)
1549
+
1550
+
1551
+ def _line_bounds(text: str, start: int) -> tuple[int, int]:
1552
+ line_start = text.rfind("\n", 0, start) + 1
1553
+ line_end = text.find("\n", start)
1554
+ if line_end == -1:
1555
+ line_end = len(text)
1556
+ return line_start, line_end
1557
+
1558
+
1559
+ def _is_table_match(text: str, start: int) -> bool:
1560
+ line_start, line_end = _line_bounds(text, start)
1561
+ line = text[line_start:line_end]
1562
+ return "|" in line and not line.lstrip().startswith(">")