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,227 @@
1
+ r"""Etapa 5: inserção determinística de blocos de imagem em notas Markdown.
2
+
3
+ Contrato:
4
+ - ``insert_images(text, items)`` recebe o markdown completo (com ou sem
5
+ frontmatter) e uma lista de :class:`InsertedImage`. Para cada item:
6
+ localiza a seção alvo via ``section_path`` e insere o bloco
7
+ ``![[<filename>]]`` + caption no fim da seção. Em seguida aplica patch
8
+ aditivo no frontmatter (``images_enriched``, ``images_enriched_at``,
9
+ ``image_count``, ``image_sources``) sem reordenar nem mexer nas chaves
10
+ existentes (``chat_id``, ``url``, ``title``, ``exported_at``, ``model``,
11
+ ``source``, ``tags``).
12
+ - ``items`` vazio → devolve ``text`` sem mudança alguma (sem patch).
13
+ - Path ambíguo (mais de uma seção com o mesmo trilho): primeira ocorrência
14
+ vence. Limitação documentada — não há campo ``occurrence`` ainda.
15
+ - Path inexistente: levanta :class:`SectionNotFound`.
16
+
17
+ Suporta headings ATX (``#``..``######``) e setext (``===``/``---``
18
+ underline). Linhas dentro de blocos de código fenced (``\`\`\``` ou
19
+ ``~~~``) são ignoradas para fins de detecção de headings.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from collections import Counter, defaultdict
25
+ from collections.abc import Iterable
26
+ from dataclasses import dataclass, field
27
+ from datetime import UTC, datetime
28
+
29
+ from mednotes.domains.wiki.capabilities.illustrate.core import frontmatter
30
+
31
+ __all__ = ["InsertedImage", "SectionNotFound", "insert_images", "parse_sections"]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class InsertedImage:
36
+ anchor_id: str
37
+ section_path: list[str]
38
+ image_filename: str
39
+ concept: str
40
+ source: str
41
+ source_url: str
42
+
43
+
44
+ class SectionNotFound(LookupError):
45
+ """``section_path`` não casa com nenhuma seção da nota."""
46
+
47
+
48
+ @dataclass
49
+ class _Section:
50
+ start_line: int # índice da linha do heading (0-based, em body)
51
+ end_line: int # exclusivo: primeira linha da próxima seção de nível ≤
52
+ level: int
53
+ text: str
54
+ path: list[str] = field(default_factory=list)
55
+
56
+
57
+ _ATX_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$")
58
+ _FENCE_RE = re.compile(r"^[ \t]*(```+|~~~+)")
59
+
60
+
61
+ def _parse_headings(body: str) -> tuple[list[str], list[_Section]]:
62
+ """Devolve (lines_split, sections). ``lines_split`` é ``body.split('\\n')``
63
+ (preserva trailing-newline como elemento '' final, se houver)."""
64
+ lines = body.split("\n")
65
+ raw: list[tuple[int, int, str]] = [] # (line_idx, level, text)
66
+ in_fence = False
67
+ i = 0
68
+ while i < len(lines):
69
+ line = lines[i]
70
+ if _FENCE_RE.match(line):
71
+ in_fence = not in_fence
72
+ i += 1
73
+ continue
74
+ if in_fence:
75
+ i += 1
76
+ continue
77
+ m = _ATX_RE.match(line)
78
+ if m:
79
+ raw.append((i, len(m.group(1)), m.group(2).strip()))
80
+ i += 1
81
+ continue
82
+ # Setext: linha não-vazia seguida de '===+' (H1) ou '---+' (H2).
83
+ stripped = line.strip()
84
+ if stripped and i + 1 < len(lines):
85
+ nxt = lines[i + 1].strip()
86
+ if nxt and (set(nxt) == {"="} or set(nxt) == {"-"}):
87
+ level = 1 if nxt[0] == "=" else 2
88
+ raw.append((i, level, stripped))
89
+ i += 2
90
+ continue
91
+ i += 1
92
+
93
+ # Constrói paths via stack de (level, text).
94
+ sections: list[_Section] = []
95
+ stack: list[tuple[int, str]] = []
96
+ for line_idx, level, text in raw:
97
+ while stack and stack[-1][0] >= level:
98
+ stack.pop()
99
+ path = [t for _, t in stack] + [text]
100
+ stack.append((level, text))
101
+ sections.append(_Section(line_idx, -1, level, text, path))
102
+
103
+ # end_line: próxima seção de nível <= esta, ou len(lines).
104
+ for idx, sec in enumerate(sections):
105
+ end = len(lines)
106
+ for j in range(idx + 1, len(sections)):
107
+ if sections[j].level <= sec.level:
108
+ end = sections[j].start_line
109
+ break
110
+ sec.end_line = end
111
+
112
+ return lines, sections
113
+
114
+
115
+ def _find_section(sections: list[_Section], path: list[str]) -> _Section:
116
+ for sec in sections:
117
+ if sec.path == path:
118
+ return sec
119
+ raise SectionNotFound(f"section_path não encontrado: {path!r}")
120
+
121
+
122
+ def _block_for(item: InsertedImage) -> tuple[str, str]:
123
+ """(linha do embed, linha da caption)."""
124
+ embed = f"![[{item.image_filename}]]"
125
+ # Normaliza concept: strip whitespace e qualquer pontuação final repetida
126
+ # ('.', '!', '?'), evitando '..*' quando o agente passa um conceito que
127
+ # já termina em ponto.
128
+ concept = item.concept.strip().rstrip(".!?")
129
+ caption = (
130
+ f"*Figura: {concept}.* "
131
+ f"*Fonte: {item.source} — {item.source_url}*"
132
+ )
133
+ return embed, caption
134
+
135
+
136
+ def _build_group(items: list[InsertedImage]) -> list[str]:
137
+ """Linhas a inserir para um grupo na mesma seção, com '' (linha em branco)
138
+ entre captions e nas bordas. As bordas podem ser podadas pelo caller."""
139
+ out: list[str] = []
140
+ for item in items:
141
+ out.append("")
142
+ embed, caption = _block_for(item)
143
+ out.append(embed)
144
+ out.append(caption)
145
+ out.append("")
146
+ return out
147
+
148
+
149
+ def _insert_at(lines: list[str], idx: int, group: list[str]) -> list[str]:
150
+ """Insere ``group`` em ``lines`` na posição ``idx``, podando bordas em
151
+ branco redundantes pra evitar linhas vazias duplicadas."""
152
+ g = list(group)
153
+ if idx > 0 and lines[idx - 1] == "" and g and g[0] == "":
154
+ g.pop(0)
155
+ if idx < len(lines) and lines[idx] == "" and g and g[-1] == "":
156
+ g.pop()
157
+ return lines[:idx] + g + lines[idx:]
158
+
159
+
160
+ def _build_patch(
161
+ items: list[InsertedImage], now: datetime
162
+ ) -> dict[str, object]:
163
+ counts = Counter(item.source for item in items)
164
+ sorted_sources = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
165
+ return {
166
+ "images_enriched": True,
167
+ "images_enriched_at": now,
168
+ "image_count": len(items),
169
+ "image_sources": [
170
+ {"source": s, "count": c} for s, c in sorted_sources
171
+ ],
172
+ }
173
+
174
+
175
+ def parse_sections(text: str) -> list[dict[str, object]]:
176
+ """Wrapper público sobre o parser de headings, JSON-serializável.
177
+
178
+ Devolve uma lista de dicts ``{section_path, level, start_line, end_line,
179
+ text}``. ``start_line`` e ``end_line`` são índices 0-based em **body**
180
+ (após `frontmatter.read`); ``end_line`` é exclusivo.
181
+ """
182
+ _, body = frontmatter.read(text)
183
+ _, sections = _parse_headings(body)
184
+ return [
185
+ {
186
+ "section_path": list(s.path),
187
+ "level": s.level,
188
+ "text": s.text,
189
+ "start_line": s.start_line,
190
+ "end_line": s.end_line,
191
+ }
192
+ for s in sections
193
+ ]
194
+
195
+
196
+ def insert_images(
197
+ text: str,
198
+ items: Iterable[InsertedImage],
199
+ *,
200
+ now: datetime | None = None,
201
+ ) -> str:
202
+ items_list = list(items)
203
+ if not items_list:
204
+ return text
205
+
206
+ meta, body = frontmatter.read(text)
207
+ lines, sections = _parse_headings(body)
208
+
209
+ # Agrupa items por seção alvo (ordem de items preservada dentro de cada grupo).
210
+ by_section: dict[int, list[InsertedImage]] = defaultdict(list)
211
+ for item in items_list:
212
+ sec = _find_section(sections, list(item.section_path))
213
+ by_section[sections.index(sec)].append(item)
214
+
215
+ # Insere de baixo pra cima pra não invalidar end_line.
216
+ for sec_idx in sorted(by_section.keys(), reverse=True):
217
+ sec = sections[sec_idx]
218
+ group = _build_group(by_section[sec_idx])
219
+ lines = _insert_at(lines, sec.end_line, group)
220
+
221
+ new_body = "\n".join(lines)
222
+
223
+ if now is None:
224
+ now = datetime.now(UTC)
225
+ patch = _build_patch(items_list, now)
226
+ new_meta = {**meta, **patch}
227
+ return frontmatter.write(new_meta, new_body)
@@ -0,0 +1,54 @@
1
+ """Local image staging/commit helpers for reviewed PDF crops."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from PIL import Image
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class StagedImage:
14
+ path: Path
15
+ sha256: str
16
+ filename: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ImportedImage:
21
+ path: Path
22
+ filename: str
23
+ sha256: str
24
+
25
+
26
+ def stage_crop(crop_path: Path, *, app_home: Path) -> StagedImage:
27
+ crop_path = crop_path.expanduser().resolve(strict=True)
28
+ with Image.open(crop_path) as image:
29
+ image.verify()
30
+ sha = _sha256_file(crop_path)
31
+ staging = app_home / "staging"
32
+ staging.mkdir(parents=True, exist_ok=True)
33
+ suffix = crop_path.suffix.lower() if crop_path.suffix else ".png"
34
+ filename = f"{sha[:16]}{suffix}"
35
+ target = staging / filename
36
+ if not target.exists():
37
+ shutil.copy2(crop_path, target)
38
+ return StagedImage(path=target, sha256=sha, filename=filename)
39
+
40
+
41
+ def commit_staged_image(staged: StagedImage, *, attachments_dir: Path) -> ImportedImage:
42
+ attachments_dir.mkdir(parents=True, exist_ok=True)
43
+ target = attachments_dir / staged.filename
44
+ if not target.exists():
45
+ shutil.copy2(staged.path, target)
46
+ return ImportedImage(path=target, filename=target.name, sha256=staged.sha256)
47
+
48
+
49
+ def _sha256_file(path: Path) -> str:
50
+ digest = hashlib.sha256()
51
+ with path.open("rb") as handle:
52
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
53
+ digest.update(chunk)
54
+ return digest.hexdigest()
@@ -0,0 +1,42 @@
1
+ """Adapters de fontes de imagem.
2
+
3
+ Cada adapter expõe:
4
+ - ``NAME``: identificador curto (``"wikimedia"``, ``"openstax"``, ...).
5
+ - ``search(query, visual_type, *, top_k=4, client=None) -> list[ImageCandidate]``.
6
+
7
+ Falha comum de um adapter não derruba os outros — a etapa de busca chama todos
8
+ e ignora exceções individuais. Exceções fatais, como cota paga esgotada, devem
9
+ parar o orquestrador para evitar que o lote continue batendo na API.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ class SourceQuotaExceeded(RuntimeError):
17
+ """Erro fatal quando uma fonte paga bloqueia busca por cota/limite."""
18
+
19
+ def __init__(self, source: str, message: str):
20
+ super().__init__(message)
21
+ self.source = source
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ImageCandidate:
26
+ source: str # ex: "wikimedia"
27
+ source_url: str # URL da página descritiva (rastreabilidade)
28
+ image_url: str # URL para download direto
29
+ title: str
30
+ description: str
31
+ width: int | None
32
+ height: int | None
33
+ license: str | None # informativo (uso pessoal/fair use)
34
+ score: float | None # relevância da fonte, opcional
35
+ thumbnail_url: str | None = None # fallback/proxy quando o original bloqueia
36
+ source_profile: str | None = None
37
+ page_domain: str | None = None
38
+ quality_hints: tuple[str, ...] = ()
39
+ trust_score: float | None = None
40
+
41
+
42
+ __all__ = ["ImageCandidate", "SourceQuotaExceeded"]
@@ -0,0 +1,99 @@
1
+ """Trusted web-profile image sources built on top of web_search."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ import httpx
7
+
8
+ from mednotes.domains.wiki.capabilities.illustrate.sources import ImageCandidate, web_search
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class WebProfile:
13
+ name: str
14
+ domains: tuple[str, ...]
15
+ visual_types: tuple[str, ...]
16
+ trust_score: float
17
+
18
+
19
+ PROFILES: dict[str, WebProfile] = {
20
+ "radiopaedia": WebProfile(
21
+ name="radiopaedia",
22
+ domains=("radiopaedia.org",),
23
+ visual_types=("radiology",),
24
+ trust_score=0.95,
25
+ ),
26
+ "nih_open_i": WebProfile(
27
+ name="nih_open_i",
28
+ domains=("nih.gov", "ncbi.nlm.nih.gov"),
29
+ visual_types=("diagram", "histology", "radiology", "photo", "chart"),
30
+ trust_score=0.90,
31
+ ),
32
+ "openstax": WebProfile(
33
+ name="openstax",
34
+ domains=("openstax.org",),
35
+ visual_types=("anatomy", "diagram", "chart"),
36
+ trust_score=0.85,
37
+ ),
38
+ "dermnet": WebProfile(
39
+ name="dermnet",
40
+ domains=("dermnetnz.org",),
41
+ visual_types=("photo",),
42
+ trust_score=0.88,
43
+ ),
44
+ "teachmeanatomy": WebProfile(
45
+ name="teachmeanatomy",
46
+ domains=("teachmeanatomy.info",),
47
+ visual_types=("anatomy",),
48
+ trust_score=0.82,
49
+ ),
50
+ }
51
+
52
+
53
+ def search_profile(
54
+ profile_name: str,
55
+ query: str,
56
+ visual_type: str,
57
+ *,
58
+ top_k: int = 4,
59
+ client: httpx.Client | None = None,
60
+ language: str | None = None,
61
+ ) -> list[ImageCandidate]:
62
+ profile = PROFILES[profile_name]
63
+ if visual_type not in profile.visual_types:
64
+ return []
65
+ out: list[ImageCandidate] = []
66
+ per_domain = max(1, top_k // len(profile.domains))
67
+ for domain in profile.domains:
68
+ out.extend(
69
+ web_search.search(
70
+ query,
71
+ visual_type,
72
+ top_k=per_domain,
73
+ client=client,
74
+ language=language,
75
+ site_filter=domain,
76
+ source_label=profile.name,
77
+ )
78
+ )
79
+ return [_with_profile_hints(item, profile) for item in out[:top_k]]
80
+
81
+
82
+ def _with_profile_hints(candidate: ImageCandidate, profile: WebProfile) -> ImageCandidate:
83
+ hints = tuple(dict.fromkeys([*candidate.quality_hints, "trusted_web_profile"]))
84
+ return ImageCandidate(
85
+ source=candidate.source,
86
+ source_url=candidate.source_url,
87
+ image_url=candidate.image_url,
88
+ title=candidate.title,
89
+ description=candidate.description,
90
+ width=candidate.width,
91
+ height=candidate.height,
92
+ license=candidate.license,
93
+ score=candidate.score,
94
+ thumbnail_url=candidate.thumbnail_url,
95
+ source_profile=profile.name,
96
+ page_domain=candidate.page_domain,
97
+ quality_hints=hints,
98
+ trust_score=profile.trust_score,
99
+ )
@@ -0,0 +1,203 @@
1
+ """Adapter de busca web genérica via SerpAPI (engine ``google_images``).
2
+
3
+ Pra cobrir o que Wikimedia/fontes médicas curadas não têm. Pago — usuário
4
+ guarda a chave no keyring do sistema ou usa env como fallback técnico. Sem a
5
+ chave, ``search`` devolve ``[]`` silenciosamente. Cota/limite esgotado levanta
6
+ ``SourceQuotaExceeded`` para o orquestrador parar o lote e avisar o usuário.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ import httpx
16
+ from pydantic import ValidationError as PydanticValidationError
17
+
18
+ from mednotes.domains.wiki.capabilities.illustrate.sources import ImageCandidate, SourceQuotaExceeded
19
+ from mednotes.platform.paths import find_config
20
+ from mednotes.platform.secrets import resolve_secret
21
+ from mednotes.platform.user_config import SecretConfig, load_user_config
22
+
23
+ NAME = "web_search"
24
+
25
+ _ENDPOINT = "https://serpapi.com/search.json"
26
+ _QUOTA_STATUS_CODES = {402, 429}
27
+ _QUOTA_MARKERS = (
28
+ "quota",
29
+ "exceeded",
30
+ "exhaust",
31
+ "run out",
32
+ "monthly search",
33
+ "searches per month",
34
+ "credits",
35
+ "rate limit",
36
+ "too many requests",
37
+ )
38
+ _LANGUAGE_TO_GOOGLE_PARAMS = {
39
+ "pt-br": {"hl": "pt-br", "gl": "br"},
40
+ "en": {"hl": "en", "gl": "us"},
41
+ }
42
+
43
+
44
+ def _serpapi_secret_config() -> SecretConfig:
45
+ try:
46
+ return load_user_config(find_config(start=Path.cwd())).secrets.serpapi
47
+ except (OSError, tomllib.TOMLDecodeError, PydanticValidationError):
48
+ return SecretConfig()
49
+
50
+
51
+ def _serpapi_key(explicit: str | None = None) -> str | None:
52
+ if explicit:
53
+ return explicit
54
+ result = resolve_secret("serpapi", _serpapi_secret_config())
55
+ return result.value if result.status == "available" else None
56
+
57
+
58
+ def search(
59
+ query: str,
60
+ visual_type: str,
61
+ *,
62
+ top_k: int = 4,
63
+ client: httpx.Client | None = None,
64
+ api_key: str | None = None,
65
+ language: str | None = None,
66
+ site_filter: str | None = None,
67
+ source_label: str | None = None,
68
+ ) -> list[ImageCandidate]:
69
+ """Busca imagens via SerpAPI (Google Images).
70
+
71
+ Sem chave via keyring/env e sem ``api_key`` explícito, devolve ``[]``.
72
+ ``visual_type`` é aceito por uniformidade com outros adapters mas não
73
+ é mapeado em facets do SerpAPI.
74
+
75
+ ``language`` é mapeado para os params ``hl`` (UI language) e ``gl``
76
+ (geolocation) do Google Images. Aceita ``"pt-br"`` e ``"en"``;
77
+ qualquer outro valor (inclusive ``"any"`` e ``None``) → sem param.
78
+ """
79
+ key = _serpapi_key(api_key)
80
+ if not key:
81
+ return []
82
+
83
+ search_query = _query_with_site_filter(query, site_filter)
84
+ params: dict[str, str] = {
85
+ "engine": "google_images",
86
+ "q": search_query,
87
+ "api_key": key,
88
+ "num": str(max(top_k * 2, top_k)),
89
+ }
90
+ lang_params = _LANGUAGE_TO_GOOGLE_PARAMS.get((language or "").lower())
91
+ if lang_params:
92
+ params.update(lang_params)
93
+
94
+ owns_client = client is None
95
+ if owns_client:
96
+ client = httpx.Client(timeout=15.0)
97
+ try:
98
+ resp = client.get(_ENDPOINT, params=params)
99
+ error_message = _response_error_message(resp)
100
+ if _is_quota_error(resp.status_code, error_message):
101
+ raise SourceQuotaExceeded(
102
+ NAME,
103
+ f"SerpAPI bloqueou a busca por cota/limite: "
104
+ f"{error_message or f'HTTP {resp.status_code}'}",
105
+ )
106
+ resp.raise_for_status()
107
+ data = resp.json()
108
+ api_error = _api_error_message(data)
109
+ if _is_quota_error(resp.status_code, api_error):
110
+ raise SourceQuotaExceeded(
111
+ NAME,
112
+ f"SerpAPI bloqueou a busca por cota/limite: {api_error}",
113
+ )
114
+ finally:
115
+ if owns_client:
116
+ client.close()
117
+
118
+ return _parse(data, top_k=top_k, source_label=source_label)
119
+
120
+
121
+ def _query_with_site_filter(query: str, site_filter: str | None) -> str:
122
+ if not site_filter:
123
+ return query
124
+ prefix = f"site:{site_filter}"
125
+ if query.strip().lower().startswith(prefix.lower()):
126
+ return query
127
+ return f"{prefix} {query}"
128
+
129
+
130
+ def _domain(url: str) -> str | None:
131
+ host = urlparse(url).netloc.lower()
132
+ if host.startswith("www."):
133
+ host = host[4:]
134
+ return host or None
135
+
136
+
137
+ def _response_error_message(resp: httpx.Response) -> str:
138
+ try:
139
+ data = resp.json()
140
+ except ValueError:
141
+ return resp.text.strip()
142
+ return _api_error_message(data)
143
+
144
+
145
+ def _api_error_message(data: Any) -> str:
146
+ if not isinstance(data, dict):
147
+ return ""
148
+ for key in ("error", "message"):
149
+ value = data.get(key)
150
+ if value:
151
+ return str(value)
152
+ errors = data.get("errors")
153
+ if isinstance(errors, list):
154
+ return "; ".join(str(item) for item in errors if item)
155
+ if errors:
156
+ return str(errors)
157
+ return ""
158
+
159
+
160
+ def _is_quota_error(status_code: int, message: str) -> bool:
161
+ lowered = (message or "").lower()
162
+ if status_code in _QUOTA_STATUS_CODES:
163
+ return True
164
+ return bool(lowered and any(marker in lowered for marker in _QUOTA_MARKERS))
165
+
166
+
167
+ def _parse(
168
+ data: dict[str, Any],
169
+ *,
170
+ top_k: int,
171
+ source_label: str | None = None,
172
+ ) -> list[ImageCandidate]:
173
+ results = data.get("images_results") or []
174
+ out: list[ImageCandidate] = []
175
+ label = source_label or NAME
176
+ for r in results:
177
+ thumbnail_url = r.get("thumbnail")
178
+ image_url = r.get("original") or thumbnail_url
179
+ if not image_url:
180
+ continue
181
+ # `link` é a página onde a imagem aparece; `source` é o domínio.
182
+ source_url = r.get("link") or image_url
183
+ title = r.get("title", "") or ""
184
+ description = r.get("snippet") or r.get("source") or title
185
+ out.append(
186
+ ImageCandidate(
187
+ source=label,
188
+ source_url=source_url,
189
+ image_url=image_url,
190
+ title=title,
191
+ description=description,
192
+ width=r.get("original_width"),
193
+ height=r.get("original_height"),
194
+ license=None, # SerpAPI não devolve licença
195
+ score=None,
196
+ thumbnail_url=thumbnail_url,
197
+ source_profile=source_label,
198
+ page_domain=_domain(source_url),
199
+ )
200
+ )
201
+ if len(out) >= top_k:
202
+ break
203
+ return out