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.
- package/.opencode/agents/med-chat-triager.md +204 -0
- package/.opencode/agents/med-flashcard-maker.md +63 -0
- package/.opencode/agents/med-knowledge-architect.md +230 -0
- package/.opencode/agents/med-link-graph-curator.md +177 -0
- package/.opencode/agents/med-publish-guard.md +62 -0
- package/.opencode/commands/flashcards.md +25 -0
- package/.opencode/commands/mednotes/create.md +25 -0
- package/.opencode/commands/mednotes/enrich.md +27 -0
- package/.opencode/commands/mednotes/fix-wiki.md +27 -0
- package/.opencode/commands/mednotes/history.md +22 -0
- package/.opencode/commands/mednotes/link-body.md +25 -0
- package/.opencode/commands/mednotes/link-related.md +27 -0
- package/.opencode/commands/mednotes/link.md +27 -0
- package/.opencode/commands/mednotes/pdf-library.md +27 -0
- package/.opencode/commands/mednotes/process-chats.md +23 -0
- package/.opencode/commands/mednotes/setup.md +21 -0
- package/.opencode/commands/mednotes/status.md +27 -0
- package/.opencode/commands/mednotes/telemetry.md +27 -0
- package/.opencode/commands/report.md +26 -0
- package/.opencode/mednotes/AGENTS.md +57 -0
- package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
- package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
- package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
- package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
- package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
- package/.opencode/mednotes/contracts/.gitkeep +1 -0
- package/.opencode/mednotes/contracts/agents.json +116 -0
- package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
- package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
- package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
- package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
- package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
- package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
- package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
- package/.opencode/mednotes/docs/extension-docs.md +40 -0
- package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
- package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
- package/.opencode/mednotes/docs/merge-policy.md +110 -0
- package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
- package/.opencode/mednotes/docs/semantic-linker.md +141 -0
- package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
- package/.opencode/mednotes/docs/triage-policy.md +187 -0
- package/.opencode/mednotes/docs/vault-version-control.md +758 -0
- package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
- package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
- package/.opencode/mednotes/hooks/hooks.json +79 -0
- package/.opencode/mednotes/package-lock.json +6361 -0
- package/.opencode/mednotes/package.json +15 -0
- package/.opencode/mednotes/pyproject.toml +48 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
- package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
- package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
- package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
- package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
- package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
- package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
- package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
- package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
- package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
- package/.opencode/mednotes/scripts/run_python.mjs +109 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
- package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
- package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
- package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
- package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
- package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
- package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
- package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
- package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
- package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
- package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
- package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
- package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
- package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
- package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
- package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
- package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
- package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
- package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
- package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
- package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
- package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
- package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
- package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
- package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
- package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
- package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
- package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
- package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
- package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
- package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
- package/.opencode/mednotes/uv.lock +932 -0
- package/.opencode/mednotes.generated.json +395 -0
- package/.opencode/opencode.json +31 -0
- package/.opencode/plugins/mednotes-fsm.mjs +7 -0
- package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
- package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
- package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
- package/AGENTS.md +57 -0
- package/README.md +194 -0
- package/adapters/antigravity/agents.json +80 -0
- package/adapters/antigravity/templates/med-chat-triager.md +214 -0
- package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
- package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
- package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
- package/adapters/antigravity/templates/med-publish-guard.md +71 -0
- package/adapters/gemini-cli/gemini-extension.json +14 -0
- package/adapters/gemini-cli/package.json +15 -0
- package/adapters/gemini-cli/pyproject.toml +48 -0
- package/bin/mednotes-opencode.mjs +155 -0
- package/contracts/agents.json +116 -0
- package/core/agents/med-chat-triager.md +197 -0
- package/core/agents/med-flashcard-maker.md +56 -0
- package/core/agents/med-knowledge-architect.md +224 -0
- package/core/agents/med-link-graph-curator.md +171 -0
- package/core/agents/med-publish-guard.md +55 -0
- package/core/commands/flashcards.toml +22 -0
- package/core/commands/mednotes/create.toml +22 -0
- package/core/commands/mednotes/enrich.toml +24 -0
- package/core/commands/mednotes/fix-wiki.toml +24 -0
- package/core/commands/mednotes/history.toml +19 -0
- package/core/commands/mednotes/link-body.toml +22 -0
- package/core/commands/mednotes/link-related.toml +24 -0
- package/core/commands/mednotes/link.toml +24 -0
- package/core/commands/mednotes/pdf-library.toml +24 -0
- package/core/commands/mednotes/process-chats.toml +20 -0
- package/core/commands/mednotes/setup.toml +18 -0
- package/core/commands/mednotes/status.toml +24 -0
- package/core/commands/mednotes/telemetry.toml +24 -0
- package/core/commands/report.toml +23 -0
- package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/core/skills/create-medical-flashcards/SKILL.md +113 -0
- package/core/skills/create-medical-note/SKILL.md +90 -0
- package/core/skills/enrich-medical-note/SKILL.md +120 -0
- package/core/skills/fix-medical-wiki/SKILL.md +559 -0
- package/core/skills/link-medical-wiki/SKILL.md +224 -0
- package/core/skills/obsidian-cli/SKILL.md +118 -0
- package/core/skills/obsidian-markdown/SKILL.md +207 -0
- package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/core/skills/obsidian-ops/SKILL.md +136 -0
- package/core/skills/pdf-library/SKILL.md +45 -0
- package/core/skills/process-medical-chats/SKILL.md +246 -0
- package/core/skills/workflow-report/SKILL.md +100 -0
- package/package.json +45 -0
|
@@ -0,0 +1,2758 @@
|
|
|
1
|
+
"""Semantic linker and graph-audit orchestration for the Wiki CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import ConfigDict, Field, field_validator
|
|
12
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
13
|
+
|
|
14
|
+
from mednotes.domains.wiki.batch_state import canonical_json_hash, file_sha256
|
|
15
|
+
from mednotes.domains.wiki.capabilities.body_link.body_linker import (
|
|
16
|
+
DEFAULT_LLM_DISAMBIGUATION_MODEL,
|
|
17
|
+
DEFAULT_LLM_TIMEOUT_SECONDS,
|
|
18
|
+
apply_body_linker_plan,
|
|
19
|
+
)
|
|
20
|
+
from mednotes.domains.wiki.capabilities.body_link.body_linker import (
|
|
21
|
+
run_body_linker as run_db_body_linker,
|
|
22
|
+
)
|
|
23
|
+
from mednotes.domains.wiki.capabilities.graph import graph as wiki_graph
|
|
24
|
+
from mednotes.domains.wiki.capabilities.notes.note_iter import iter_notes
|
|
25
|
+
from mednotes.domains.wiki.capabilities.notes.note_style.frontmatter import infer_title
|
|
26
|
+
from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
|
|
27
|
+
from mednotes.domains.wiki.capabilities.related_notes.related_notes import (
|
|
28
|
+
RELATED_NOTES_SYNC_SCHEMA,
|
|
29
|
+
default_export_path,
|
|
30
|
+
recover_related_notes_export_operation_result,
|
|
31
|
+
sync_related_notes_operation_result,
|
|
32
|
+
)
|
|
33
|
+
from mednotes.domains.wiki.capabilities.vocabulary.link_terms import extract_aliases, normalize_key
|
|
34
|
+
from mednotes.domains.wiki.capabilities.vocabulary.link_terms import is_index_note as _is_index_note
|
|
35
|
+
from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_bootstrap import planned_vocabulary_bootstrap
|
|
36
|
+
from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_curator_batch import build_vocabulary_curator_batch_plan
|
|
37
|
+
from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_ingestion import apply_semantic_ingestion
|
|
38
|
+
from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_map import (
|
|
39
|
+
initialize_vocabulary_db,
|
|
40
|
+
load_vocabulary_map_diagnosis,
|
|
41
|
+
note_content_hash,
|
|
42
|
+
upsert_note,
|
|
43
|
+
)
|
|
44
|
+
from mednotes.domains.wiki.common import ValidationError, _now_iso, wiki_cli_command
|
|
45
|
+
from mednotes.domains.wiki.config import MedConfig, _path
|
|
46
|
+
from mednotes.domains.wiki.contracts.link_git import LinkGitContext, LinkState
|
|
47
|
+
from mednotes.domains.wiki.contracts.related_notes_runtime import (
|
|
48
|
+
LinkRelatedSyncResult,
|
|
49
|
+
RelatedNotesPassSummary,
|
|
50
|
+
RelatedNotesRecoveryState,
|
|
51
|
+
)
|
|
52
|
+
from mednotes.domains.wiki.contracts.workflow_blockers import blocker_entry, decision_for_code
|
|
53
|
+
from mednotes.domains.wiki.contracts.workflow_guardrails import LINK_REQUIRED_INPUTS
|
|
54
|
+
from mednotes.domains.wiki.flows.link.link_git import (
|
|
55
|
+
collect_git_context,
|
|
56
|
+
load_link_state,
|
|
57
|
+
trigger_context_from_git,
|
|
58
|
+
write_link_state,
|
|
59
|
+
)
|
|
60
|
+
from mednotes.domains.wiki.flows.link.link_retry_governance import (
|
|
61
|
+
build_diagnosis_identity,
|
|
62
|
+
force_diagnose_event,
|
|
63
|
+
record_diagnosis_attempt,
|
|
64
|
+
redundant_diagnosis_payload,
|
|
65
|
+
)
|
|
66
|
+
from mednotes.domains.wiki.flows.link.link_triggers import (
|
|
67
|
+
affected_notes_from_context,
|
|
68
|
+
derive_triggers,
|
|
69
|
+
is_image_only_context,
|
|
70
|
+
load_trigger_context,
|
|
71
|
+
structural_events_from_context,
|
|
72
|
+
)
|
|
73
|
+
from mednotes.domains.wiki.flows.link.reference_repair import apply_reference_repair_plan, plan_reference_repair
|
|
74
|
+
from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
|
|
75
|
+
|
|
76
|
+
LINK_DIAGNOSIS_SCHEMA = "medical-notes-workbench.link-diagnosis.v1"
|
|
77
|
+
LINK_RUN_SCHEMA = "medical-notes-workbench.link-run.v1"
|
|
78
|
+
LINK_RUN_RECEIPT_SCHEMA = "medical-notes-workbench.link-run-receipt.v1"
|
|
79
|
+
RELATED_NOTES_CONVERGENCE_MAX_PASSES = 3
|
|
80
|
+
LINK_PHASE_ORDER = (
|
|
81
|
+
"reference_repair",
|
|
82
|
+
"contextual_alias_disambiguation",
|
|
83
|
+
"body_term_linker",
|
|
84
|
+
"related_notes_sync",
|
|
85
|
+
"graph_validation",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _json_object(payload: object) -> JsonObject:
|
|
90
|
+
if isinstance(payload, dict):
|
|
91
|
+
return cast(JsonObject, payload)
|
|
92
|
+
return JsonObjectAdapter.validate_python(payload)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _json_object_or_empty(payload: object | None) -> JsonObject:
|
|
96
|
+
return _json_object(payload) if isinstance(payload, dict) else {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _json_field(payload: JsonObject, key: str, default: object = "") -> object:
|
|
100
|
+
return payload[key] if key in payload else default
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _json_text(payload: JsonObject, key: str, default: str = "") -> str:
|
|
104
|
+
"""Read a text field from already-validated JSON without loose `str(x or "")` fallback."""
|
|
105
|
+
|
|
106
|
+
value = _json_field(payload, key, default)
|
|
107
|
+
return value if isinstance(value, str) else default
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _json_bool(payload: JsonObject, key: str, default: bool = False) -> bool:
|
|
111
|
+
"""Read boolean flags used for linker flow decisions from validated JSON."""
|
|
112
|
+
|
|
113
|
+
value = _json_field(payload, key, default)
|
|
114
|
+
return value if isinstance(value, bool) else default
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _json_int(payload: JsonObject, key: str, default: int = 0) -> int:
|
|
118
|
+
value = _json_field(payload, key, default)
|
|
119
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
120
|
+
raise ValueError(f"{key} must be an integer")
|
|
121
|
+
if value < 0:
|
|
122
|
+
raise ValueError(f"{key} must be non-negative")
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_VocabularyMapStatus = Literal[
|
|
127
|
+
"",
|
|
128
|
+
"blocked",
|
|
129
|
+
"blocked_human",
|
|
130
|
+
"blocked_pending",
|
|
131
|
+
"failed",
|
|
132
|
+
"planned",
|
|
133
|
+
"ready",
|
|
134
|
+
"skipped",
|
|
135
|
+
]
|
|
136
|
+
_VocabularyBootstrapStatus = Literal[
|
|
137
|
+
"",
|
|
138
|
+
"completed",
|
|
139
|
+
"existing",
|
|
140
|
+
"failed",
|
|
141
|
+
"planned",
|
|
142
|
+
"queued_semantic_ingestion",
|
|
143
|
+
"ready",
|
|
144
|
+
"skipped",
|
|
145
|
+
]
|
|
146
|
+
_VocabularySemanticRepairStatus = Literal["completed", "completed_with_blockers", "skipped"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _ContextualAliasDiagnosis(ContractModel):
|
|
150
|
+
"""Typed view for contextual alias evidence emitted by the body linker."""
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(extra="ignore")
|
|
153
|
+
|
|
154
|
+
status: str = "skipped"
|
|
155
|
+
mode: str = ""
|
|
156
|
+
candidate_count: int = Field(default=0, ge=0, strict=True)
|
|
157
|
+
decision_count: int = Field(default=0, ge=0, strict=True)
|
|
158
|
+
linked_count: int = Field(default=0, ge=0, strict=True)
|
|
159
|
+
deferred_count: int = Field(default=0, ge=0, strict=True)
|
|
160
|
+
no_link_count: int = Field(default=0, ge=0, strict=True)
|
|
161
|
+
rejected_count: int = Field(default=0, ge=0, strict=True)
|
|
162
|
+
skipped_reason: str = ""
|
|
163
|
+
blocked_reason: str = ""
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_payload(cls, value: object) -> _ContextualAliasDiagnosis:
|
|
167
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _GitContextView(ContractModel):
|
|
171
|
+
"""Typed projection of git context fields consumed by link apply safety."""
|
|
172
|
+
|
|
173
|
+
model_config = ConfigDict(extra="ignore")
|
|
174
|
+
|
|
175
|
+
available: bool = False
|
|
176
|
+
repo_root: str = ""
|
|
177
|
+
branch: str = ""
|
|
178
|
+
head: str = ""
|
|
179
|
+
status_hash: str = ""
|
|
180
|
+
changed_paths: list[JsonObject] = Field(default_factory=list)
|
|
181
|
+
unavailable_reason: str = ""
|
|
182
|
+
|
|
183
|
+
@field_validator("changed_paths", mode="before")
|
|
184
|
+
@classmethod
|
|
185
|
+
def _changed_paths_or_empty(cls, value: object) -> list[JsonObject]:
|
|
186
|
+
if not isinstance(value, list):
|
|
187
|
+
return []
|
|
188
|
+
return [_json_object(item) for item in value if isinstance(item, dict)]
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_payload(cls, value: object) -> _GitContextView:
|
|
192
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _GraphAuditView(ContractModel):
|
|
196
|
+
"""Typed graph audit counts that determine link apply completion."""
|
|
197
|
+
|
|
198
|
+
model_config = ConfigDict(extra="ignore")
|
|
199
|
+
|
|
200
|
+
error_count: int = Field(default=0, ge=0, strict=True)
|
|
201
|
+
warning_count: int = Field(default=0, ge=0, strict=True)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_payload(cls, value: object) -> _GraphAuditView:
|
|
205
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _VocabularyMapIssueView(ContractModel):
|
|
209
|
+
"""Typed vocabulary-map issue used to construct link blockers."""
|
|
210
|
+
|
|
211
|
+
model_config = ConfigDict(extra="ignore")
|
|
212
|
+
|
|
213
|
+
code: str = ""
|
|
214
|
+
message: str = ""
|
|
215
|
+
next_action: str = ""
|
|
216
|
+
required_inputs: list[str] = Field(default_factory=list)
|
|
217
|
+
decision_summary: JsonObject | None = None
|
|
218
|
+
severity: str = ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _VocabularyMapDiagnosisView(ContractModel):
|
|
222
|
+
"""Typed view of vocabulary diagnosis fields that can block link diagnosis."""
|
|
223
|
+
|
|
224
|
+
model_config = ConfigDict(extra="ignore")
|
|
225
|
+
|
|
226
|
+
status: _VocabularyMapStatus = ""
|
|
227
|
+
pending_semantic_ingestion_count: int = Field(default=0, ge=0, strict=True)
|
|
228
|
+
note_count: int = Field(default=0, ge=0, strict=True)
|
|
229
|
+
meaning_count: int = Field(default=0, ge=0, strict=True)
|
|
230
|
+
issues: list[_VocabularyMapIssueView] = Field(default_factory=list)
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_payload(cls, value: object) -> _VocabularyMapDiagnosisView:
|
|
234
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _VocabularyBootstrapView(ContractModel):
|
|
238
|
+
"""Typed vocabulary bootstrap diagnosis before it controls link phases."""
|
|
239
|
+
|
|
240
|
+
model_config = ConfigDict(extra="ignore")
|
|
241
|
+
|
|
242
|
+
status: _VocabularyBootstrapStatus = ""
|
|
243
|
+
note_count: int = Field(default=0, ge=0, strict=True)
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_payload(cls, value: object) -> _VocabularyBootstrapView:
|
|
247
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _VocabularySemanticRepairView(ContractModel):
|
|
251
|
+
"""Typed vocabulary repair receipt before link apply can branch on it."""
|
|
252
|
+
|
|
253
|
+
model_config = ConfigDict(extra="ignore")
|
|
254
|
+
|
|
255
|
+
schema_id: Literal["medical-notes-workbench.vocabulary-semantic-repair.v1"] = Field(alias="schema")
|
|
256
|
+
status: _VocabularySemanticRepairStatus
|
|
257
|
+
blocked_reason: str = ""
|
|
258
|
+
next_action: str = ""
|
|
259
|
+
human_decision_required: bool = Field(default=False, strict=True)
|
|
260
|
+
applied_count: int = Field(default=0, ge=0, strict=True)
|
|
261
|
+
blocked_count: int = Field(default=0, ge=0, strict=True)
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def from_payload(cls, value: object) -> _VocabularySemanticRepairView:
|
|
265
|
+
return cls.model_validate(_json_object(value))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class _ReferenceRepairView(ContractModel):
|
|
269
|
+
"""Typed view of the reference-repair plan used by linker phases and receipts."""
|
|
270
|
+
|
|
271
|
+
model_config = ConfigDict(extra="ignore")
|
|
272
|
+
|
|
273
|
+
status: str = "skipped"
|
|
274
|
+
affected_note_count: int = Field(default=0, ge=0, strict=True)
|
|
275
|
+
action_count: int = Field(default=0, ge=0, strict=True)
|
|
276
|
+
blocking_action_count: int = Field(default=0, ge=0, strict=True)
|
|
277
|
+
human_decision_count: int = Field(default=0, ge=0, strict=True)
|
|
278
|
+
triage_count: int = Field(default=0, ge=0, strict=True)
|
|
279
|
+
human_decision_packets: list[JsonObject] = Field(default_factory=list)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def from_payload(cls, value: object) -> _ReferenceRepairView:
|
|
283
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _ReferenceApplyView(ContractModel):
|
|
287
|
+
"""Typed reference-repair apply receipt before link receipts consume it."""
|
|
288
|
+
|
|
289
|
+
model_config = ConfigDict(extra="ignore")
|
|
290
|
+
|
|
291
|
+
status: str = ""
|
|
292
|
+
changed_file_count: int = Field(default=0, ge=0, strict=True)
|
|
293
|
+
reports: list[JsonObject] = Field(default_factory=list)
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def from_payload(cls, value: object | None) -> _ReferenceApplyView:
|
|
297
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _LinkBodyPlanView(ContractModel):
|
|
301
|
+
"""Typed body-linker note plan used for changed-file and skip accounting."""
|
|
302
|
+
|
|
303
|
+
model_config = ConfigDict(extra="ignore")
|
|
304
|
+
|
|
305
|
+
file: str = ""
|
|
306
|
+
changed: bool = False
|
|
307
|
+
insertions: list[JsonObject] = Field(default_factory=list)
|
|
308
|
+
rewrites: list[JsonObject] = Field(default_factory=list)
|
|
309
|
+
skipped: list[JsonObject] = Field(default_factory=list)
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def from_payload(cls, value: object) -> _LinkBodyPlanView:
|
|
313
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class _LinkPlanSkipView(ContractModel):
|
|
317
|
+
"""Typed skipped occurrence emitted by contextual/body linker planning."""
|
|
318
|
+
|
|
319
|
+
model_config = ConfigDict(extra="ignore")
|
|
320
|
+
|
|
321
|
+
occurrence_id: str = ""
|
|
322
|
+
reason_code: str = ""
|
|
323
|
+
action: str = ""
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def from_payload(cls, value: object) -> _LinkPlanSkipView:
|
|
327
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class _SnapshotNoteView(ContractModel):
|
|
331
|
+
"""Typed note entry from a link snapshot."""
|
|
332
|
+
|
|
333
|
+
model_config = ConfigDict(extra="ignore")
|
|
334
|
+
|
|
335
|
+
path: str = ""
|
|
336
|
+
content_hash: str = ""
|
|
337
|
+
|
|
338
|
+
@classmethod
|
|
339
|
+
def from_payload(cls, value: object) -> _SnapshotNoteView:
|
|
340
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class _SemanticIngestionItemView(ContractModel):
|
|
344
|
+
"""Typed subset of semantic-ingestion items used for repair error receipts."""
|
|
345
|
+
|
|
346
|
+
model_config = ConfigDict(extra="ignore")
|
|
347
|
+
|
|
348
|
+
note_path: str = ""
|
|
349
|
+
content_hash: str = ""
|
|
350
|
+
|
|
351
|
+
@classmethod
|
|
352
|
+
def from_payload(cls, value: object) -> _SemanticIngestionItemView:
|
|
353
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class _BodyLinkerView(ContractModel):
|
|
357
|
+
"""Typed view over body-linker output before it is folded into the link FSM."""
|
|
358
|
+
|
|
359
|
+
model_config = ConfigDict(extra="ignore")
|
|
360
|
+
|
|
361
|
+
status: str = "skipped"
|
|
362
|
+
blocked_reason: str = ""
|
|
363
|
+
next_action: str = ""
|
|
364
|
+
returncode: int = Field(default=0, ge=0, strict=True)
|
|
365
|
+
files_changed: int = Field(default=0, ge=0, strict=True)
|
|
366
|
+
links_planned: int = Field(default=0, ge=0, strict=True)
|
|
367
|
+
links_rewritten: int = Field(default=0, ge=0, strict=True)
|
|
368
|
+
blocker_count: int = Field(default=0, ge=0, strict=True)
|
|
369
|
+
error: str = ""
|
|
370
|
+
parse_error: str = ""
|
|
371
|
+
body_linker_mode: str = ""
|
|
372
|
+
contextual_alias_disambiguation: _ContextualAliasDiagnosis = Field(default_factory=_ContextualAliasDiagnosis)
|
|
373
|
+
graph_audit_before: JsonObject = Field(default_factory=dict)
|
|
374
|
+
vocabulary_map_diagnosis: _VocabularyMapDiagnosisView = Field(default_factory=_VocabularyMapDiagnosisView)
|
|
375
|
+
plans: list[_LinkBodyPlanView] = Field(default_factory=list)
|
|
376
|
+
blockers: list[JsonObject] = Field(default_factory=list)
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def from_payload(cls, value: object) -> _BodyLinkerView:
|
|
380
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _LinkDiagnosisView(ContractModel):
|
|
384
|
+
"""Typed view of saved link diagnosis fields consumed by apply preflight."""
|
|
385
|
+
|
|
386
|
+
model_config = ConfigDict(extra="ignore")
|
|
387
|
+
|
|
388
|
+
status: str = ""
|
|
389
|
+
blocked_reason: str = ""
|
|
390
|
+
next_action: str = ""
|
|
391
|
+
human_decision_required: bool = False
|
|
392
|
+
diagnosis_path: str = ""
|
|
393
|
+
wiki_dir: str = ""
|
|
394
|
+
vocabulary_db_path: str = ""
|
|
395
|
+
blocker_count: int = Field(default=0, ge=0)
|
|
396
|
+
blockers: list[JsonObject] = Field(default_factory=list)
|
|
397
|
+
changed_files: list[str] = Field(default_factory=list)
|
|
398
|
+
files_changed: int = Field(default=0, ge=0)
|
|
399
|
+
snapshot_hash: str = ""
|
|
400
|
+
plan_hash: str = ""
|
|
401
|
+
trigger_context: JsonObject = Field(default_factory=dict)
|
|
402
|
+
triggers_detected: list[str] = Field(default_factory=list)
|
|
403
|
+
affected_notes: list[JsonObject] = Field(default_factory=list)
|
|
404
|
+
git: _GitContextView = Field(default_factory=_GitContextView)
|
|
405
|
+
reference_repair: _ReferenceRepairView = Field(default_factory=_ReferenceRepairView)
|
|
406
|
+
body_term_linker: _BodyLinkerView = Field(default_factory=_BodyLinkerView)
|
|
407
|
+
related_notes_sync: JsonObject = Field(default_factory=dict)
|
|
408
|
+
version_control_safety: JsonObject = Field(default_factory=dict)
|
|
409
|
+
receipt: JsonObject = Field(default_factory=dict)
|
|
410
|
+
guard_receipt: JsonObject = Field(default_factory=dict)
|
|
411
|
+
vocabulary_bootstrap: JsonObject = Field(default_factory=dict)
|
|
412
|
+
|
|
413
|
+
@field_validator(
|
|
414
|
+
"trigger_context",
|
|
415
|
+
"related_notes_sync",
|
|
416
|
+
"version_control_safety",
|
|
417
|
+
"receipt",
|
|
418
|
+
"guard_receipt",
|
|
419
|
+
"vocabulary_bootstrap",
|
|
420
|
+
mode="before",
|
|
421
|
+
)
|
|
422
|
+
@classmethod
|
|
423
|
+
def _object_or_empty(cls, value: object) -> JsonObject:
|
|
424
|
+
return _json_object_or_empty(value)
|
|
425
|
+
|
|
426
|
+
@field_validator("affected_notes", mode="before")
|
|
427
|
+
@classmethod
|
|
428
|
+
def _affected_notes_or_empty(cls, value: object) -> list[JsonObject]:
|
|
429
|
+
if not isinstance(value, list):
|
|
430
|
+
return []
|
|
431
|
+
return [_json_object(item) for item in value if isinstance(item, dict)]
|
|
432
|
+
|
|
433
|
+
@field_validator("changed_files", "triggers_detected", mode="before")
|
|
434
|
+
@classmethod
|
|
435
|
+
def _string_list_or_empty(cls, value: object) -> list[str]:
|
|
436
|
+
if not isinstance(value, list):
|
|
437
|
+
return []
|
|
438
|
+
return [item for item in value if isinstance(item, str)]
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def from_payload(cls, value: object) -> _LinkDiagnosisView:
|
|
442
|
+
return cls.model_validate(_json_object_or_empty(value))
|
|
443
|
+
|
|
444
|
+
def version_control_safety_payload(self) -> JsonObject:
|
|
445
|
+
if self.version_control_safety:
|
|
446
|
+
return self.version_control_safety
|
|
447
|
+
receipt = _json_object_or_empty(self.receipt)
|
|
448
|
+
nested = _json_object_or_empty(_json_field(receipt, "version_control_safety"))
|
|
449
|
+
if nested:
|
|
450
|
+
return nested
|
|
451
|
+
guard_receipt = _json_object_or_empty(self.guard_receipt)
|
|
452
|
+
return _json_object_or_empty(_json_field(guard_receipt, "version_control_safety"))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def related_notes_sync_blocked(result: object) -> bool:
|
|
456
|
+
typed = LinkRelatedSyncResult.from_payload(result)
|
|
457
|
+
return typed.status == "blocked" or bool(typed.blocked_reason)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _related_notes_apply_blocked(result: LinkRelatedSyncResult | None, *, required: bool) -> bool:
|
|
461
|
+
"""Treat skipped Related Notes as blocking only when the parent made it required."""
|
|
462
|
+
|
|
463
|
+
if result is None:
|
|
464
|
+
return False
|
|
465
|
+
return related_notes_sync_blocked(result) or (
|
|
466
|
+
required and result.status == "skipped" and bool(result.skipped_reason)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _related_notes_required_for_apply(diagnosis: JsonObject) -> bool:
|
|
471
|
+
"""Process-chats publishes new notes, so its link package must close Related Notes too."""
|
|
472
|
+
|
|
473
|
+
context = _json_object_or_empty(_json_field(diagnosis, "trigger_context"))
|
|
474
|
+
return _json_field(context, "source_workflow") == "/mednotes:process-chats"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _related_notes_waiting_external(result: LinkRelatedSyncResult | None) -> bool:
|
|
478
|
+
if result is None:
|
|
479
|
+
return False
|
|
480
|
+
recovery = result.related_notes_recovery_state
|
|
481
|
+
return (
|
|
482
|
+
recovery.status == "waiting_for_retry"
|
|
483
|
+
and recovery.blocked_reason
|
|
484
|
+
in {
|
|
485
|
+
"related_notes_headless_quota_exhausted",
|
|
486
|
+
"related_notes_headless_time_budget_exhausted",
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _related_notes_recovery_payload(result: LinkRelatedSyncResult | None) -> JsonObject:
|
|
492
|
+
if result is None:
|
|
493
|
+
return {}
|
|
494
|
+
recovery = result.related_notes_recovery_state
|
|
495
|
+
return recovery.to_payload() if recovery else {}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _link_diagnosis_contract_invalid_payload(
|
|
499
|
+
*,
|
|
500
|
+
diagnosis_path: Path,
|
|
501
|
+
detail: str,
|
|
502
|
+
source_payload: JsonObject,
|
|
503
|
+
extra: JsonObject | None = None,
|
|
504
|
+
) -> JsonObject:
|
|
505
|
+
"""Block apply when a saved/refreshed link artifact is not FSM-complete."""
|
|
506
|
+
|
|
507
|
+
return _json_object(
|
|
508
|
+
{
|
|
509
|
+
"schema": LINK_RUN_SCHEMA,
|
|
510
|
+
"phase": "link_apply_preflight",
|
|
511
|
+
"status": "blocked",
|
|
512
|
+
"blocked_reason": "link_diagnosis_contract_invalid",
|
|
513
|
+
"next_action": "Reexecutar /mednotes:link --diagnose para gerar diagnóstico FSM válido.",
|
|
514
|
+
"required_inputs": ["diagnosis"],
|
|
515
|
+
"human_decision_required": False,
|
|
516
|
+
"diagnosis_path": str(diagnosis_path),
|
|
517
|
+
"error_context": {
|
|
518
|
+
"root_cause": "effect_payload_contract_invalid",
|
|
519
|
+
"detail": detail,
|
|
520
|
+
},
|
|
521
|
+
"invalid_diagnosis": source_payload,
|
|
522
|
+
"returncode": 3,
|
|
523
|
+
**(extra or {}),
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _link_apply_blocked_reason(
|
|
529
|
+
*,
|
|
530
|
+
body_or_graph_blocked: bool,
|
|
531
|
+
related_notes_blocked: bool,
|
|
532
|
+
) -> str:
|
|
533
|
+
if related_notes_blocked:
|
|
534
|
+
return "related_notes_blocked"
|
|
535
|
+
if body_or_graph_blocked:
|
|
536
|
+
return "graph_blockers"
|
|
537
|
+
return ""
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _link_apply_next_action(
|
|
541
|
+
*,
|
|
542
|
+
blocked_reason: str,
|
|
543
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
544
|
+
) -> str:
|
|
545
|
+
if blocked_reason == "related_notes_blocked":
|
|
546
|
+
return related_notes.next_action if related_notes is not None and related_notes.next_action else (
|
|
547
|
+
"Atualizar o export do Related Notes ou aguardar a cota externa e repetir /mednotes:link."
|
|
548
|
+
)
|
|
549
|
+
if blocked_reason == "graph_blockers":
|
|
550
|
+
return "Rodar /mednotes:fix-wiki --dry-run para resolver blockers semânticos."
|
|
551
|
+
return ""
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def run_related_notes_sync(
|
|
555
|
+
config: MedConfig,
|
|
556
|
+
*,
|
|
557
|
+
apply: bool = False,
|
|
558
|
+
backup: bool = False,
|
|
559
|
+
receipt_path: Path | None = None,
|
|
560
|
+
max_age_hours: float = 168.0,
|
|
561
|
+
allow_stale_note_hashes: bool = False,
|
|
562
|
+
) -> LinkRelatedSyncResult:
|
|
563
|
+
export_path = default_export_path(config.wiki_dir)
|
|
564
|
+
if not export_path.is_file():
|
|
565
|
+
return LinkRelatedSyncResult.from_payload(
|
|
566
|
+
{
|
|
567
|
+
"schema": RELATED_NOTES_SYNC_SCHEMA,
|
|
568
|
+
"phase": "related_notes_skipped",
|
|
569
|
+
"status": "skipped",
|
|
570
|
+
"blocked_reason": "",
|
|
571
|
+
"skipped_reason": "related_notes_export_missing",
|
|
572
|
+
"next_action": (
|
|
573
|
+
"Exportar medical-notes-export.json pelo plugin Related Notes para sincronizar "
|
|
574
|
+
"a seção gerenciada Notas Relacionadas."
|
|
575
|
+
),
|
|
576
|
+
"required_inputs": ["wiki_dir", "related_notes_export"],
|
|
577
|
+
"human_decision_required": False,
|
|
578
|
+
"wiki_dir": str(config.wiki_dir),
|
|
579
|
+
"export_path": "",
|
|
580
|
+
"default_export_name": "medical-notes-export.json",
|
|
581
|
+
"applied_note_count": 0,
|
|
582
|
+
"planned_note_count": 0,
|
|
583
|
+
"proposed_link_count": 0,
|
|
584
|
+
"cleared_link_count": 0,
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
return LinkRelatedSyncResult.from_payload(
|
|
588
|
+
sync_related_notes_operation_result(
|
|
589
|
+
config,
|
|
590
|
+
export_path=export_path,
|
|
591
|
+
apply=apply,
|
|
592
|
+
backup=backup,
|
|
593
|
+
receipt_path=receipt_path,
|
|
594
|
+
max_age_hours=max_age_hours,
|
|
595
|
+
allow_stale_note_hashes=allow_stale_note_hashes,
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _related_notes_payload(result: LinkRelatedSyncResult | None) -> JsonObject | None:
|
|
601
|
+
return _json_object(result.operation_payload) if result is not None else None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _related_notes_required_inputs(result: LinkRelatedSyncResult) -> list[str]:
|
|
605
|
+
return result.required_inputs or ["wiki_dir", "related_notes_export"]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _related_notes_pass_summary(kind: str, result: LinkRelatedSyncResult) -> RelatedNotesPassSummary:
|
|
609
|
+
return RelatedNotesPassSummary.from_sync_result(kind, result)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _combined_related_notes_updates(results: list[LinkRelatedSyncResult]) -> list[JsonObject]:
|
|
613
|
+
combined: list[JsonObject] = []
|
|
614
|
+
seen: set[tuple[str, str]] = set()
|
|
615
|
+
for result in results:
|
|
616
|
+
for update in result.updates:
|
|
617
|
+
payload = _json_object(update.operation_payload)
|
|
618
|
+
path = update.path or _json_text(payload, "file")
|
|
619
|
+
key = (path, json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
620
|
+
if key in seen:
|
|
621
|
+
continue
|
|
622
|
+
seen.add(key)
|
|
623
|
+
combined.append(payload)
|
|
624
|
+
return combined
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _convergence_state_from_blocked(result: LinkRelatedSyncResult) -> str:
|
|
628
|
+
if result.related_notes_recovery_state.status == "waiting_for_retry":
|
|
629
|
+
return "waiting_external"
|
|
630
|
+
if result.blocked_reason in {
|
|
631
|
+
"related_notes_headless_quota_exhausted",
|
|
632
|
+
"related_notes_headless_time_budget_exhausted",
|
|
633
|
+
}:
|
|
634
|
+
return "waiting_external"
|
|
635
|
+
return "blocked"
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _related_notes_convergence_result(
|
|
639
|
+
*,
|
|
640
|
+
base: LinkRelatedSyncResult,
|
|
641
|
+
status: str,
|
|
642
|
+
blocked_reason: str = "",
|
|
643
|
+
next_action: str = "",
|
|
644
|
+
applied_results: list[LinkRelatedSyncResult],
|
|
645
|
+
passes: list[RelatedNotesPassSummary],
|
|
646
|
+
final_planned_note_count: int,
|
|
647
|
+
max_passes: int,
|
|
648
|
+
extra: JsonObject | None = None,
|
|
649
|
+
) -> LinkRelatedSyncResult:
|
|
650
|
+
result = _json_object(base.operation_payload)
|
|
651
|
+
if extra:
|
|
652
|
+
result.update(extra)
|
|
653
|
+
total_applied = sum(payload.applied_note_count for payload in applied_results)
|
|
654
|
+
operation_count = len(passes)
|
|
655
|
+
cycle_count = sum(1 for item in passes if item.kind == "apply")
|
|
656
|
+
blocked_probe = LinkRelatedSyncResult.from_payload({**result, "status": status, "blocked_reason": blocked_reason})
|
|
657
|
+
result.update(
|
|
658
|
+
{
|
|
659
|
+
"schema": RELATED_NOTES_SYNC_SCHEMA,
|
|
660
|
+
"phase": "related_notes_apply_convergence",
|
|
661
|
+
"status": status,
|
|
662
|
+
"blocked_reason": blocked_reason,
|
|
663
|
+
"next_action": next_action,
|
|
664
|
+
"planned_note_count": final_planned_note_count,
|
|
665
|
+
"applied_note_count": total_applied,
|
|
666
|
+
"updates": _combined_related_notes_updates(applied_results),
|
|
667
|
+
"convergence": {
|
|
668
|
+
"schema": "medical-notes-workbench.related-notes-convergence.v1",
|
|
669
|
+
"status": "stable" if status == "completed" else _convergence_state_from_blocked(blocked_probe),
|
|
670
|
+
"pass_count": cycle_count,
|
|
671
|
+
"max_passes": max_passes,
|
|
672
|
+
"cycle_count": cycle_count,
|
|
673
|
+
"max_cycles": max_passes,
|
|
674
|
+
"operation_count": operation_count,
|
|
675
|
+
"final_planned_note_count": final_planned_note_count,
|
|
676
|
+
"applied_note_count": total_applied,
|
|
677
|
+
"passes": [item.to_payload() for item in passes],
|
|
678
|
+
},
|
|
679
|
+
}
|
|
680
|
+
)
|
|
681
|
+
return LinkRelatedSyncResult.from_payload(result)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _related_notes_convergence_base(config: MedConfig) -> LinkRelatedSyncResult:
|
|
685
|
+
export_path = default_export_path(config.wiki_dir)
|
|
686
|
+
return LinkRelatedSyncResult.from_payload(
|
|
687
|
+
{
|
|
688
|
+
"schema": RELATED_NOTES_SYNC_SCHEMA,
|
|
689
|
+
"phase": "related_notes_apply_convergence",
|
|
690
|
+
"status": "blocked",
|
|
691
|
+
"blocked_reason": "",
|
|
692
|
+
"next_action": "",
|
|
693
|
+
"required_inputs": ["wiki_dir", "related_notes_export"],
|
|
694
|
+
"human_decision_required": False,
|
|
695
|
+
"manual_instruction_allowed": False,
|
|
696
|
+
"wiki_dir": str(config.wiki_dir),
|
|
697
|
+
"export_path": str(export_path),
|
|
698
|
+
"planned_note_count": 0,
|
|
699
|
+
"proposed_link_count": 0,
|
|
700
|
+
"cleared_link_count": 0,
|
|
701
|
+
"skipped_edge_count": 0,
|
|
702
|
+
"applied_note_count": 0,
|
|
703
|
+
"updates": [],
|
|
704
|
+
}
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _related_notes_recovery_sync_result(value: object) -> LinkRelatedSyncResult:
|
|
709
|
+
"""Project export recovery into the sync-result stream without erasing its type."""
|
|
710
|
+
|
|
711
|
+
raw = _json_object_or_empty(value)
|
|
712
|
+
nested_recovery = raw["related_notes_recovery_state"] if "related_notes_recovery_state" in raw else value
|
|
713
|
+
recovery = RelatedNotesRecoveryState.from_payload(nested_recovery)
|
|
714
|
+
# Keep the adapter's recovery evidence at the operation boundary. The typed
|
|
715
|
+
# recovery state drives resumability; stale-note/API evidence remains
|
|
716
|
+
# audit-only, but fix-wiki must still be able to prove what was recovered.
|
|
717
|
+
payload: JsonObject = {
|
|
718
|
+
**raw,
|
|
719
|
+
"schema": RELATED_NOTES_SYNC_SCHEMA,
|
|
720
|
+
"phase": "related_notes_export_recovery",
|
|
721
|
+
"status": recovery.status or _json_text(raw, "status"),
|
|
722
|
+
"blocked_reason": recovery.blocked_reason or _json_text(raw, "blocked_reason"),
|
|
723
|
+
"next_action": recovery.next_action or _json_text(raw, "next_action"),
|
|
724
|
+
"related_notes_recovery_state": recovery.to_payload(),
|
|
725
|
+
}
|
|
726
|
+
return LinkRelatedSyncResult.from_payload(
|
|
727
|
+
payload
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _converge_related_notes_sync(
|
|
732
|
+
config: MedConfig,
|
|
733
|
+
*,
|
|
734
|
+
backup: bool,
|
|
735
|
+
max_passes: int = RELATED_NOTES_CONVERGENCE_MAX_PASSES,
|
|
736
|
+
) -> LinkRelatedSyncResult:
|
|
737
|
+
if not default_export_path(config.wiki_dir).is_file():
|
|
738
|
+
return run_related_notes_sync(config, apply=True, backup=backup)
|
|
739
|
+
applied_results: list[LinkRelatedSyncResult] = []
|
|
740
|
+
passes: list[RelatedNotesPassSummary] = []
|
|
741
|
+
last_payload = _related_notes_convergence_base(config)
|
|
742
|
+
|
|
743
|
+
for pass_index in range(1, max_passes + 1):
|
|
744
|
+
recovery = _related_notes_recovery_sync_result(
|
|
745
|
+
recover_related_notes_export_operation_result(
|
|
746
|
+
config,
|
|
747
|
+
mode="auto",
|
|
748
|
+
workflow="/mednotes:link",
|
|
749
|
+
run_id=f"related-notes-convergence-{pass_index}",
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
passes.append(_related_notes_pass_summary("recover_export", recovery))
|
|
753
|
+
if related_notes_sync_blocked(recovery):
|
|
754
|
+
return _related_notes_convergence_result(
|
|
755
|
+
base=last_payload,
|
|
756
|
+
status="blocked",
|
|
757
|
+
blocked_reason=recovery.blocked_reason or "related_notes_export_recovery_blocked",
|
|
758
|
+
next_action=recovery.next_action,
|
|
759
|
+
applied_results=applied_results,
|
|
760
|
+
passes=passes,
|
|
761
|
+
final_planned_note_count=last_payload.planned_note_count,
|
|
762
|
+
max_passes=max_passes,
|
|
763
|
+
extra={
|
|
764
|
+
"related_notes_export_recovery": recovery.operation_payload,
|
|
765
|
+
"related_notes_recovery_state": recovery.related_notes_recovery_state.operation_payload,
|
|
766
|
+
},
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
preview = run_related_notes_sync(config, apply=False, backup=False)
|
|
770
|
+
passes.append(_related_notes_pass_summary("dry_run", preview))
|
|
771
|
+
if preview.status == "skipped":
|
|
772
|
+
return preview
|
|
773
|
+
planned_note_count = preview.planned_note_count
|
|
774
|
+
if related_notes_sync_blocked(preview):
|
|
775
|
+
return _related_notes_convergence_result(
|
|
776
|
+
base=preview,
|
|
777
|
+
status="blocked",
|
|
778
|
+
blocked_reason=preview.blocked_reason or "related_notes_preview_blocked",
|
|
779
|
+
next_action=preview.next_action,
|
|
780
|
+
applied_results=applied_results,
|
|
781
|
+
passes=passes,
|
|
782
|
+
final_planned_note_count=planned_note_count,
|
|
783
|
+
max_passes=max_passes,
|
|
784
|
+
extra={"related_notes_export_recovery": recovery.operation_payload},
|
|
785
|
+
)
|
|
786
|
+
if planned_note_count == 0:
|
|
787
|
+
return _related_notes_convergence_result(
|
|
788
|
+
base=preview,
|
|
789
|
+
status="completed",
|
|
790
|
+
applied_results=applied_results,
|
|
791
|
+
passes=passes,
|
|
792
|
+
final_planned_note_count=0,
|
|
793
|
+
max_passes=max_passes,
|
|
794
|
+
extra={"related_notes_export_recovery": recovery.operation_payload},
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
last_payload = run_related_notes_sync(config, apply=True, backup=backup)
|
|
798
|
+
passes.append(_related_notes_pass_summary("apply", last_payload))
|
|
799
|
+
if related_notes_sync_blocked(last_payload):
|
|
800
|
+
return _related_notes_convergence_result(
|
|
801
|
+
base=last_payload,
|
|
802
|
+
status="blocked",
|
|
803
|
+
blocked_reason=last_payload.blocked_reason or "related_notes_apply_blocked",
|
|
804
|
+
next_action=last_payload.next_action,
|
|
805
|
+
applied_results=applied_results,
|
|
806
|
+
passes=passes,
|
|
807
|
+
final_planned_note_count=planned_note_count,
|
|
808
|
+
max_passes=max_passes,
|
|
809
|
+
extra={"related_notes_export_recovery": recovery.operation_payload},
|
|
810
|
+
)
|
|
811
|
+
applied_results.append(last_payload)
|
|
812
|
+
if last_payload.applied_note_count >= planned_note_count:
|
|
813
|
+
return _related_notes_convergence_result(
|
|
814
|
+
base=last_payload,
|
|
815
|
+
status="completed",
|
|
816
|
+
applied_results=applied_results,
|
|
817
|
+
passes=passes,
|
|
818
|
+
final_planned_note_count=0,
|
|
819
|
+
max_passes=max_passes,
|
|
820
|
+
extra={"related_notes_export_recovery": recovery.operation_payload},
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
return _related_notes_convergence_result(
|
|
824
|
+
base=last_payload,
|
|
825
|
+
status="blocked",
|
|
826
|
+
blocked_reason="related_notes_convergence_not_reached",
|
|
827
|
+
next_action="A sincronização de Notas Relacionadas ainda mudou após várias passadas; repetir pela rota oficial depois de revisar o relatório.",
|
|
828
|
+
applied_results=applied_results,
|
|
829
|
+
passes=passes,
|
|
830
|
+
final_planned_note_count=last_payload.planned_note_count,
|
|
831
|
+
max_passes=max_passes,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _run_id() -> str:
|
|
836
|
+
return datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def default_link_diagnosis_path() -> Path:
|
|
840
|
+
return _path(f"~/.mednotes/runs/{_run_id()}/link-diagnosis.json")
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def default_link_receipt_path() -> Path:
|
|
844
|
+
return _path(f"~/.mednotes/runs/{_run_id()}/link-run-receipt.json")
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _first_heading_or_stem(text: str, path: Path) -> str:
|
|
848
|
+
for line in text.splitlines():
|
|
849
|
+
stripped = line.strip()
|
|
850
|
+
if stripped.startswith("# "):
|
|
851
|
+
return stripped[2:].strip() or path.stem
|
|
852
|
+
return path.stem
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _file_hash(path: Path | None) -> str:
|
|
856
|
+
if not path or not path.is_file():
|
|
857
|
+
return ""
|
|
858
|
+
return "sha256:" + file_sha256(path)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _collect_snapshot(config: MedConfig) -> JsonObject:
|
|
862
|
+
notes: list[JsonObject] = []
|
|
863
|
+
if config.wiki_dir.is_dir():
|
|
864
|
+
for path in iter_notes(config.wiki_dir):
|
|
865
|
+
try:
|
|
866
|
+
text = path.read_text(encoding="utf-8")
|
|
867
|
+
except OSError as exc:
|
|
868
|
+
notes.append(
|
|
869
|
+
_json_object({
|
|
870
|
+
"path": path.relative_to(config.wiki_dir).as_posix(),
|
|
871
|
+
"read_error": str(exc),
|
|
872
|
+
})
|
|
873
|
+
)
|
|
874
|
+
continue
|
|
875
|
+
if _is_index_note(path, text):
|
|
876
|
+
continue
|
|
877
|
+
notes.append(
|
|
878
|
+
_json_object({
|
|
879
|
+
"path": path.relative_to(config.wiki_dir).as_posix(),
|
|
880
|
+
"stem": path.stem,
|
|
881
|
+
"title": _first_heading_or_stem(text, path),
|
|
882
|
+
"aliases": extract_aliases(text),
|
|
883
|
+
"content_hash": "sha256:" + file_sha256(path),
|
|
884
|
+
})
|
|
885
|
+
)
|
|
886
|
+
export_path = default_export_path(config.wiki_dir)
|
|
887
|
+
snapshot = JsonObjectAdapter.validate_python({
|
|
888
|
+
"wiki_dir": str(config.wiki_dir),
|
|
889
|
+
"wiki_dir_exists": config.wiki_dir.is_dir(),
|
|
890
|
+
"catalog_path": str(config.catalog_path) if config.catalog_path else "",
|
|
891
|
+
"catalog_hash": _file_hash(config.catalog_path),
|
|
892
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
893
|
+
"vocabulary_db_hash": _file_hash(config.vocabulary_db_path),
|
|
894
|
+
"related_notes_export_path": str(export_path) if export_path.is_file() else "",
|
|
895
|
+
"related_notes_export_hash": _file_hash(export_path),
|
|
896
|
+
"note_count": len(notes),
|
|
897
|
+
"notes": notes,
|
|
898
|
+
})
|
|
899
|
+
snapshot["snapshot_hash"] = "sha256:" + canonical_json_hash(snapshot)
|
|
900
|
+
return snapshot
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _git_context_for(config: MedConfig, link_state: LinkState | None = None) -> LinkGitContext:
|
|
904
|
+
"""Collect typed Git context before any linker branch reads its fields."""
|
|
905
|
+
|
|
906
|
+
return collect_git_context(
|
|
907
|
+
config.wiki_dir,
|
|
908
|
+
previous_state=link_state if link_state is not None else load_link_state(),
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _git_trigger_context_for(
|
|
913
|
+
git_context: LinkGitContext,
|
|
914
|
+
*,
|
|
915
|
+
snapshot: JsonObject,
|
|
916
|
+
link_state: LinkState | None,
|
|
917
|
+
) -> JsonObject | None:
|
|
918
|
+
if link_state is not None and link_state.snapshot_hash == _json_field(snapshot, "snapshot_hash"):
|
|
919
|
+
return None
|
|
920
|
+
trigger_context = trigger_context_from_git(git_context)
|
|
921
|
+
return trigger_context.to_payload() if trigger_context is not None else None
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _phase_status_from_linker(payload: JsonObject, *, phase: str) -> str:
|
|
925
|
+
if _json_field(payload, "error") or _json_field(payload, "parse_error"):
|
|
926
|
+
return "failed"
|
|
927
|
+
if _json_int(payload, "blocker_count"):
|
|
928
|
+
return "blocked"
|
|
929
|
+
if phase == "body_term_linker":
|
|
930
|
+
return (
|
|
931
|
+
"planned"
|
|
932
|
+
if _json_int(payload, "links_planned")
|
|
933
|
+
or _json_int(payload, "links_rewritten")
|
|
934
|
+
else "skipped"
|
|
935
|
+
)
|
|
936
|
+
return "planned"
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _graph_issues_from(body_linker: JsonObject) -> list[JsonObject]:
|
|
940
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
941
|
+
graph = body_view.graph_audit_before
|
|
942
|
+
issues: list[JsonObject] = []
|
|
943
|
+
for key in ("errors", "warnings"):
|
|
944
|
+
values = graph.get(key)
|
|
945
|
+
if isinstance(values, list):
|
|
946
|
+
issues.extend(_json_object(item) for item in values if isinstance(item, dict))
|
|
947
|
+
return issues
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _diagnosis_phases(
|
|
951
|
+
body_linker: JsonObject,
|
|
952
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
953
|
+
reference_repair: JsonObject,
|
|
954
|
+
) -> JsonObject:
|
|
955
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
956
|
+
repair_view = _ReferenceRepairView.from_payload(reference_repair)
|
|
957
|
+
related_status = "skipped"
|
|
958
|
+
if related_notes is not None:
|
|
959
|
+
if related_notes_sync_blocked(related_notes):
|
|
960
|
+
related_status = "blocked"
|
|
961
|
+
elif related_notes.planned_note_count:
|
|
962
|
+
related_status = "planned"
|
|
963
|
+
else:
|
|
964
|
+
related_status = related_notes.status or "skipped"
|
|
965
|
+
contextual = body_view.contextual_alias_disambiguation
|
|
966
|
+
return _json_object({
|
|
967
|
+
"reference_repair": {
|
|
968
|
+
"status": repair_view.status,
|
|
969
|
+
"affected_note_count": repair_view.affected_note_count,
|
|
970
|
+
"action_count": repair_view.action_count,
|
|
971
|
+
"blocking_action_count": repair_view.blocking_action_count,
|
|
972
|
+
"human_decision_count": repair_view.human_decision_count,
|
|
973
|
+
"triage_count": repair_view.triage_count,
|
|
974
|
+
},
|
|
975
|
+
"contextual_alias_disambiguation": {
|
|
976
|
+
"status": contextual.status,
|
|
977
|
+
"mode": contextual.mode,
|
|
978
|
+
"candidate_count": contextual.candidate_count,
|
|
979
|
+
"linked_count": contextual.linked_count,
|
|
980
|
+
"deferred_count": contextual.deferred_count,
|
|
981
|
+
"no_link_count": contextual.no_link_count,
|
|
982
|
+
"rejected_count": contextual.rejected_count,
|
|
983
|
+
"skipped_reason": contextual.skipped_reason,
|
|
984
|
+
"blocked_reason": contextual.blocked_reason,
|
|
985
|
+
},
|
|
986
|
+
"body_term_linker": {
|
|
987
|
+
"status": _phase_status_from_linker(body_linker, phase="body_term_linker"),
|
|
988
|
+
"blocked_reason": body_view.blocked_reason,
|
|
989
|
+
"links_planned": body_view.links_planned,
|
|
990
|
+
"links_rewritten": body_view.links_rewritten,
|
|
991
|
+
},
|
|
992
|
+
"related_notes_sync": {
|
|
993
|
+
"status": related_status,
|
|
994
|
+
"planned_note_count": related_notes.planned_note_count if related_notes is not None else 0,
|
|
995
|
+
"skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
|
|
996
|
+
"blocked_reason": related_notes.blocked_reason if related_notes is not None else "",
|
|
997
|
+
},
|
|
998
|
+
"graph_validation": {
|
|
999
|
+
"status": "blocked" if body_view.blocker_count else "planned",
|
|
1000
|
+
"blocker_count": body_view.blocker_count,
|
|
1001
|
+
},
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _collect_blockers(
|
|
1006
|
+
body_linker: JsonObject,
|
|
1007
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
1008
|
+
reference_repair: JsonObject | None = None,
|
|
1009
|
+
) -> list[JsonObject]:
|
|
1010
|
+
repair_view = _ReferenceRepairView.from_payload(reference_repair) if reference_repair is not None else None
|
|
1011
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
1012
|
+
blocker_list = [_json_object(item) for item in body_view.blockers if isinstance(item, dict)]
|
|
1013
|
+
if repair_view is not None and repair_view.blocking_action_count:
|
|
1014
|
+
blocker_list.append(
|
|
1015
|
+
_json_object({
|
|
1016
|
+
"code": "reference_repair_blocked",
|
|
1017
|
+
"message": "Há WikiLinks ausentes/ambíguos ou alvos estruturais que exigem reparo antes do apply.",
|
|
1018
|
+
"blocking_action_count": repair_view.blocking_action_count,
|
|
1019
|
+
"human_decision_count": repair_view.human_decision_count,
|
|
1020
|
+
})
|
|
1021
|
+
)
|
|
1022
|
+
if related_notes is not None and related_notes_sync_blocked(related_notes):
|
|
1023
|
+
blocker_list.append(
|
|
1024
|
+
_json_object({
|
|
1025
|
+
"code": related_notes.blocked_reason or "related_notes_blocked",
|
|
1026
|
+
"message": related_notes.next_action,
|
|
1027
|
+
})
|
|
1028
|
+
)
|
|
1029
|
+
return blocker_list
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _diagnosis_next_action(
|
|
1033
|
+
*,
|
|
1034
|
+
blockers: list[JsonObject],
|
|
1035
|
+
body_linker: JsonObject,
|
|
1036
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
1037
|
+
) -> str:
|
|
1038
|
+
if related_notes is not None and related_notes_sync_blocked(related_notes):
|
|
1039
|
+
return related_notes.next_action or "Corrigir o export Related Notes antes de aplicar."
|
|
1040
|
+
if blockers:
|
|
1041
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
1042
|
+
return body_view.next_action or "Rodar /mednotes:fix-wiki --dry-run para resolver blockers semânticos antes de aplicar."
|
|
1043
|
+
return "Aplicar com run-linker --apply --diagnosis <link-diagnosis.json>."
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _diagnosis_required_inputs(*, related_notes: LinkRelatedSyncResult | None) -> list[str]:
|
|
1047
|
+
if related_notes is not None and related_notes_sync_blocked(related_notes):
|
|
1048
|
+
return _related_notes_required_inputs(related_notes)
|
|
1049
|
+
return LINK_REQUIRED_INPUTS
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _curator_batch_next_action(plan_path: Path) -> str:
|
|
1053
|
+
return (
|
|
1054
|
+
"/mednotes:link deve continuar a curadoria do grafo: lançar um "
|
|
1055
|
+
f"med-link-graph-curator por work_items[] em {plan_path}, escrever um "
|
|
1056
|
+
"vocabulary-curator-batch-output-manifest.v1, rodar "
|
|
1057
|
+
f"eval-curator-batch --plan {plan_path} --outputs <manifest.json> "
|
|
1058
|
+
"--report <curator-prompt-eval.json> --json, validar com "
|
|
1059
|
+
f"apply-curator-batch --plan {plan_path} --outputs <manifest.json> "
|
|
1060
|
+
"--validate-only --json e aplicar com --prompt-eval antes de repetir "
|
|
1061
|
+
"run-linker --diagnose."
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _link_vocabulary_curator_batch(
|
|
1066
|
+
config: MedConfig,
|
|
1067
|
+
*,
|
|
1068
|
+
diagnosis_path: Path,
|
|
1069
|
+
body_linker: JsonObject,
|
|
1070
|
+
) -> tuple[JsonObject, str, str]:
|
|
1071
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
1072
|
+
if body_view.blocked_reason != "vocabulary_semantic_ingestion_pending":
|
|
1073
|
+
return {}, "", ""
|
|
1074
|
+
if config.vocabulary_db_path is None:
|
|
1075
|
+
return {}, "", ""
|
|
1076
|
+
run_dir = diagnosis_path.parent
|
|
1077
|
+
plan_path = run_dir / "vocabulary-curator-batch-plan.json"
|
|
1078
|
+
plan = build_vocabulary_curator_batch_plan(
|
|
1079
|
+
db_path=config.vocabulary_db_path,
|
|
1080
|
+
batch_id=f"link:{diagnosis_path.stem}",
|
|
1081
|
+
output_dir=run_dir / "vocabulary-curator-outputs",
|
|
1082
|
+
)
|
|
1083
|
+
atomic_write_text(plan_path, json.dumps(plan, ensure_ascii=False, indent=2) + "\n")
|
|
1084
|
+
return _json_object(plan), str(plan_path), _curator_batch_next_action(plan_path)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _dedupe_texts(values: list[str]) -> list[str]:
|
|
1088
|
+
seen: set[str] = set()
|
|
1089
|
+
result: list[str] = []
|
|
1090
|
+
for value in values:
|
|
1091
|
+
text = str(value).strip()
|
|
1092
|
+
normalized = normalize_key(text)
|
|
1093
|
+
if not text or not normalized or normalized in seen:
|
|
1094
|
+
continue
|
|
1095
|
+
seen.add(normalized)
|
|
1096
|
+
result.append(text)
|
|
1097
|
+
return result
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _sync_vocabulary_notes_from_wiki(config: MedConfig) -> None:
|
|
1101
|
+
if config.vocabulary_db_path is None:
|
|
1102
|
+
return
|
|
1103
|
+
initialize_vocabulary_db(config.vocabulary_db_path)
|
|
1104
|
+
with sqlite3.connect(config.vocabulary_db_path) as conn:
|
|
1105
|
+
for path in iter_notes(config.wiki_dir) if config.wiki_dir.exists() else []:
|
|
1106
|
+
text = path.read_text(encoding="utf-8")
|
|
1107
|
+
if _is_index_note(path, text):
|
|
1108
|
+
continue
|
|
1109
|
+
upsert_note(conn, path=path, title=infer_title(text, path), content_hash=note_content_hash(path))
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _yaml_aliases_by_note_id(conn: sqlite3.Connection) -> dict[int, list[str]]:
|
|
1113
|
+
aliases: dict[int, list[str]] = {}
|
|
1114
|
+
rows = conn.execute(
|
|
1115
|
+
"""
|
|
1116
|
+
SELECT note_id, alias_text
|
|
1117
|
+
FROM yaml_alias_claims
|
|
1118
|
+
WHERE visible_in_yaml = 1
|
|
1119
|
+
AND claim_status != 'conflicting_alias'
|
|
1120
|
+
ORDER BY note_id, normalized_surface
|
|
1121
|
+
"""
|
|
1122
|
+
).fetchall()
|
|
1123
|
+
for note_id, alias_text in rows:
|
|
1124
|
+
aliases.setdefault(int(note_id), []).append(str(alias_text))
|
|
1125
|
+
return aliases
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _baseline_semantic_ingestion_items(config: MedConfig) -> list[JsonObject]:
|
|
1129
|
+
if config.vocabulary_db_path is None or not config.vocabulary_db_path.exists():
|
|
1130
|
+
return []
|
|
1131
|
+
_sync_vocabulary_notes_from_wiki(config)
|
|
1132
|
+
with sqlite3.connect(config.vocabulary_db_path) as conn:
|
|
1133
|
+
conn.row_factory = sqlite3.Row
|
|
1134
|
+
yaml_aliases = _yaml_aliases_by_note_id(conn)
|
|
1135
|
+
rows = conn.execute(
|
|
1136
|
+
"""
|
|
1137
|
+
SELECT id, path, title, content_hash
|
|
1138
|
+
FROM notes
|
|
1139
|
+
WHERE status = 'active'
|
|
1140
|
+
ORDER BY path
|
|
1141
|
+
"""
|
|
1142
|
+
).fetchall()
|
|
1143
|
+
items: list[JsonObject] = []
|
|
1144
|
+
for row in rows:
|
|
1145
|
+
note_path = Path(str(row["path"]))
|
|
1146
|
+
if not note_path.is_file():
|
|
1147
|
+
continue
|
|
1148
|
+
text = note_path.read_text(encoding="utf-8")
|
|
1149
|
+
title = infer_title(text, note_path)
|
|
1150
|
+
title_norm = normalize_key(title)
|
|
1151
|
+
aliases = _dedupe_texts([title, note_path.stem, *extract_aliases(text), *yaml_aliases.get(int(row["id"]), [])])
|
|
1152
|
+
item_aliases: list[JsonObject] = []
|
|
1153
|
+
for alias in aliases:
|
|
1154
|
+
alias_norm = normalize_key(alias)
|
|
1155
|
+
item_aliases.append(
|
|
1156
|
+
_json_object({
|
|
1157
|
+
"text": alias,
|
|
1158
|
+
"kind": "canonical_title" if alias_norm == title_norm else "alias",
|
|
1159
|
+
"link_policy": "direct" if alias_norm == title_norm else "requires_context",
|
|
1160
|
+
"visible_in_yaml": True,
|
|
1161
|
+
"intrinsically_ambiguous": alias_norm != title_norm,
|
|
1162
|
+
"source": "system",
|
|
1163
|
+
})
|
|
1164
|
+
)
|
|
1165
|
+
items.append(
|
|
1166
|
+
_json_object({
|
|
1167
|
+
"schema": "medical-notes-workbench.note-semantic-ingestion.v1",
|
|
1168
|
+
"workflow": "/mednotes:link",
|
|
1169
|
+
"phase": "vocabulary_curation",
|
|
1170
|
+
"agent": "med-link-graph-curator",
|
|
1171
|
+
"source_workflow": "/mednotes:link",
|
|
1172
|
+
"note_path": str(note_path),
|
|
1173
|
+
"content_hash": note_content_hash(note_path),
|
|
1174
|
+
"primary_meaning": {
|
|
1175
|
+
"label": title,
|
|
1176
|
+
"semantic_type": "medical_concept",
|
|
1177
|
+
"atomic_status": "unknown",
|
|
1178
|
+
},
|
|
1179
|
+
"aliases": item_aliases,
|
|
1180
|
+
"deferred_work_items": [],
|
|
1181
|
+
"confidence": 0.72,
|
|
1182
|
+
"source": "system",
|
|
1183
|
+
})
|
|
1184
|
+
)
|
|
1185
|
+
return items
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _drop_unresolved_surfaces(db_path: Path) -> int:
|
|
1189
|
+
initialize_vocabulary_db(db_path)
|
|
1190
|
+
with sqlite3.connect(db_path) as conn:
|
|
1191
|
+
rows = conn.execute(
|
|
1192
|
+
"""
|
|
1193
|
+
SELECT s.id
|
|
1194
|
+
FROM surfaces s
|
|
1195
|
+
LEFT JOIN surface_meaning_policy p ON p.surface_id = s.id
|
|
1196
|
+
WHERE p.id IS NULL
|
|
1197
|
+
"""
|
|
1198
|
+
).fetchall()
|
|
1199
|
+
conn.executemany("DELETE FROM surfaces WHERE id = ?", [(int(row[0]),) for row in rows])
|
|
1200
|
+
return len(rows)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _direct_ambiguous_surface_repair_needed(diagnosis: JsonObject) -> bool:
|
|
1204
|
+
issue_payload = _json_field(diagnosis, "issues")
|
|
1205
|
+
issues = issue_payload if isinstance(issue_payload, list) else []
|
|
1206
|
+
return any(
|
|
1207
|
+
isinstance(issue, dict) and issue.get("code") == "vocabulary_map.direct_policy_on_ambiguous_surface"
|
|
1208
|
+
for issue in issues
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _human_vocabulary_issues_are_auto_contextualizable(diagnosis: JsonObject) -> bool:
|
|
1213
|
+
issue_payload = _json_field(diagnosis, "issues", [])
|
|
1214
|
+
issues = [_json_object(issue) for issue in issue_payload if isinstance(issue, dict)] if isinstance(issue_payload, list) else []
|
|
1215
|
+
human_issues = [
|
|
1216
|
+
issue
|
|
1217
|
+
for issue in issues
|
|
1218
|
+
if issue.get("severity") == "human_decision"
|
|
1219
|
+
or issue.get("code")
|
|
1220
|
+
in {
|
|
1221
|
+
"vocabulary_map.duplicate_meaning",
|
|
1222
|
+
"vocabulary_map.non_atomic_note",
|
|
1223
|
+
"vocabulary_map.conflicting_alias",
|
|
1224
|
+
"vocabulary_map.direct_policy_on_ambiguous_surface",
|
|
1225
|
+
}
|
|
1226
|
+
]
|
|
1227
|
+
return bool(human_issues) and all(
|
|
1228
|
+
issue.get("code") == "vocabulary_map.direct_policy_on_ambiguous_surface"
|
|
1229
|
+
for issue in human_issues
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _contextualize_direct_policies_for_ambiguous_surfaces(db_path: Path) -> int:
|
|
1234
|
+
initialize_vocabulary_db(db_path)
|
|
1235
|
+
with sqlite3.connect(db_path) as conn:
|
|
1236
|
+
rows = conn.execute(
|
|
1237
|
+
"""
|
|
1238
|
+
SELECT p.id
|
|
1239
|
+
FROM surface_meaning_policy p
|
|
1240
|
+
JOIN surfaces s ON s.id = p.surface_id
|
|
1241
|
+
WHERE p.link_policy = 'direct'
|
|
1242
|
+
AND p.surface_id IN (
|
|
1243
|
+
SELECT s2.id
|
|
1244
|
+
FROM surfaces s2
|
|
1245
|
+
JOIN surface_meaning_policy p2 ON p2.surface_id = s2.id
|
|
1246
|
+
GROUP BY s2.id
|
|
1247
|
+
HAVING COUNT(DISTINCT p2.meaning_id) > 1
|
|
1248
|
+
OR MAX(s2.intrinsically_ambiguous) = 1
|
|
1249
|
+
)
|
|
1250
|
+
ORDER BY p.id
|
|
1251
|
+
"""
|
|
1252
|
+
).fetchall()
|
|
1253
|
+
policy_ids = [(int(row[0]),) for row in rows]
|
|
1254
|
+
conn.executemany(
|
|
1255
|
+
"""
|
|
1256
|
+
UPDATE surface_meaning_policy
|
|
1257
|
+
SET link_policy='requires_context', updated_at=CURRENT_TIMESTAMP
|
|
1258
|
+
WHERE id = ?
|
|
1259
|
+
""",
|
|
1260
|
+
policy_ids,
|
|
1261
|
+
)
|
|
1262
|
+
return len(policy_ids)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _vocabulary_repair_needed(diagnosis: JsonObject) -> bool:
|
|
1266
|
+
diagnosis_view = _VocabularyMapDiagnosisView.from_payload(diagnosis)
|
|
1267
|
+
if diagnosis_view.status == "blocked_human" and not _human_vocabulary_issues_are_auto_contextualizable(diagnosis):
|
|
1268
|
+
return False
|
|
1269
|
+
if diagnosis_view.pending_semantic_ingestion_count > 0:
|
|
1270
|
+
return True
|
|
1271
|
+
if _direct_ambiguous_surface_repair_needed(diagnosis):
|
|
1272
|
+
return True
|
|
1273
|
+
if any(
|
|
1274
|
+
issue.code == "vocabulary_map.unresolved_surfaces_without_meanings"
|
|
1275
|
+
for issue in diagnosis_view.issues
|
|
1276
|
+
):
|
|
1277
|
+
return True
|
|
1278
|
+
return diagnosis_view.note_count > 0 and diagnosis_view.meaning_count == 0
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _registered_blocker_requires_human(code: str, *, fallback: bool) -> bool:
|
|
1282
|
+
try:
|
|
1283
|
+
return blocker_entry(code).requires_human_packet
|
|
1284
|
+
except Exception:
|
|
1285
|
+
return fallback
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _attach_registered_decision_summary(
|
|
1289
|
+
item: JsonObject,
|
|
1290
|
+
*,
|
|
1291
|
+
phase: str,
|
|
1292
|
+
fallback_code: str,
|
|
1293
|
+
) -> JsonObject:
|
|
1294
|
+
issue = _VocabularyMapIssueView.model_validate(item)
|
|
1295
|
+
if issue.decision_summary is not None:
|
|
1296
|
+
return item
|
|
1297
|
+
code = issue.code or fallback_code
|
|
1298
|
+
try:
|
|
1299
|
+
decision = decision_for_code(
|
|
1300
|
+
code,
|
|
1301
|
+
phase=phase,
|
|
1302
|
+
public_summary=issue.message or "Bloqueio recuperavel no vocabulario.",
|
|
1303
|
+
developer_summary=issue.message or code,
|
|
1304
|
+
next_action=issue.next_action or "Continuar pelo fluxo oficial do workflow.",
|
|
1305
|
+
)
|
|
1306
|
+
except Exception:
|
|
1307
|
+
return item
|
|
1308
|
+
enriched = _json_object(item)
|
|
1309
|
+
enriched["decision_summary"] = decision.decision_summary()
|
|
1310
|
+
return _json_object(enriched)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _diagnosis_requests_vocabulary_repair(diagnosis: JsonObject) -> bool:
|
|
1314
|
+
body = _json_field(diagnosis, "body_term_linker")
|
|
1315
|
+
body_reason = _BodyLinkerView.from_payload(body).blocked_reason
|
|
1316
|
+
if body_reason in {"vocabulary_semantic_ingestion_pending", "vocabulary_map_blocked"}:
|
|
1317
|
+
return True
|
|
1318
|
+
vocabulary_map = _json_field(diagnosis, "vocabulary_map_diagnosis")
|
|
1319
|
+
if isinstance(vocabulary_map, dict) and _vocabulary_repair_needed(_json_object(vocabulary_map)):
|
|
1320
|
+
return True
|
|
1321
|
+
blocker_payload = _json_field(diagnosis, "blockers")
|
|
1322
|
+
blockers = blocker_payload if isinstance(blocker_payload, list) else []
|
|
1323
|
+
return any(
|
|
1324
|
+
_json_text(_json_object(blocker), "code")
|
|
1325
|
+
in {"vocabulary_semantic_ingestion_pending", "vocabulary_map.unresolved_surfaces_without_meanings"}
|
|
1326
|
+
if isinstance(blocker, dict)
|
|
1327
|
+
else False
|
|
1328
|
+
for blocker in blockers
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def repair_vocabulary_semantics_for_link(
|
|
1333
|
+
config: MedConfig,
|
|
1334
|
+
*,
|
|
1335
|
+
run_dir: Path | None = None,
|
|
1336
|
+
trigger: str = "link_apply",
|
|
1337
|
+
) -> JsonObject:
|
|
1338
|
+
if config.vocabulary_db_path is None:
|
|
1339
|
+
return _json_object({
|
|
1340
|
+
"schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
|
|
1341
|
+
"status": "skipped",
|
|
1342
|
+
"skipped_reason": "vocabulary_db_unconfigured",
|
|
1343
|
+
"applied_count": 0,
|
|
1344
|
+
"blocked_count": 0,
|
|
1345
|
+
})
|
|
1346
|
+
db_path = config.vocabulary_db_path
|
|
1347
|
+
if not db_path.exists():
|
|
1348
|
+
return _json_object({
|
|
1349
|
+
"schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
|
|
1350
|
+
"status": "skipped",
|
|
1351
|
+
"skipped_reason": "vocabulary_db_missing",
|
|
1352
|
+
"db_path": str(db_path),
|
|
1353
|
+
"applied_count": 0,
|
|
1354
|
+
"blocked_count": 0,
|
|
1355
|
+
})
|
|
1356
|
+
before = _json_object(load_vocabulary_map_diagnosis(db_path).as_diagnosis_dict())
|
|
1357
|
+
before_view = _VocabularyMapDiagnosisView.from_payload(before)
|
|
1358
|
+
if not _vocabulary_repair_needed(before):
|
|
1359
|
+
return _json_object({
|
|
1360
|
+
"schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
|
|
1361
|
+
"status": "skipped",
|
|
1362
|
+
"skipped_reason": "vocabulary_already_ready"
|
|
1363
|
+
if before_view.status == "ready"
|
|
1364
|
+
else "human_decision_required",
|
|
1365
|
+
"trigger": trigger,
|
|
1366
|
+
"db_path": str(db_path),
|
|
1367
|
+
"diagnosis_before": before,
|
|
1368
|
+
"applied_count": 0,
|
|
1369
|
+
"blocked_count": 0,
|
|
1370
|
+
})
|
|
1371
|
+
items = _baseline_semantic_ingestion_items(config)
|
|
1372
|
+
receipts: list[JsonObject] = []
|
|
1373
|
+
applied_count = 0
|
|
1374
|
+
blocked_count = 0
|
|
1375
|
+
with sqlite3.connect(db_path) as conn:
|
|
1376
|
+
for item in items:
|
|
1377
|
+
try:
|
|
1378
|
+
receipt = apply_semantic_ingestion(db_path=db_path, item=item, conn=conn)
|
|
1379
|
+
except ValidationError as exc:
|
|
1380
|
+
item_view = _SemanticIngestionItemView.from_payload(item)
|
|
1381
|
+
receipt = _json_object({
|
|
1382
|
+
"schema": "medical-notes-workbench.note-semantic-ingestion-apply-receipt.v1",
|
|
1383
|
+
"status": "blocked",
|
|
1384
|
+
"blocked_reason": "semantic_ingestion.validation_error",
|
|
1385
|
+
"error": str(exc),
|
|
1386
|
+
"note_path": item_view.note_path,
|
|
1387
|
+
"content_hash": item_view.content_hash,
|
|
1388
|
+
})
|
|
1389
|
+
receipt = _json_object(receipt)
|
|
1390
|
+
if _json_field(receipt, "status") == "applied":
|
|
1391
|
+
applied_count += 1
|
|
1392
|
+
else:
|
|
1393
|
+
blocked_count += 1
|
|
1394
|
+
receipts.append(receipt)
|
|
1395
|
+
dropped_orphan_surface_count = _drop_unresolved_surfaces(db_path)
|
|
1396
|
+
contextualized_direct_policy_count = _contextualize_direct_policies_for_ambiguous_surfaces(db_path)
|
|
1397
|
+
after = _json_object(load_vocabulary_map_diagnosis(db_path).as_diagnosis_dict())
|
|
1398
|
+
after_view = _VocabularyMapDiagnosisView.from_payload(after)
|
|
1399
|
+
status = "completed" if after_view.status == "ready" else "completed_with_blockers"
|
|
1400
|
+
payload = _json_object({
|
|
1401
|
+
"schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
|
|
1402
|
+
"phase": "vocabulary_semantic_repair",
|
|
1403
|
+
"status": status,
|
|
1404
|
+
"trigger": trigger,
|
|
1405
|
+
"db_path": str(db_path),
|
|
1406
|
+
"diagnosis_before": before,
|
|
1407
|
+
"diagnosis_after": after,
|
|
1408
|
+
"item_count": len(items),
|
|
1409
|
+
"applied_count": applied_count,
|
|
1410
|
+
"blocked_count": blocked_count,
|
|
1411
|
+
"dropped_orphan_surface_count": dropped_orphan_surface_count,
|
|
1412
|
+
"contextualized_direct_policy_count": contextualized_direct_policy_count,
|
|
1413
|
+
"receipts": receipts,
|
|
1414
|
+
})
|
|
1415
|
+
if status != "completed":
|
|
1416
|
+
payload = _json_object(
|
|
1417
|
+
{
|
|
1418
|
+
**payload,
|
|
1419
|
+
"blocked_reason": _json_text(after, "status", "vocabulary_semantic_repair_blocked"),
|
|
1420
|
+
"next_action": "Resolver decisões humanas ou erros de ingestão restantes pelo workflow /mednotes:link.",
|
|
1421
|
+
}
|
|
1422
|
+
)
|
|
1423
|
+
if run_dir is not None:
|
|
1424
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1425
|
+
receipt_path = run_dir / "vocabulary-semantic-repair-receipt.json"
|
|
1426
|
+
atomic_write_text(receipt_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
1427
|
+
payload["receipt_path"] = str(receipt_path)
|
|
1428
|
+
return _json_object(payload)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _body_only_fallback(
|
|
1432
|
+
*,
|
|
1433
|
+
path: Path,
|
|
1434
|
+
body_linker: JsonObject,
|
|
1435
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
1436
|
+
reference_repair: JsonObject,
|
|
1437
|
+
) -> JsonObject | None:
|
|
1438
|
+
if related_notes is None or not related_notes_sync_blocked(related_notes):
|
|
1439
|
+
return None
|
|
1440
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
1441
|
+
repair_view = _ReferenceRepairView.from_payload(reference_repair)
|
|
1442
|
+
body_blocked = bool(body_view.error or body_view.parse_error or body_view.blocker_count)
|
|
1443
|
+
reference_blocked = bool(repair_view.blocking_action_count)
|
|
1444
|
+
blocked_phases: list[str] = []
|
|
1445
|
+
if body_blocked:
|
|
1446
|
+
blocked_phases.append("body_term_linker")
|
|
1447
|
+
if reference_blocked:
|
|
1448
|
+
blocked_phases.append("reference_repair")
|
|
1449
|
+
recovery_command = wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json")
|
|
1450
|
+
if blocked_phases:
|
|
1451
|
+
return _json_object({
|
|
1452
|
+
"safe": False,
|
|
1453
|
+
"command": "",
|
|
1454
|
+
"diagnosis_path": str(path),
|
|
1455
|
+
"allowed_scope": [],
|
|
1456
|
+
"excluded_scope": ["related_notes_sync"],
|
|
1457
|
+
"blocked_phases": blocked_phases,
|
|
1458
|
+
"expected_changed_counts": {
|
|
1459
|
+
"modified": 0,
|
|
1460
|
+
"deleted": 0,
|
|
1461
|
+
"created": 0,
|
|
1462
|
+
"links_planned": body_view.links_planned,
|
|
1463
|
+
"reference_actions": repair_view.action_count,
|
|
1464
|
+
},
|
|
1465
|
+
"reason": "Related Notes está bloqueado e pelo menos uma fase body/reference também não está segura.",
|
|
1466
|
+
"next_action": recovery_command,
|
|
1467
|
+
})
|
|
1468
|
+
return _json_object({
|
|
1469
|
+
"safe": True,
|
|
1470
|
+
"command": "/mednotes:link-body",
|
|
1471
|
+
"cli_command": wiki_cli_command("run-linker", "--apply", "--no-related-notes", "--diagnosis", str(path), "--json"),
|
|
1472
|
+
"diagnosis_path": str(path),
|
|
1473
|
+
"allowed_scope": ["reference_repair", "body_term_linker"],
|
|
1474
|
+
"excluded_scope": ["related_notes_sync"],
|
|
1475
|
+
"blocked_phases": [],
|
|
1476
|
+
"expected_changed_counts": {
|
|
1477
|
+
"modified": max(
|
|
1478
|
+
body_view.files_changed,
|
|
1479
|
+
repair_view.affected_note_count,
|
|
1480
|
+
),
|
|
1481
|
+
"deleted": 0,
|
|
1482
|
+
"created": 0,
|
|
1483
|
+
"links_planned": body_view.links_planned,
|
|
1484
|
+
"reference_actions": repair_view.action_count,
|
|
1485
|
+
},
|
|
1486
|
+
"reason": "Related Notes export está stale, mas as fases body/reference não dependem do export.",
|
|
1487
|
+
"next_action": "/mednotes:link-body",
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _skipped_image_only_diagnosis(
|
|
1492
|
+
config: MedConfig,
|
|
1493
|
+
*,
|
|
1494
|
+
path: Path,
|
|
1495
|
+
trigger_context: JsonObject,
|
|
1496
|
+
git_context: LinkGitContext | None = None,
|
|
1497
|
+
) -> JsonObject:
|
|
1498
|
+
snapshot = _collect_snapshot(config)
|
|
1499
|
+
git_context = git_context or _git_context_for(config)
|
|
1500
|
+
git_payload = git_context.to_payload()
|
|
1501
|
+
git_view = _GitContextView.from_payload(git_payload)
|
|
1502
|
+
phases = JsonObjectAdapter.validate_python({
|
|
1503
|
+
phase: {"status": "skipped"}
|
|
1504
|
+
for phase in LINK_PHASE_ORDER
|
|
1505
|
+
})
|
|
1506
|
+
plan_payload = JsonObjectAdapter.validate_python({"phase_order": list(LINK_PHASE_ORDER), "phases": phases})
|
|
1507
|
+
payload = _json_object({
|
|
1508
|
+
"schema": LINK_DIAGNOSIS_SCHEMA,
|
|
1509
|
+
"generated_at": _now_iso(),
|
|
1510
|
+
"phase": "link_diagnosis",
|
|
1511
|
+
"status": "skipped",
|
|
1512
|
+
"blocked_reason": "",
|
|
1513
|
+
"skipped_reason": "image_only_changes",
|
|
1514
|
+
"next_action": "",
|
|
1515
|
+
"required_inputs": LINK_REQUIRED_INPUTS,
|
|
1516
|
+
"human_decision_required": False,
|
|
1517
|
+
"diagnosis_path": str(path),
|
|
1518
|
+
"wiki_dir": str(config.wiki_dir),
|
|
1519
|
+
"catalog_path": str(config.catalog_path) if config.catalog_path else None,
|
|
1520
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
1521
|
+
"trigger_context": trigger_context,
|
|
1522
|
+
"triggers_detected": derive_triggers(trigger_context),
|
|
1523
|
+
"affected_notes": affected_notes_from_context(trigger_context),
|
|
1524
|
+
"git": git_payload,
|
|
1525
|
+
"git_status_hash": git_view.status_hash,
|
|
1526
|
+
"snapshot": snapshot,
|
|
1527
|
+
"snapshot_hash": snapshot["snapshot_hash"],
|
|
1528
|
+
"plan": plan_payload,
|
|
1529
|
+
"plan_hash": "sha256:" + canonical_json_hash(plan_payload),
|
|
1530
|
+
"phases": phases,
|
|
1531
|
+
"reference_repair": {
|
|
1532
|
+
"schema": "medical-notes-workbench.reference-repair-plan.v1",
|
|
1533
|
+
"phase": "reference_repair",
|
|
1534
|
+
"status": "skipped",
|
|
1535
|
+
"package_mode": "diagnosis_bound",
|
|
1536
|
+
"manual_script_allowed": False,
|
|
1537
|
+
"requires_backup": False,
|
|
1538
|
+
"requires_receipt": True,
|
|
1539
|
+
"action_count": 0,
|
|
1540
|
+
"affected_note_count": 0,
|
|
1541
|
+
"blocking_action_count": 0,
|
|
1542
|
+
"human_decision_count": 0,
|
|
1543
|
+
"triage_count": 0,
|
|
1544
|
+
"human_decision_required": False,
|
|
1545
|
+
"triage_required": False,
|
|
1546
|
+
"note_actions": [],
|
|
1547
|
+
"structural_actions": [],
|
|
1548
|
+
"catalog_actions": [],
|
|
1549
|
+
"human_decision_packets": [],
|
|
1550
|
+
},
|
|
1551
|
+
"human_decision_packets": [],
|
|
1552
|
+
"links_planned": 0,
|
|
1553
|
+
"links_rewritten": 0,
|
|
1554
|
+
"blocker_count": 0,
|
|
1555
|
+
"blockers": [],
|
|
1556
|
+
"body_term_linker": None,
|
|
1557
|
+
"related_notes_sync": None,
|
|
1558
|
+
"related_notes_skipped_reason": "",
|
|
1559
|
+
"returncode": 0,
|
|
1560
|
+
})
|
|
1561
|
+
atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
1562
|
+
return payload
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _run_body_linker(
|
|
1566
|
+
config: MedConfig,
|
|
1567
|
+
*,
|
|
1568
|
+
dry_run: bool,
|
|
1569
|
+
llm_disambiguation: str = "off",
|
|
1570
|
+
llm_model: str | None = None,
|
|
1571
|
+
llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
|
|
1572
|
+
llm_disambiguator: Callable[..., object] | None = None,
|
|
1573
|
+
) -> JsonObject:
|
|
1574
|
+
if config.vocabulary_db_path is None:
|
|
1575
|
+
graph_before = graph_audit(config) if config.wiki_dir.exists() else {}
|
|
1576
|
+
return _json_object({
|
|
1577
|
+
"ok": False,
|
|
1578
|
+
"blocked": True,
|
|
1579
|
+
"dry_run": dry_run,
|
|
1580
|
+
"phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
|
|
1581
|
+
"status": "blocked",
|
|
1582
|
+
"blocked_reason": "vocabulary_db_unconfigured",
|
|
1583
|
+
"next_action": "Configure vocabulary_db_path e rode /mednotes:fix-wiki --apply para instanciar o DB.",
|
|
1584
|
+
"required_inputs": ["vocabulary_db_path"],
|
|
1585
|
+
"human_decision_required": False,
|
|
1586
|
+
"body_linker_mode": "vocabulary_db",
|
|
1587
|
+
"body_linker_skipped_reason": "vocabulary_db_unconfigured",
|
|
1588
|
+
"vocabulary_db_path": "",
|
|
1589
|
+
"returncode": 3,
|
|
1590
|
+
"files_scanned": 0,
|
|
1591
|
+
"files_changed": 0,
|
|
1592
|
+
"links_planned": 0,
|
|
1593
|
+
"links_rewritten": 0,
|
|
1594
|
+
"blocker_count": 1,
|
|
1595
|
+
"blockers": [
|
|
1596
|
+
{
|
|
1597
|
+
"code": "vocabulary_db_unconfigured",
|
|
1598
|
+
"message": "O linker atual exige vocabulary DB configurado antes de aplicar body links.",
|
|
1599
|
+
}
|
|
1600
|
+
],
|
|
1601
|
+
"graph_audit_before": graph_before,
|
|
1602
|
+
"plans": [],
|
|
1603
|
+
})
|
|
1604
|
+
if config.vocabulary_db_path and config.vocabulary_db_path.exists():
|
|
1605
|
+
graph_before = graph_audit(config)
|
|
1606
|
+
vocabulary_map_diagnosis = load_vocabulary_map_diagnosis(config.vocabulary_db_path).as_diagnosis_dict()
|
|
1607
|
+
vocabulary_view = _VocabularyMapDiagnosisView.from_payload(vocabulary_map_diagnosis)
|
|
1608
|
+
vocabulary_status = vocabulary_view.status
|
|
1609
|
+
if vocabulary_status in {"blocked_pending", "blocked_human"}:
|
|
1610
|
+
pending_count = vocabulary_view.pending_semantic_ingestion_count
|
|
1611
|
+
blocked_reason = "vocabulary_semantic_ingestion_pending" if pending_count else "vocabulary_map_blocked"
|
|
1612
|
+
blockers: list[JsonObject] = []
|
|
1613
|
+
for issue in vocabulary_view.issues:
|
|
1614
|
+
item = _json_object({
|
|
1615
|
+
"code": issue.code or blocked_reason,
|
|
1616
|
+
"message": issue.message,
|
|
1617
|
+
"next_action": issue.next_action,
|
|
1618
|
+
"required_inputs": issue.required_inputs,
|
|
1619
|
+
})
|
|
1620
|
+
if issue.decision_summary is not None:
|
|
1621
|
+
item = _json_object({**item, "decision_summary": issue.decision_summary})
|
|
1622
|
+
blockers.append(
|
|
1623
|
+
_attach_registered_decision_summary(
|
|
1624
|
+
item,
|
|
1625
|
+
phase="link_diagnosis",
|
|
1626
|
+
fallback_code=blocked_reason,
|
|
1627
|
+
)
|
|
1628
|
+
)
|
|
1629
|
+
if not blockers:
|
|
1630
|
+
blockers = [
|
|
1631
|
+
_attach_registered_decision_summary(
|
|
1632
|
+
_json_object({
|
|
1633
|
+
"code": blocked_reason,
|
|
1634
|
+
"message": "Vocabulary DB is not ready for body linker.",
|
|
1635
|
+
}),
|
|
1636
|
+
phase="link_diagnosis",
|
|
1637
|
+
fallback_code=blocked_reason,
|
|
1638
|
+
)
|
|
1639
|
+
]
|
|
1640
|
+
human_decision_required = any(
|
|
1641
|
+
_registered_blocker_requires_human(
|
|
1642
|
+
_json_text(item, "code", blocked_reason),
|
|
1643
|
+
fallback=vocabulary_status == "blocked_human",
|
|
1644
|
+
)
|
|
1645
|
+
for item in blockers
|
|
1646
|
+
)
|
|
1647
|
+
return _json_object({
|
|
1648
|
+
"ok": False,
|
|
1649
|
+
"blocked": True,
|
|
1650
|
+
"dry_run": dry_run,
|
|
1651
|
+
"phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
|
|
1652
|
+
"status": "blocked",
|
|
1653
|
+
"blocked_reason": blocked_reason,
|
|
1654
|
+
"next_action": (
|
|
1655
|
+
"Continuar a curadoria semântica dentro de /mednotes:link antes de linkar o corpo."
|
|
1656
|
+
if blocked_reason == "vocabulary_semantic_ingestion_pending"
|
|
1657
|
+
else "Reconciliar o vocabulary DB pelo fluxo oficial de /mednotes:link antes de projetar aliases ou linkar o corpo."
|
|
1658
|
+
),
|
|
1659
|
+
"required_inputs": ["vocabulary_semantic_ingestion"] if pending_count else ["vocabulary_recovery"],
|
|
1660
|
+
"human_decision_required": human_decision_required,
|
|
1661
|
+
"body_linker_mode": "vocabulary_db",
|
|
1662
|
+
"body_linker_skipped_reason": blocked_reason,
|
|
1663
|
+
"vocabulary_db_path": str(config.vocabulary_db_path),
|
|
1664
|
+
"vocabulary_map_diagnosis": vocabulary_map_diagnosis,
|
|
1665
|
+
"pending_semantic_ingestion_count": pending_count,
|
|
1666
|
+
"returncode": 3,
|
|
1667
|
+
"files_scanned": 0,
|
|
1668
|
+
"files_changed": 0,
|
|
1669
|
+
"links_planned": 0,
|
|
1670
|
+
"links_rewritten": 0,
|
|
1671
|
+
"blocker_count": len(blockers),
|
|
1672
|
+
"blockers": blockers,
|
|
1673
|
+
"graph_audit_before": graph_before,
|
|
1674
|
+
"plans": [],
|
|
1675
|
+
})
|
|
1676
|
+
payload = _json_object(run_db_body_linker(
|
|
1677
|
+
wiki_dir=config.wiki_dir,
|
|
1678
|
+
db_path=config.vocabulary_db_path,
|
|
1679
|
+
dry_run=dry_run,
|
|
1680
|
+
llm_mode=llm_disambiguation if dry_run else "off",
|
|
1681
|
+
llm_model=llm_model,
|
|
1682
|
+
llm_timeout=llm_timeout,
|
|
1683
|
+
llm_disambiguator=llm_disambiguator,
|
|
1684
|
+
))
|
|
1685
|
+
payload["returncode"] = 3 if _json_field(payload, "blocked") else 0
|
|
1686
|
+
payload["graph_audit_before"] = graph_before
|
|
1687
|
+
payload["vocabulary_map_diagnosis"] = vocabulary_map_diagnosis
|
|
1688
|
+
return _json_object(payload)
|
|
1689
|
+
|
|
1690
|
+
return _body_linker_blocked_for_vocabulary_bootstrap(
|
|
1691
|
+
config,
|
|
1692
|
+
dry_run=dry_run,
|
|
1693
|
+
vocabulary_bootstrap=_json_object(planned_vocabulary_bootstrap(config)),
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def _body_linker_blocked_for_vocabulary_bootstrap(
|
|
1698
|
+
config: MedConfig,
|
|
1699
|
+
*,
|
|
1700
|
+
dry_run: bool,
|
|
1701
|
+
vocabulary_bootstrap: JsonObject,
|
|
1702
|
+
) -> JsonObject:
|
|
1703
|
+
graph_before = graph_audit(config) if config.wiki_dir.exists() else {}
|
|
1704
|
+
bootstrap_view = _VocabularyBootstrapView.from_payload(vocabulary_bootstrap)
|
|
1705
|
+
if bootstrap_view.note_count == 0:
|
|
1706
|
+
return _json_object({
|
|
1707
|
+
"ok": True,
|
|
1708
|
+
"blocked": False,
|
|
1709
|
+
"dry_run": dry_run,
|
|
1710
|
+
"phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
|
|
1711
|
+
"status": "skipped",
|
|
1712
|
+
"blocked_reason": "",
|
|
1713
|
+
"skipped_reason": "vocabulary_bootstrap_empty_wiki",
|
|
1714
|
+
"next_action": "",
|
|
1715
|
+
"required_inputs": [],
|
|
1716
|
+
"human_decision_required": False,
|
|
1717
|
+
"body_linker_mode": "vocabulary_db",
|
|
1718
|
+
"body_linker_skipped_reason": "vocabulary_bootstrap_empty_wiki",
|
|
1719
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
1720
|
+
"vocabulary_bootstrap": vocabulary_bootstrap,
|
|
1721
|
+
"returncode": 0,
|
|
1722
|
+
"files_scanned": 0,
|
|
1723
|
+
"files_changed": 0,
|
|
1724
|
+
"links_planned": 0,
|
|
1725
|
+
"links_rewritten": 0,
|
|
1726
|
+
"blocker_count": 0,
|
|
1727
|
+
"blockers": [],
|
|
1728
|
+
"graph_audit_before": graph_before,
|
|
1729
|
+
"plans": [],
|
|
1730
|
+
})
|
|
1731
|
+
return _json_object({
|
|
1732
|
+
"ok": False,
|
|
1733
|
+
"blocked": True,
|
|
1734
|
+
"dry_run": dry_run,
|
|
1735
|
+
"phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
|
|
1736
|
+
"status": "blocked",
|
|
1737
|
+
"blocked_reason": "vocabulary_bootstrap_required",
|
|
1738
|
+
"next_action": (
|
|
1739
|
+
"Resolver pendências do linker/grafo: instanciar o vocabulary DB via workflow apply, "
|
|
1740
|
+
"processar a fila com med-link-graph-curator e repetir o diagnóstico de links."
|
|
1741
|
+
),
|
|
1742
|
+
"required_inputs": ["vocabulary_bootstrap", "vocabulary_semantic_ingestion"],
|
|
1743
|
+
"human_decision_required": False,
|
|
1744
|
+
"body_linker_mode": "vocabulary_db",
|
|
1745
|
+
"body_linker_skipped_reason": "vocabulary_bootstrap_required",
|
|
1746
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
1747
|
+
"vocabulary_bootstrap": vocabulary_bootstrap,
|
|
1748
|
+
"returncode": 3,
|
|
1749
|
+
"files_scanned": 0,
|
|
1750
|
+
"files_changed": 0,
|
|
1751
|
+
"links_planned": 0,
|
|
1752
|
+
"links_rewritten": 0,
|
|
1753
|
+
"blocker_count": 1,
|
|
1754
|
+
"blockers": [
|
|
1755
|
+
{
|
|
1756
|
+
"code": "vocabulary_bootstrap_required",
|
|
1757
|
+
"message": "O DB de vocabulário ainda não existe; diagnóstico não instancia nem limpa notas.",
|
|
1758
|
+
}
|
|
1759
|
+
],
|
|
1760
|
+
"graph_audit_before": graph_before,
|
|
1761
|
+
"plans": [],
|
|
1762
|
+
})
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def diagnose_links(
|
|
1766
|
+
config: MedConfig,
|
|
1767
|
+
*,
|
|
1768
|
+
diagnosis_path: Path | None = None,
|
|
1769
|
+
include_related_notes: bool = True,
|
|
1770
|
+
force_diagnose: bool = False,
|
|
1771
|
+
trigger_context: JsonObject | None = None,
|
|
1772
|
+
llm_disambiguation: str = "auto",
|
|
1773
|
+
llm_model: str | None = None,
|
|
1774
|
+
llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
|
|
1775
|
+
llm_disambiguator: Callable[..., object] | None = None,
|
|
1776
|
+
) -> JsonObject:
|
|
1777
|
+
path = diagnosis_path or default_link_diagnosis_path()
|
|
1778
|
+
link_state = load_link_state()
|
|
1779
|
+
git_context = _git_context_for(config, link_state)
|
|
1780
|
+
git_payload = git_context.to_payload()
|
|
1781
|
+
if not config.wiki_dir.exists():
|
|
1782
|
+
message = f"Wiki dir não encontrado: {config.wiki_dir}"
|
|
1783
|
+
git_view = _GitContextView.from_payload(git_payload)
|
|
1784
|
+
payload = _json_object({
|
|
1785
|
+
"schema": LINK_DIAGNOSIS_SCHEMA,
|
|
1786
|
+
"generated_at": _now_iso(),
|
|
1787
|
+
"phase": "link_diagnosis",
|
|
1788
|
+
"status": "failed",
|
|
1789
|
+
"blocked_reason": "linker_error",
|
|
1790
|
+
"next_action": "Corrigir --wiki-dir ou [paths].wiki_dir e rodar o diagnóstico novamente.",
|
|
1791
|
+
"required_inputs": LINK_REQUIRED_INPUTS,
|
|
1792
|
+
"human_decision_required": False,
|
|
1793
|
+
"diagnosis_path": str(path),
|
|
1794
|
+
"wiki_dir": str(config.wiki_dir),
|
|
1795
|
+
"catalog_path": str(config.catalog_path) if config.catalog_path else None,
|
|
1796
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
1797
|
+
"git": git_payload,
|
|
1798
|
+
"git_status_hash": git_view.status_hash,
|
|
1799
|
+
"error": message,
|
|
1800
|
+
"returncode": 4,
|
|
1801
|
+
})
|
|
1802
|
+
atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
1803
|
+
return payload
|
|
1804
|
+
if trigger_context is not None and is_image_only_context(trigger_context):
|
|
1805
|
+
return _skipped_image_only_diagnosis(config, path=path, trigger_context=trigger_context, git_context=git_context)
|
|
1806
|
+
vocabulary_bootstrap = _json_object(planned_vocabulary_bootstrap(config))
|
|
1807
|
+
trigger_snapshot = _collect_snapshot(config)
|
|
1808
|
+
effective_trigger_context = trigger_context or _git_trigger_context_for(git_context, snapshot=trigger_snapshot, link_state=link_state)
|
|
1809
|
+
identity = build_diagnosis_identity(
|
|
1810
|
+
snapshot=trigger_snapshot,
|
|
1811
|
+
git_context=git_payload,
|
|
1812
|
+
trigger_context=effective_trigger_context,
|
|
1813
|
+
include_related_notes=include_related_notes,
|
|
1814
|
+
llm_disambiguation=llm_disambiguation,
|
|
1815
|
+
llm_model=llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
|
|
1816
|
+
llm_timeout=llm_timeout,
|
|
1817
|
+
)
|
|
1818
|
+
if not force_diagnose:
|
|
1819
|
+
redundant = redundant_diagnosis_payload(link_state.to_payload() if link_state is not None else {}, identity)
|
|
1820
|
+
if redundant is not None:
|
|
1821
|
+
return redundant
|
|
1822
|
+
bootstrap_view = _VocabularyBootstrapView.from_payload(vocabulary_bootstrap)
|
|
1823
|
+
body_linker = (
|
|
1824
|
+
_body_linker_blocked_for_vocabulary_bootstrap(
|
|
1825
|
+
config,
|
|
1826
|
+
dry_run=True,
|
|
1827
|
+
vocabulary_bootstrap=vocabulary_bootstrap,
|
|
1828
|
+
)
|
|
1829
|
+
if bootstrap_view.status == "planned"
|
|
1830
|
+
else _run_body_linker(
|
|
1831
|
+
config,
|
|
1832
|
+
dry_run=True,
|
|
1833
|
+
llm_disambiguation=llm_disambiguation,
|
|
1834
|
+
llm_model=llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
|
|
1835
|
+
llm_timeout=llm_timeout,
|
|
1836
|
+
llm_disambiguator=llm_disambiguator,
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
vocabulary_curator_batch_plan, vocabulary_curator_batch_plan_path, vocabulary_curator_next_action = (
|
|
1840
|
+
_link_vocabulary_curator_batch(
|
|
1841
|
+
config,
|
|
1842
|
+
diagnosis_path=path,
|
|
1843
|
+
body_linker=body_linker,
|
|
1844
|
+
)
|
|
1845
|
+
)
|
|
1846
|
+
if vocabulary_curator_next_action:
|
|
1847
|
+
body_linker = _json_object({**body_linker, "next_action": vocabulary_curator_next_action})
|
|
1848
|
+
related_notes = run_related_notes_sync(config, apply=False, backup=False) if include_related_notes else None
|
|
1849
|
+
related_notes_payload = _related_notes_payload(related_notes)
|
|
1850
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
1851
|
+
contextual_diagnosis = body_view.contextual_alias_disambiguation
|
|
1852
|
+
# The body linker can persist contextual LLM decisions into the vocabulary
|
|
1853
|
+
# DB during diagnosis. Persist the post-diagnosis snapshot only when that
|
|
1854
|
+
# happened so ordinary diagnosis still reuses the trigger snapshot.
|
|
1855
|
+
snapshot = _collect_snapshot(config) if contextual_diagnosis.decision_count else trigger_snapshot
|
|
1856
|
+
reference_repair = _json_object(plan_reference_repair(
|
|
1857
|
+
_graph_issues_from(body_linker),
|
|
1858
|
+
structural_events=structural_events_from_context(effective_trigger_context),
|
|
1859
|
+
))
|
|
1860
|
+
repair_view = _ReferenceRepairView.from_payload(reference_repair)
|
|
1861
|
+
blockers = _collect_blockers(body_linker, related_notes, reference_repair)
|
|
1862
|
+
phases = _diagnosis_phases(body_linker, related_notes, reference_repair)
|
|
1863
|
+
human_decision_packets = list(repair_view.human_decision_packets)
|
|
1864
|
+
failed = bool(body_view.error or body_view.parse_error)
|
|
1865
|
+
status = "failed" if failed else "blocked" if blockers else "diagnosis_ready"
|
|
1866
|
+
blocked_reason = (
|
|
1867
|
+
"linker_error"
|
|
1868
|
+
if failed
|
|
1869
|
+
else "link_plan_blocked"
|
|
1870
|
+
if blockers
|
|
1871
|
+
else ""
|
|
1872
|
+
)
|
|
1873
|
+
plan = _json_object({
|
|
1874
|
+
"phase_order": list(LINK_PHASE_ORDER),
|
|
1875
|
+
"phases": phases,
|
|
1876
|
+
"reference_repair": reference_repair,
|
|
1877
|
+
"body_term_linker": body_linker,
|
|
1878
|
+
"related_notes_sync": related_notes_payload,
|
|
1879
|
+
"llm_disambiguation": {
|
|
1880
|
+
"mode": llm_disambiguation,
|
|
1881
|
+
"model": llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
|
|
1882
|
+
"timeout_seconds": llm_timeout,
|
|
1883
|
+
},
|
|
1884
|
+
})
|
|
1885
|
+
if vocabulary_curator_batch_plan:
|
|
1886
|
+
plan["vocabulary_curation"] = {
|
|
1887
|
+
"status": _json_text(vocabulary_curator_batch_plan, "status"),
|
|
1888
|
+
"item_count": _json_int(vocabulary_curator_batch_plan, "item_count"),
|
|
1889
|
+
"plan_path": vocabulary_curator_batch_plan_path,
|
|
1890
|
+
}
|
|
1891
|
+
plan_hash = "sha256:" + canonical_json_hash(plan)
|
|
1892
|
+
payload = _json_object({
|
|
1893
|
+
"schema": LINK_DIAGNOSIS_SCHEMA,
|
|
1894
|
+
"generated_at": _now_iso(),
|
|
1895
|
+
"phase": "link_diagnosis",
|
|
1896
|
+
"status": status,
|
|
1897
|
+
"blocked_reason": blocked_reason,
|
|
1898
|
+
"next_action": _diagnosis_next_action(blockers=blockers, body_linker=body_linker, related_notes=related_notes),
|
|
1899
|
+
"required_inputs": _diagnosis_required_inputs(related_notes=related_notes),
|
|
1900
|
+
"human_decision_required": bool(human_decision_packets),
|
|
1901
|
+
"diagnosis_path": str(path),
|
|
1902
|
+
"wiki_dir": str(config.wiki_dir),
|
|
1903
|
+
"catalog_path": str(config.catalog_path) if config.catalog_path else None,
|
|
1904
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
1905
|
+
"vocabulary_bootstrap": vocabulary_bootstrap,
|
|
1906
|
+
"vocabulary_map_diagnosis": body_view.vocabulary_map_diagnosis.to_payload(),
|
|
1907
|
+
"vocabulary_curator_batch_plan": vocabulary_curator_batch_plan,
|
|
1908
|
+
"vocabulary_curator_batch_plan_path": vocabulary_curator_batch_plan_path,
|
|
1909
|
+
"vocabulary_curator_next_action": vocabulary_curator_next_action,
|
|
1910
|
+
"trigger_context": effective_trigger_context,
|
|
1911
|
+
"triggers_detected": derive_triggers(effective_trigger_context),
|
|
1912
|
+
"affected_notes": affected_notes_from_context(effective_trigger_context),
|
|
1913
|
+
"git": git_payload,
|
|
1914
|
+
"git_status_hash": _GitContextView.from_payload(git_payload).status_hash,
|
|
1915
|
+
"snapshot": snapshot,
|
|
1916
|
+
"snapshot_hash": snapshot["snapshot_hash"],
|
|
1917
|
+
"plan": plan,
|
|
1918
|
+
"plan_hash": plan_hash,
|
|
1919
|
+
"phases": phases,
|
|
1920
|
+
"reference_repair": reference_repair,
|
|
1921
|
+
"human_decision_packets": human_decision_packets,
|
|
1922
|
+
"links_planned": body_view.links_planned,
|
|
1923
|
+
"links_rewritten": body_view.links_rewritten,
|
|
1924
|
+
"blocker_count": len(blockers),
|
|
1925
|
+
"blockers": blockers,
|
|
1926
|
+
"body_term_linker": body_linker,
|
|
1927
|
+
"contextual_alias_disambiguation": body_view.contextual_alias_disambiguation.to_payload(),
|
|
1928
|
+
"related_notes_sync": related_notes_payload,
|
|
1929
|
+
"related_notes_skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
|
|
1930
|
+
"body_only_fallback": _body_only_fallback(
|
|
1931
|
+
path=path,
|
|
1932
|
+
body_linker=body_linker,
|
|
1933
|
+
related_notes=related_notes,
|
|
1934
|
+
reference_repair=reference_repair,
|
|
1935
|
+
),
|
|
1936
|
+
"agent_events": [force_diagnose_event(diagnosis_path=path)] if force_diagnose else [],
|
|
1937
|
+
"returncode": body_view.returncode,
|
|
1938
|
+
})
|
|
1939
|
+
if body_view.error:
|
|
1940
|
+
payload["error"] = body_view.error
|
|
1941
|
+
if body_view.parse_error:
|
|
1942
|
+
payload["parse_error"] = body_view.parse_error
|
|
1943
|
+
atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
1944
|
+
record_diagnosis_attempt(payload, identity=identity)
|
|
1945
|
+
return _json_object(payload)
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def _load_diagnosis(path: Path) -> JsonObject:
|
|
1949
|
+
try:
|
|
1950
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1951
|
+
except FileNotFoundError as exc:
|
|
1952
|
+
raise ValidationError(f"Diagnóstico de links não encontrado: {path}") from exc
|
|
1953
|
+
except json.JSONDecodeError as exc:
|
|
1954
|
+
raise ValidationError(f"Diagnóstico de links inválido: {path}: {exc}") from exc
|
|
1955
|
+
if not isinstance(data, dict) or data.get("schema") != LINK_DIAGNOSIS_SCHEMA:
|
|
1956
|
+
raise ValidationError(f"Diagnóstico de links precisa usar schema {LINK_DIAGNOSIS_SCHEMA}.")
|
|
1957
|
+
return _json_object(data)
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def _changed_files_from(*payloads: JsonObject | None) -> list[str]:
|
|
1961
|
+
changed: set[str] = set()
|
|
1962
|
+
for payload in payloads:
|
|
1963
|
+
if not payload:
|
|
1964
|
+
continue
|
|
1965
|
+
plans = _json_field(payload, "plans")
|
|
1966
|
+
if isinstance(plans, list):
|
|
1967
|
+
for plan in plans:
|
|
1968
|
+
plan_view = _LinkBodyPlanView.from_payload(plan)
|
|
1969
|
+
if plan_view.changed and plan_view.file:
|
|
1970
|
+
changed.add(plan_view.file)
|
|
1971
|
+
updates = _json_field(payload, "updates")
|
|
1972
|
+
if isinstance(updates, list):
|
|
1973
|
+
for update in updates:
|
|
1974
|
+
if not isinstance(update, dict) or update.get("changed") is False:
|
|
1975
|
+
continue
|
|
1976
|
+
file_path = update.get("file") or update.get("path")
|
|
1977
|
+
if isinstance(file_path, str):
|
|
1978
|
+
changed.add(file_path)
|
|
1979
|
+
changed_files = _json_field(payload, "changed_files")
|
|
1980
|
+
if isinstance(changed_files, list):
|
|
1981
|
+
for item in changed_files:
|
|
1982
|
+
if isinstance(item, str):
|
|
1983
|
+
changed.add(item)
|
|
1984
|
+
return sorted(changed)
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def _snapshot_note_hashes(snapshot: JsonObject) -> dict[str, str]:
|
|
1988
|
+
notes = snapshot.get("notes")
|
|
1989
|
+
if not isinstance(notes, list):
|
|
1990
|
+
return {}
|
|
1991
|
+
hashes: dict[str, str] = {}
|
|
1992
|
+
for note in notes:
|
|
1993
|
+
note_view = _SnapshotNoteView.from_payload(note)
|
|
1994
|
+
if note_view.path:
|
|
1995
|
+
hashes[note_view.path] = note_view.content_hash
|
|
1996
|
+
return hashes
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def _relative_receipt_path(config: MedConfig, value: str) -> str:
|
|
2000
|
+
path = Path(value)
|
|
2001
|
+
if path.is_absolute():
|
|
2002
|
+
try:
|
|
2003
|
+
return path.resolve().relative_to(config.wiki_dir.resolve()).as_posix()
|
|
2004
|
+
except (OSError, ValueError):
|
|
2005
|
+
return path.as_posix()
|
|
2006
|
+
return path.as_posix()
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
def _safe_link_action(action: JsonObject, *, phase: str) -> JsonObject:
|
|
2010
|
+
allowed = (
|
|
2011
|
+
"action",
|
|
2012
|
+
"target",
|
|
2013
|
+
"old_target",
|
|
2014
|
+
"new_target",
|
|
2015
|
+
"replacement",
|
|
2016
|
+
"receipt_code",
|
|
2017
|
+
"line",
|
|
2018
|
+
"term",
|
|
2019
|
+
"matched_text",
|
|
2020
|
+
"display_text",
|
|
2021
|
+
"start",
|
|
2022
|
+
"end",
|
|
2023
|
+
"source",
|
|
2024
|
+
"occurrence_id",
|
|
2025
|
+
"context_hash",
|
|
2026
|
+
"confidence",
|
|
2027
|
+
"reason_code",
|
|
2028
|
+
)
|
|
2029
|
+
clean: JsonObject = {"phase": phase}
|
|
2030
|
+
for key in allowed:
|
|
2031
|
+
value = action.get(key)
|
|
2032
|
+
if value not in (None, ""):
|
|
2033
|
+
clean[key] = value
|
|
2034
|
+
return _json_object(clean)
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
def _phase_file_actions(
|
|
2038
|
+
config: MedConfig,
|
|
2039
|
+
*,
|
|
2040
|
+
reference_apply: JsonObject | None,
|
|
2041
|
+
body_linker: JsonObject,
|
|
2042
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
2043
|
+
) -> dict[str, JsonObject]:
|
|
2044
|
+
by_path: dict[str, JsonObject] = {}
|
|
2045
|
+
|
|
2046
|
+
def entry(path_value: str) -> JsonObject:
|
|
2047
|
+
rel = _relative_receipt_path(config, path_value)
|
|
2048
|
+
return by_path.setdefault(rel, {"path": rel, "phases": [], "actions": [], "backup_paths": []})
|
|
2049
|
+
|
|
2050
|
+
reference_apply_view = _ReferenceApplyView.from_payload(reference_apply)
|
|
2051
|
+
for report in reference_apply_view.reports:
|
|
2052
|
+
report_payload = _json_object(report)
|
|
2053
|
+
report_path = _json_field(report_payload, "path")
|
|
2054
|
+
if not _json_field(report_payload, "changed") or not isinstance(report_path, str):
|
|
2055
|
+
continue
|
|
2056
|
+
item = entry(report_path)
|
|
2057
|
+
item["phases"].append("reference_repair")
|
|
2058
|
+
backup_path = _json_text(report_payload, "backup_path")
|
|
2059
|
+
if backup_path:
|
|
2060
|
+
item["backup_paths"].append(backup_path)
|
|
2061
|
+
actions = _json_field(report_payload, "actions")
|
|
2062
|
+
if isinstance(actions, list):
|
|
2063
|
+
item["actions"].extend(
|
|
2064
|
+
_safe_link_action(action, phase="reference_repair")
|
|
2065
|
+
for action in actions
|
|
2066
|
+
if isinstance(action, dict)
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
2070
|
+
for plan_view in body_view.plans:
|
|
2071
|
+
if not plan_view.changed or not plan_view.file:
|
|
2072
|
+
continue
|
|
2073
|
+
item = entry(plan_view.file)
|
|
2074
|
+
item["phases"].append("body_term_linker")
|
|
2075
|
+
for insertion in plan_view.insertions:
|
|
2076
|
+
if isinstance(insertion, dict):
|
|
2077
|
+
item["actions"].append(
|
|
2078
|
+
_json_object({
|
|
2079
|
+
"phase": "body_term_linker",
|
|
2080
|
+
"action": "insert_body_wikilink",
|
|
2081
|
+
**_safe_link_action(insertion, phase="body_term_linker"),
|
|
2082
|
+
})
|
|
2083
|
+
)
|
|
2084
|
+
for rewrite in plan_view.rewrites:
|
|
2085
|
+
if isinstance(rewrite, dict):
|
|
2086
|
+
item["actions"].append(
|
|
2087
|
+
_json_object({
|
|
2088
|
+
"phase": "body_term_linker",
|
|
2089
|
+
"action": "rewrite_body_wikilink",
|
|
2090
|
+
**_safe_link_action(rewrite, phase="body_term_linker"),
|
|
2091
|
+
})
|
|
2092
|
+
)
|
|
2093
|
+
|
|
2094
|
+
if related_notes is not None:
|
|
2095
|
+
for update_model in related_notes.updates:
|
|
2096
|
+
path_value = update_model.relative_path or update_model.path or update_model.file
|
|
2097
|
+
if not path_value:
|
|
2098
|
+
continue
|
|
2099
|
+
item = entry(path_value)
|
|
2100
|
+
item["phases"].append("related_notes_sync")
|
|
2101
|
+
if update_model.backup_path:
|
|
2102
|
+
item["backup_paths"].append(update_model.backup_path)
|
|
2103
|
+
item["actions"].append(
|
|
2104
|
+
_json_object({
|
|
2105
|
+
"phase": "related_notes_sync",
|
|
2106
|
+
"action": "rewrite_related_notes_section",
|
|
2107
|
+
"cleared_link_count": update_model.cleared_link_count,
|
|
2108
|
+
"proposed_link_count": len(update_model.proposed_links),
|
|
2109
|
+
})
|
|
2110
|
+
)
|
|
2111
|
+
for value in by_path.values():
|
|
2112
|
+
value["phases"] = sorted(set(value["phases"]))
|
|
2113
|
+
value["backup_paths"] = sorted(set(value["backup_paths"]))
|
|
2114
|
+
return by_path
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def _file_changes(
|
|
2118
|
+
*,
|
|
2119
|
+
config: MedConfig,
|
|
2120
|
+
before_snapshot: JsonObject,
|
|
2121
|
+
after_snapshot: JsonObject,
|
|
2122
|
+
reference_apply: JsonObject | None,
|
|
2123
|
+
body_linker: JsonObject,
|
|
2124
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
2125
|
+
) -> list[JsonObject]:
|
|
2126
|
+
before_hashes = _snapshot_note_hashes(before_snapshot)
|
|
2127
|
+
after_hashes = _snapshot_note_hashes(after_snapshot)
|
|
2128
|
+
action_map = _phase_file_actions(config, reference_apply=reference_apply, body_linker=body_linker, related_notes=related_notes)
|
|
2129
|
+
changed_paths = set(action_map)
|
|
2130
|
+
for raw_path in _changed_files_from(reference_apply, body_linker, _related_notes_payload(related_notes)):
|
|
2131
|
+
changed_paths.add(_relative_receipt_path(config, raw_path))
|
|
2132
|
+
changes: list[JsonObject] = []
|
|
2133
|
+
for rel in sorted(path for path in changed_paths if path):
|
|
2134
|
+
detail = action_map.get(rel, {"path": rel, "phases": [], "actions": [], "backup_paths": []})
|
|
2135
|
+
changes.append(
|
|
2136
|
+
_json_object({
|
|
2137
|
+
"path": rel,
|
|
2138
|
+
"phases": detail.get("phases", []),
|
|
2139
|
+
"before_hash": before_hashes.get(rel, ""),
|
|
2140
|
+
"after_hash": after_hashes.get(rel, ""),
|
|
2141
|
+
"actions": detail.get("actions", []),
|
|
2142
|
+
"backup_paths": detail.get("backup_paths", []),
|
|
2143
|
+
})
|
|
2144
|
+
)
|
|
2145
|
+
return changes
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
def _trigger_context_summary(diagnosis: JsonObject) -> JsonObject:
|
|
2149
|
+
context = _json_object_or_empty(_json_field(diagnosis, "trigger_context"))
|
|
2150
|
+
source = _json_field(context, "source_workflow", "manual")
|
|
2151
|
+
return _json_object({
|
|
2152
|
+
"source_workflow": source or "manual",
|
|
2153
|
+
"triggers_detected": _json_field(diagnosis, "triggers_detected", []),
|
|
2154
|
+
"affected_notes": _json_field(diagnosis, "affected_notes", []),
|
|
2155
|
+
})
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def _receipt_skips(diagnosis: JsonObject, related_notes: LinkRelatedSyncResult | None) -> list[JsonObject]:
|
|
2159
|
+
skips: list[JsonObject] = []
|
|
2160
|
+
phases = _json_field(diagnosis, "phases")
|
|
2161
|
+
for phase, details in phases.items() if isinstance(phases, dict) else []:
|
|
2162
|
+
if isinstance(details, dict) and details.get("status") == "skipped":
|
|
2163
|
+
skips.append(_json_object({"phase": phase, "reason": details.get("skipped_reason") or "skipped"}))
|
|
2164
|
+
if related_notes is not None and related_notes.skipped_reason:
|
|
2165
|
+
skips.append(_json_object({"phase": "related_notes_sync", "reason": related_notes.skipped_reason}))
|
|
2166
|
+
body_view = _BodyLinkerView.from_payload(_json_field(diagnosis, "body_term_linker"))
|
|
2167
|
+
for plan_view in body_view.plans:
|
|
2168
|
+
for item in plan_view.skipped:
|
|
2169
|
+
skip_view = _LinkPlanSkipView.from_payload(item)
|
|
2170
|
+
skips.append(
|
|
2171
|
+
_json_object({
|
|
2172
|
+
"phase": "contextual_alias_disambiguation",
|
|
2173
|
+
"path": _relative_receipt_path_from_diagnosis(diagnosis, plan_view.file),
|
|
2174
|
+
"occurrence_id": skip_view.occurrence_id,
|
|
2175
|
+
"reason": skip_view.reason_code or skip_view.action or "contextual_alias_skipped",
|
|
2176
|
+
"action": skip_view.action,
|
|
2177
|
+
})
|
|
2178
|
+
)
|
|
2179
|
+
return skips
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def _link_apply_safety_preflight_block(
|
|
2183
|
+
*,
|
|
2184
|
+
diagnosis_path: Path,
|
|
2185
|
+
diagnosis: JsonObject,
|
|
2186
|
+
version_control_guard_active: bool,
|
|
2187
|
+
vocabulary_repair_requested: bool,
|
|
2188
|
+
) -> JsonObject | None:
|
|
2189
|
+
"""Stop mutating apply routes after stale checks and before resource writes."""
|
|
2190
|
+
|
|
2191
|
+
if _diagnosis_has_mutating_guard_safety(diagnosis):
|
|
2192
|
+
return None
|
|
2193
|
+
planned_change_count = _diagnosis_planned_change_count(diagnosis)
|
|
2194
|
+
if planned_change_count <= 0 and not vocabulary_repair_requested:
|
|
2195
|
+
return None
|
|
2196
|
+
if version_control_guard_active:
|
|
2197
|
+
return None
|
|
2198
|
+
return _json_object({
|
|
2199
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2200
|
+
"phase": "link_apply_preflight",
|
|
2201
|
+
"status": "blocked",
|
|
2202
|
+
"blocked_reason": "version_control_safety_evidence_missing",
|
|
2203
|
+
"next_action": (
|
|
2204
|
+
"Abrir o ponto de restauração do vault pela rota oficial, repetir a conferência se o diagnóstico "
|
|
2205
|
+
"ficar obsoleto e só então aplicar."
|
|
2206
|
+
),
|
|
2207
|
+
"required_inputs": ["version_control_safety"],
|
|
2208
|
+
"human_decision_required": False,
|
|
2209
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2210
|
+
"planned_changed_file_count": planned_change_count,
|
|
2211
|
+
"vocabulary_repair_requested": vocabulary_repair_requested,
|
|
2212
|
+
"changed_files": [],
|
|
2213
|
+
"files_changed": 0,
|
|
2214
|
+
"returncode": 3,
|
|
2215
|
+
})
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
def _diagnosis_has_mutating_guard_safety(diagnosis: JsonObject) -> bool:
|
|
2219
|
+
"""Only real guard evidence can authorize a mutating apply attempt."""
|
|
2220
|
+
|
|
2221
|
+
payload = _diagnosis_version_control_safety_payload(diagnosis)
|
|
2222
|
+
if not payload:
|
|
2223
|
+
return False
|
|
2224
|
+
return bool(
|
|
2225
|
+
_json_field(payload, "resource_guard_active")
|
|
2226
|
+
and _json_field(payload, "run_start_seen")
|
|
2227
|
+
and _json_field(payload, "run_finish_seen")
|
|
2228
|
+
and not _json_field(payload, "no_resource_mutation")
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _diagnosis_version_control_safety_payload(diagnosis: JsonObject) -> JsonObject:
|
|
2233
|
+
direct = _json_object_or_empty(_json_field(diagnosis, "version_control_safety"))
|
|
2234
|
+
if direct:
|
|
2235
|
+
return direct
|
|
2236
|
+
receipt = _json_object_or_empty(_json_field(diagnosis, "receipt"))
|
|
2237
|
+
nested = _json_object_or_empty(_json_field(receipt, "version_control_safety"))
|
|
2238
|
+
if nested:
|
|
2239
|
+
return nested
|
|
2240
|
+
guard_receipt = _json_object_or_empty(_json_field(diagnosis, "guard_receipt"))
|
|
2241
|
+
return _json_object_or_empty(_json_field(guard_receipt, "version_control_safety"))
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _diagnosis_planned_change_count(diagnosis: JsonObject) -> int:
|
|
2245
|
+
changed_files = _json_field(diagnosis, "changed_files")
|
|
2246
|
+
return max(
|
|
2247
|
+
_strict_non_negative_int(_json_field(diagnosis, "files_changed")),
|
|
2248
|
+
len(changed_files) if isinstance(changed_files, list) else 0,
|
|
2249
|
+
_body_linker_planned_change_count(_json_object_or_empty(_json_field(diagnosis, "body_term_linker"))),
|
|
2250
|
+
_reference_repair_planned_change_count(_json_object_or_empty(_json_field(diagnosis, "reference_repair"))),
|
|
2251
|
+
_related_notes_planned_change_count(_json_object_or_empty(_json_field(diagnosis, "related_notes_sync"))),
|
|
2252
|
+
)
|
|
2253
|
+
|
|
2254
|
+
|
|
2255
|
+
def _body_linker_planned_change_count(body_linker: JsonObject) -> int:
|
|
2256
|
+
plans = _json_field(body_linker, "plans")
|
|
2257
|
+
if isinstance(plans, list):
|
|
2258
|
+
return sum(1 for plan in plans if _json_field(_json_object_or_empty(plan), "changed") is True)
|
|
2259
|
+
return max(
|
|
2260
|
+
_strict_non_negative_int(_json_field(body_linker, "files_changed")),
|
|
2261
|
+
_strict_non_negative_int(_json_field(body_linker, "links_planned")),
|
|
2262
|
+
_strict_non_negative_int(_json_field(body_linker, "links_rewritten")),
|
|
2263
|
+
)
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def _reference_repair_planned_change_count(reference_repair: JsonObject) -> int:
|
|
2267
|
+
return max(
|
|
2268
|
+
_strict_non_negative_int(_json_field(reference_repair, "changed_file_count")),
|
|
2269
|
+
_strict_non_negative_int(_json_field(reference_repair, "affected_note_count")),
|
|
2270
|
+
_strict_non_negative_int(_json_field(reference_repair, "action_count")),
|
|
2271
|
+
)
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _related_notes_planned_change_count(related_notes: JsonObject) -> int:
|
|
2275
|
+
updates = _json_field(related_notes, "updates")
|
|
2276
|
+
if isinstance(updates, list):
|
|
2277
|
+
return sum(1 for update in updates if _json_field(_json_object_or_empty(update), "changed") is True)
|
|
2278
|
+
return max(
|
|
2279
|
+
_strict_non_negative_int(_json_field(related_notes, "applied_note_count")),
|
|
2280
|
+
_strict_non_negative_int(_json_field(related_notes, "update_count")),
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
def _strict_non_negative_int(value: object) -> int:
|
|
2285
|
+
if isinstance(value, bool):
|
|
2286
|
+
return 0
|
|
2287
|
+
if isinstance(value, int) and value >= 0:
|
|
2288
|
+
return value
|
|
2289
|
+
return 0
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def _relative_receipt_path_from_diagnosis(diagnosis: JsonObject, value: str) -> str:
|
|
2293
|
+
if not value:
|
|
2294
|
+
return ""
|
|
2295
|
+
path = Path(value)
|
|
2296
|
+
wiki_dir = Path(_LinkDiagnosisView.from_payload(diagnosis).wiki_dir)
|
|
2297
|
+
if path.is_absolute() and str(wiki_dir):
|
|
2298
|
+
try:
|
|
2299
|
+
return path.resolve().relative_to(wiki_dir.resolve()).as_posix()
|
|
2300
|
+
except (OSError, ValueError):
|
|
2301
|
+
return path.as_posix()
|
|
2302
|
+
return path.as_posix()
|
|
2303
|
+
|
|
2304
|
+
|
|
2305
|
+
def _write_link_receipt(
|
|
2306
|
+
path: Path,
|
|
2307
|
+
*,
|
|
2308
|
+
config: MedConfig,
|
|
2309
|
+
diagnosis: JsonObject,
|
|
2310
|
+
snapshot_before: JsonObject,
|
|
2311
|
+
snapshot_after: JsonObject,
|
|
2312
|
+
git_before: JsonObject,
|
|
2313
|
+
git_after: JsonObject,
|
|
2314
|
+
body_linker: JsonObject,
|
|
2315
|
+
related_notes: LinkRelatedSyncResult | None,
|
|
2316
|
+
reference_apply: JsonObject | None,
|
|
2317
|
+
graph_after: JsonObject,
|
|
2318
|
+
) -> JsonObject:
|
|
2319
|
+
diagnosis_view = _LinkDiagnosisView.from_payload(diagnosis)
|
|
2320
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
2321
|
+
graph_view = _GraphAuditView.from_payload(graph_after)
|
|
2322
|
+
git_before_view = _GitContextView.from_payload(git_before)
|
|
2323
|
+
git_after_view = _GitContextView.from_payload(git_after)
|
|
2324
|
+
related_notes_payload = _related_notes_payload(related_notes)
|
|
2325
|
+
changed_files = _changed_files_from(reference_apply, body_linker, related_notes_payload)
|
|
2326
|
+
file_changes = _file_changes(
|
|
2327
|
+
config=config,
|
|
2328
|
+
before_snapshot=snapshot_before,
|
|
2329
|
+
after_snapshot=snapshot_after,
|
|
2330
|
+
reference_apply=reference_apply,
|
|
2331
|
+
body_linker=body_linker,
|
|
2332
|
+
related_notes=related_notes,
|
|
2333
|
+
)
|
|
2334
|
+
reference_apply_view = _ReferenceApplyView.from_payload(reference_apply)
|
|
2335
|
+
reference_repair_payload = diagnosis_view.reference_repair.to_payload()
|
|
2336
|
+
contextual = body_view.contextual_alias_disambiguation
|
|
2337
|
+
phase_receipts = _json_object({
|
|
2338
|
+
"reference_repair": {
|
|
2339
|
+
"status": reference_apply_view.status or diagnosis_view.reference_repair.status or "skipped",
|
|
2340
|
+
"affected_note_count": diagnosis_view.reference_repair.affected_note_count,
|
|
2341
|
+
"action_count": diagnosis_view.reference_repair.action_count,
|
|
2342
|
+
"blocking_action_count": diagnosis_view.reference_repair.blocking_action_count,
|
|
2343
|
+
"human_decision_count": diagnosis_view.reference_repair.human_decision_count,
|
|
2344
|
+
"triage_count": diagnosis_view.reference_repair.triage_count,
|
|
2345
|
+
"changed_file_count": reference_apply_view.changed_file_count,
|
|
2346
|
+
},
|
|
2347
|
+
"contextual_alias_disambiguation": {
|
|
2348
|
+
"status": contextual.status,
|
|
2349
|
+
"mode": contextual.mode,
|
|
2350
|
+
"candidate_count": contextual.candidate_count,
|
|
2351
|
+
"decision_count": contextual.decision_count,
|
|
2352
|
+
"linked_count": contextual.linked_count,
|
|
2353
|
+
"deferred_count": contextual.deferred_count,
|
|
2354
|
+
"no_link_count": contextual.no_link_count,
|
|
2355
|
+
"rejected_count": contextual.rejected_count,
|
|
2356
|
+
"skipped_reason": contextual.skipped_reason,
|
|
2357
|
+
"blocked_reason": contextual.blocked_reason,
|
|
2358
|
+
},
|
|
2359
|
+
"body_term_linker": {
|
|
2360
|
+
"status": "completed" if body_view.returncode == 0 else "blocked",
|
|
2361
|
+
"links_planned": body_view.links_planned,
|
|
2362
|
+
"links_rewritten": body_view.links_rewritten,
|
|
2363
|
+
},
|
|
2364
|
+
"related_notes_sync": {
|
|
2365
|
+
"status": related_notes.status if related_notes is not None else "skipped",
|
|
2366
|
+
"applied_note_count": related_notes.applied_note_count if related_notes is not None else 0,
|
|
2367
|
+
"skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
|
|
2368
|
+
},
|
|
2369
|
+
"graph_validation": {
|
|
2370
|
+
"status": "completed" if not graph_view.error_count else "blocked",
|
|
2371
|
+
"error_count": graph_view.error_count,
|
|
2372
|
+
"warning_count": graph_view.warning_count,
|
|
2373
|
+
},
|
|
2374
|
+
})
|
|
2375
|
+
body_or_graph_blocked = bool(
|
|
2376
|
+
body_view.blocker_count or graph_view.error_count
|
|
2377
|
+
)
|
|
2378
|
+
related_notes_required = _related_notes_required_for_apply(diagnosis)
|
|
2379
|
+
related_notes_blocked = _related_notes_apply_blocked(related_notes, required=related_notes_required)
|
|
2380
|
+
blocked = bool(body_or_graph_blocked or related_notes_blocked)
|
|
2381
|
+
blocked_reason = _link_apply_blocked_reason(
|
|
2382
|
+
body_or_graph_blocked=body_or_graph_blocked,
|
|
2383
|
+
related_notes_blocked=related_notes_blocked,
|
|
2384
|
+
)
|
|
2385
|
+
# The apply receipt carries the same guard evidence that authorized the
|
|
2386
|
+
# diagnosis, preserving a single audit trail for mutating linker work.
|
|
2387
|
+
version_control_safety = _diagnosis_version_control_safety_payload(diagnosis)
|
|
2388
|
+
receipt = _json_object({
|
|
2389
|
+
"schema": LINK_RUN_RECEIPT_SCHEMA,
|
|
2390
|
+
"generated_at": _now_iso(),
|
|
2391
|
+
"phase": "link_apply",
|
|
2392
|
+
"status": "completed_with_link_blockers" if blocked else "completed",
|
|
2393
|
+
"blocked_reason": blocked_reason,
|
|
2394
|
+
"next_action": _link_apply_next_action(blocked_reason=blocked_reason, related_notes=related_notes),
|
|
2395
|
+
"required_inputs": [*LINK_REQUIRED_INPUTS, "diagnosis"],
|
|
2396
|
+
"human_decision_required": False,
|
|
2397
|
+
"receipt_path": str(path),
|
|
2398
|
+
"wiki_dir": str(config.wiki_dir),
|
|
2399
|
+
"catalog_path": str(config.catalog_path) if config.catalog_path else None,
|
|
2400
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
2401
|
+
"vocabulary_bootstrap": _json_field(diagnosis, "vocabulary_bootstrap", {}),
|
|
2402
|
+
"diagnosis_path": diagnosis_view.diagnosis_path,
|
|
2403
|
+
"diagnosis_hash": "sha256:" + canonical_json_hash(diagnosis),
|
|
2404
|
+
"plan_hash": _json_field(diagnosis, "plan_hash", ""),
|
|
2405
|
+
"snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
|
|
2406
|
+
"trigger_context_summary": _trigger_context_summary(diagnosis),
|
|
2407
|
+
"git": {
|
|
2408
|
+
"available": git_before_view.available,
|
|
2409
|
+
"repo_root": git_before_view.repo_root,
|
|
2410
|
+
"branch": git_before_view.branch,
|
|
2411
|
+
"head_before": git_before_view.head,
|
|
2412
|
+
"head_after": git_after_view.head,
|
|
2413
|
+
"status_hash_before": git_before_view.status_hash,
|
|
2414
|
+
"status_hash_after": git_after_view.status_hash,
|
|
2415
|
+
"changed_paths_before": git_before_view.changed_paths,
|
|
2416
|
+
"changed_paths_after": git_after_view.changed_paths,
|
|
2417
|
+
"unavailable_reason": git_before_view.unavailable_reason,
|
|
2418
|
+
},
|
|
2419
|
+
"snapshots": {
|
|
2420
|
+
"diagnosis_snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
|
|
2421
|
+
"before_hash": snapshot_before.get("snapshot_hash", ""),
|
|
2422
|
+
"after_hash": snapshot_after.get("snapshot_hash", ""),
|
|
2423
|
+
"note_count_before": int(snapshot_before.get("note_count", 0) or 0),
|
|
2424
|
+
"note_count_after": int(snapshot_after.get("note_count", 0) or 0),
|
|
2425
|
+
},
|
|
2426
|
+
"phases": phase_receipts,
|
|
2427
|
+
"phase_receipts": phase_receipts,
|
|
2428
|
+
"changed_files": changed_files,
|
|
2429
|
+
"files_changed": len(changed_files),
|
|
2430
|
+
"file_changes": file_changes,
|
|
2431
|
+
"version_control_safety": version_control_safety,
|
|
2432
|
+
"protected_zone_checks": {
|
|
2433
|
+
"status": "completed",
|
|
2434
|
+
"strategy": "Fases de apply usam spans protegidos para YAML, headings, code, tabelas, footer e seção Notas Relacionadas quando aplicável.",
|
|
2435
|
+
},
|
|
2436
|
+
"blockers": _json_field(diagnosis, "blockers", []),
|
|
2437
|
+
"skips": _receipt_skips(diagnosis, related_notes),
|
|
2438
|
+
"rollback": {
|
|
2439
|
+
"type": "git" if git_before.get("available") else "backup",
|
|
2440
|
+
"details": "Use os pontos de restauração/version control do vault para rollback.",
|
|
2441
|
+
},
|
|
2442
|
+
"reference_repair": reference_repair_payload,
|
|
2443
|
+
"reference_repair_apply": reference_apply,
|
|
2444
|
+
"body_term_linker": body_linker,
|
|
2445
|
+
"related_notes_sync": related_notes_payload,
|
|
2446
|
+
"graph_audit_after": graph_after,
|
|
2447
|
+
})
|
|
2448
|
+
atomic_write_text(path, json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
|
|
2449
|
+
return _json_object(receipt)
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
def apply_link_diagnosis(
|
|
2453
|
+
config: MedConfig,
|
|
2454
|
+
*,
|
|
2455
|
+
diagnosis_path: Path,
|
|
2456
|
+
receipt_path: Path | None = None,
|
|
2457
|
+
include_related_notes: bool = True,
|
|
2458
|
+
backup: bool = False,
|
|
2459
|
+
version_control_guard_active: bool = False,
|
|
2460
|
+
) -> JsonObject:
|
|
2461
|
+
diagnosis = _load_diagnosis(diagnosis_path)
|
|
2462
|
+
diagnosis_view = _LinkDiagnosisView.from_payload(diagnosis)
|
|
2463
|
+
if receipt_path is not None and receipt_path.exists():
|
|
2464
|
+
return _json_object({
|
|
2465
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2466
|
+
"phase": "link_apply_preflight",
|
|
2467
|
+
"status": "blocked",
|
|
2468
|
+
"blocked_reason": "receipt_path_exists",
|
|
2469
|
+
"next_action": "Escolha um novo --receipt para preservar a evidência da tentativa anterior.",
|
|
2470
|
+
"required_inputs": ["receipt"],
|
|
2471
|
+
"human_decision_required": False,
|
|
2472
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2473
|
+
"receipt_path": str(receipt_path),
|
|
2474
|
+
"returncode": 3,
|
|
2475
|
+
})
|
|
2476
|
+
current_snapshot = _collect_snapshot(config)
|
|
2477
|
+
current_git = _git_context_for(config)
|
|
2478
|
+
current_git_payload = current_git.to_payload()
|
|
2479
|
+
current_git_view = _GitContextView.from_payload(current_git_payload)
|
|
2480
|
+
expected_db = diagnosis_view.vocabulary_db_path
|
|
2481
|
+
actual_db = str(config.vocabulary_db_path) if config.vocabulary_db_path else ""
|
|
2482
|
+
if expected_db != actual_db:
|
|
2483
|
+
return _json_object({
|
|
2484
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2485
|
+
"phase": "link_apply_preflight",
|
|
2486
|
+
"status": "blocked",
|
|
2487
|
+
"blocked_reason": "vocabulary_db_mismatch",
|
|
2488
|
+
"next_action": "Rodar run-linker --diagnose novamente usando o mesmo vocabulary DB do apply.",
|
|
2489
|
+
"required_inputs": ["diagnosis", "vocabulary_db"],
|
|
2490
|
+
"human_decision_required": False,
|
|
2491
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2492
|
+
"expected_vocabulary_db_path": expected_db,
|
|
2493
|
+
"actual_vocabulary_db_path": actual_db,
|
|
2494
|
+
"returncode": 3,
|
|
2495
|
+
})
|
|
2496
|
+
if current_snapshot["snapshot_hash"] != _json_field(diagnosis, "snapshot_hash"):
|
|
2497
|
+
return _json_object({
|
|
2498
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2499
|
+
"phase": "link_apply_preflight",
|
|
2500
|
+
"status": "blocked",
|
|
2501
|
+
"blocked_reason": "stale_diagnosis",
|
|
2502
|
+
"next_action": "Rodar run-linker --diagnose novamente; a Wiki mudou desde o diagnóstico.",
|
|
2503
|
+
"required_inputs": ["diagnosis"],
|
|
2504
|
+
"human_decision_required": False,
|
|
2505
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2506
|
+
"expected_snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
|
|
2507
|
+
"actual_snapshot_hash": current_snapshot["snapshot_hash"],
|
|
2508
|
+
"returncode": 3,
|
|
2509
|
+
})
|
|
2510
|
+
expected_git = diagnosis_view.git
|
|
2511
|
+
if expected_git.available and expected_git.status_hash:
|
|
2512
|
+
if current_git_view.status_hash != expected_git.status_hash:
|
|
2513
|
+
return _json_object({
|
|
2514
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2515
|
+
"phase": "link_apply_preflight",
|
|
2516
|
+
"status": "blocked",
|
|
2517
|
+
"blocked_reason": "stale_diagnosis",
|
|
2518
|
+
"stale_reason": "git_status_changed",
|
|
2519
|
+
"next_action": "Rodar run-linker --diagnose novamente; o estado Git da Wiki mudou desde o diagnóstico.",
|
|
2520
|
+
"required_inputs": ["diagnosis"],
|
|
2521
|
+
"human_decision_required": False,
|
|
2522
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2523
|
+
"expected_git_status_hash": expected_git.status_hash,
|
|
2524
|
+
"actual_git_status_hash": current_git_view.status_hash,
|
|
2525
|
+
"expected_git_head": expected_git.head,
|
|
2526
|
+
"actual_git_head": current_git_view.head,
|
|
2527
|
+
"returncode": 3,
|
|
2528
|
+
})
|
|
2529
|
+
if diagnosis_view.status != "diagnosis_ready" or diagnosis_view.blocker_count:
|
|
2530
|
+
if _diagnosis_requests_vocabulary_repair(diagnosis):
|
|
2531
|
+
safety_block = _link_apply_safety_preflight_block(
|
|
2532
|
+
diagnosis_path=diagnosis_path,
|
|
2533
|
+
diagnosis=diagnosis,
|
|
2534
|
+
version_control_guard_active=version_control_guard_active,
|
|
2535
|
+
vocabulary_repair_requested=True,
|
|
2536
|
+
)
|
|
2537
|
+
if safety_block is not None:
|
|
2538
|
+
return safety_block
|
|
2539
|
+
vocabulary_repair = repair_vocabulary_semantics_for_link(
|
|
2540
|
+
config,
|
|
2541
|
+
run_dir=diagnosis_path.parent,
|
|
2542
|
+
trigger="run_linker_apply",
|
|
2543
|
+
)
|
|
2544
|
+
try:
|
|
2545
|
+
vocabulary_repair_view = _VocabularySemanticRepairView.from_payload(vocabulary_repair)
|
|
2546
|
+
except (PydanticValidationError, ValueError) as exc:
|
|
2547
|
+
return _link_diagnosis_contract_invalid_payload(
|
|
2548
|
+
diagnosis_path=diagnosis_path,
|
|
2549
|
+
detail=str(exc),
|
|
2550
|
+
source_payload=vocabulary_repair,
|
|
2551
|
+
extra=_json_object({"contract": "vocabulary_semantic_repair"}),
|
|
2552
|
+
)
|
|
2553
|
+
if vocabulary_repair_view.status == "completed":
|
|
2554
|
+
refreshed = diagnose_links(
|
|
2555
|
+
config,
|
|
2556
|
+
diagnosis_path=diagnosis_path,
|
|
2557
|
+
include_related_notes=include_related_notes,
|
|
2558
|
+
force_diagnose=True,
|
|
2559
|
+
trigger_context=_json_object(_json_field(diagnosis, "trigger_context"))
|
|
2560
|
+
if isinstance(_json_field(diagnosis, "trigger_context"), dict)
|
|
2561
|
+
else None,
|
|
2562
|
+
)
|
|
2563
|
+
refreshed_view = _LinkDiagnosisView.from_payload(refreshed)
|
|
2564
|
+
if refreshed_view.status == "diagnosis_ready" and not refreshed_view.blocker_count:
|
|
2565
|
+
applied = apply_link_diagnosis(
|
|
2566
|
+
config,
|
|
2567
|
+
diagnosis_path=diagnosis_path,
|
|
2568
|
+
receipt_path=receipt_path,
|
|
2569
|
+
include_related_notes=include_related_notes,
|
|
2570
|
+
backup=backup,
|
|
2571
|
+
version_control_guard_active=version_control_guard_active,
|
|
2572
|
+
)
|
|
2573
|
+
return _json_object({
|
|
2574
|
+
**applied,
|
|
2575
|
+
"vocabulary_semantic_repair": vocabulary_repair,
|
|
2576
|
+
"vocabulary_repaired_diagnosis": refreshed,
|
|
2577
|
+
})
|
|
2578
|
+
return _json_object({
|
|
2579
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2580
|
+
"phase": "link_apply_preflight",
|
|
2581
|
+
"status": "blocked",
|
|
2582
|
+
"blocked_reason": refreshed_view.blocked_reason or "diagnosis_blocked_after_vocabulary_repair",
|
|
2583
|
+
"next_action": refreshed_view.next_action or "Resolver blockers restantes do diagnóstico.",
|
|
2584
|
+
"required_inputs": ["diagnosis"],
|
|
2585
|
+
"human_decision_required": refreshed_view.human_decision_required,
|
|
2586
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2587
|
+
"vocabulary_semantic_repair": vocabulary_repair,
|
|
2588
|
+
"refreshed_diagnosis": refreshed,
|
|
2589
|
+
"returncode": 3,
|
|
2590
|
+
})
|
|
2591
|
+
return _json_object({
|
|
2592
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2593
|
+
"phase": "link_apply_preflight",
|
|
2594
|
+
"status": "blocked",
|
|
2595
|
+
"blocked_reason": vocabulary_repair_view.blocked_reason or "vocabulary_semantic_repair_blocked",
|
|
2596
|
+
"next_action": vocabulary_repair_view.next_action or "Resolver vocabulary DB pelo workflow /mednotes:link.",
|
|
2597
|
+
"required_inputs": ["vocabulary_semantic_repair"],
|
|
2598
|
+
"human_decision_required": vocabulary_repair_view.human_decision_required,
|
|
2599
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2600
|
+
"vocabulary_semantic_repair": vocabulary_repair,
|
|
2601
|
+
"returncode": 3,
|
|
2602
|
+
})
|
|
2603
|
+
return _json_object({
|
|
2604
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2605
|
+
"phase": "link_apply_preflight",
|
|
2606
|
+
"status": "blocked",
|
|
2607
|
+
"blocked_reason": _json_field(diagnosis, "blocked_reason") or "diagnosis_blocked",
|
|
2608
|
+
"next_action": _json_field(diagnosis, "next_action") or "Resolver blockers do diagnóstico antes de aplicar.",
|
|
2609
|
+
"required_inputs": ["diagnosis"],
|
|
2610
|
+
"human_decision_required": bool(_json_field(diagnosis, "human_decision_required")),
|
|
2611
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2612
|
+
"blocker_count": _json_int(diagnosis, "blocker_count"),
|
|
2613
|
+
"blockers": _json_field(diagnosis, "blockers", []),
|
|
2614
|
+
"reference_repair": _json_field(diagnosis, "reference_repair", {}),
|
|
2615
|
+
"human_decision_packets": _json_field(diagnosis, "human_decision_packets", []),
|
|
2616
|
+
"returncode": 3,
|
|
2617
|
+
})
|
|
2618
|
+
|
|
2619
|
+
safety_block = _link_apply_safety_preflight_block(
|
|
2620
|
+
diagnosis_path=diagnosis_path,
|
|
2621
|
+
diagnosis=diagnosis,
|
|
2622
|
+
version_control_guard_active=version_control_guard_active,
|
|
2623
|
+
vocabulary_repair_requested=False,
|
|
2624
|
+
)
|
|
2625
|
+
if safety_block is not None:
|
|
2626
|
+
return safety_block
|
|
2627
|
+
|
|
2628
|
+
reference_repair_plan = _json_object_or_empty(_json_field(diagnosis, "reference_repair"))
|
|
2629
|
+
reference_apply = _json_object(apply_reference_repair_plan(config.wiki_dir, reference_repair_plan))
|
|
2630
|
+
diagnosis_body_linker = _json_object_or_empty(_json_field(diagnosis, "body_term_linker"))
|
|
2631
|
+
diagnosis_body_view = _BodyLinkerView.from_payload(diagnosis_body_linker)
|
|
2632
|
+
if diagnosis_body_view.body_linker_mode == "vocabulary_db":
|
|
2633
|
+
body_linker = _json_object(apply_body_linker_plan(
|
|
2634
|
+
wiki_dir=config.wiki_dir,
|
|
2635
|
+
body_linker_payload=diagnosis_body_linker,
|
|
2636
|
+
))
|
|
2637
|
+
else:
|
|
2638
|
+
body_linker = _run_body_linker(config, dry_run=False)
|
|
2639
|
+
related_notes = (
|
|
2640
|
+
_converge_related_notes_sync(config, backup=backup)
|
|
2641
|
+
if include_related_notes
|
|
2642
|
+
else None
|
|
2643
|
+
)
|
|
2644
|
+
related_notes_payload = _related_notes_payload(related_notes)
|
|
2645
|
+
graph_after = graph_audit(config)
|
|
2646
|
+
snapshot_after = _collect_snapshot(config)
|
|
2647
|
+
git_after = _git_context_for(config)
|
|
2648
|
+
git_after_payload = git_after.to_payload()
|
|
2649
|
+
actual_receipt_path = receipt_path or default_link_receipt_path()
|
|
2650
|
+
receipt = _write_link_receipt(
|
|
2651
|
+
actual_receipt_path,
|
|
2652
|
+
config=config,
|
|
2653
|
+
diagnosis=diagnosis,
|
|
2654
|
+
snapshot_before=current_snapshot,
|
|
2655
|
+
snapshot_after=snapshot_after,
|
|
2656
|
+
git_before=current_git_payload,
|
|
2657
|
+
git_after=git_after_payload,
|
|
2658
|
+
body_linker=body_linker,
|
|
2659
|
+
related_notes=related_notes,
|
|
2660
|
+
reference_apply=reference_apply,
|
|
2661
|
+
graph_after=graph_after,
|
|
2662
|
+
)
|
|
2663
|
+
write_link_state(
|
|
2664
|
+
snapshot_hash=_json_text(snapshot_after, "snapshot_hash"),
|
|
2665
|
+
git_context=git_after,
|
|
2666
|
+
receipt_path=actual_receipt_path,
|
|
2667
|
+
)
|
|
2668
|
+
body_view = _BodyLinkerView.from_payload(body_linker)
|
|
2669
|
+
graph_view = _GraphAuditView.from_payload(graph_after)
|
|
2670
|
+
body_or_graph_blocked = bool(
|
|
2671
|
+
body_view.blocker_count or graph_view.error_count
|
|
2672
|
+
)
|
|
2673
|
+
related_notes_required = _related_notes_required_for_apply(diagnosis)
|
|
2674
|
+
related_notes_blocked = _related_notes_apply_blocked(related_notes, required=related_notes_required)
|
|
2675
|
+
blocked = bool(body_or_graph_blocked or related_notes_blocked)
|
|
2676
|
+
blocked_reason = _link_apply_blocked_reason(
|
|
2677
|
+
body_or_graph_blocked=body_or_graph_blocked,
|
|
2678
|
+
related_notes_blocked=related_notes_blocked,
|
|
2679
|
+
)
|
|
2680
|
+
return _json_object({
|
|
2681
|
+
"schema": LINK_RUN_SCHEMA,
|
|
2682
|
+
"phase": "link_apply",
|
|
2683
|
+
"status": "completed_with_link_blockers" if blocked else "completed",
|
|
2684
|
+
"blocked_reason": blocked_reason,
|
|
2685
|
+
"next_action": _link_apply_next_action(blocked_reason=blocked_reason, related_notes=related_notes),
|
|
2686
|
+
"required_inputs": [*LINK_REQUIRED_INPUTS, "diagnosis"],
|
|
2687
|
+
"human_decision_required": False,
|
|
2688
|
+
"wiki_dir": str(config.wiki_dir),
|
|
2689
|
+
"catalog_path": str(config.catalog_path) if config.catalog_path else None,
|
|
2690
|
+
"vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
|
|
2691
|
+
"diagnosis_path": str(diagnosis_path),
|
|
2692
|
+
"receipt_path": str(actual_receipt_path),
|
|
2693
|
+
"plan_hash": _json_field(diagnosis, "plan_hash", ""),
|
|
2694
|
+
"snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
|
|
2695
|
+
"phases": receipt["phases"],
|
|
2696
|
+
"changed_files": receipt["changed_files"],
|
|
2697
|
+
"files_changed": len(receipt["changed_files"]),
|
|
2698
|
+
"version_control_safety": _json_object_or_empty(_json_field(receipt, "version_control_safety")),
|
|
2699
|
+
"body_term_linker": body_linker,
|
|
2700
|
+
"reference_repair_apply": reference_apply,
|
|
2701
|
+
"related_notes_sync": related_notes_payload,
|
|
2702
|
+
"related_notes_recovery_state": _related_notes_recovery_payload(related_notes),
|
|
2703
|
+
"related_notes_applied": bool(related_notes.applied_note_count) if related_notes is not None else False,
|
|
2704
|
+
"related_notes_skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
|
|
2705
|
+
"graph_audit_after": graph_after,
|
|
2706
|
+
"blocker_count": body_view.blocker_count,
|
|
2707
|
+
"returncode": 3 if blocked else body_view.returncode,
|
|
2708
|
+
})
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
def run_linker(
|
|
2712
|
+
config: MedConfig,
|
|
2713
|
+
*,
|
|
2714
|
+
diagnose: bool = False,
|
|
2715
|
+
apply: bool = False,
|
|
2716
|
+
diagnosis_path: Path | None = None,
|
|
2717
|
+
receipt_path: Path | None = None,
|
|
2718
|
+
include_related_notes: bool = True,
|
|
2719
|
+
backup: bool = False,
|
|
2720
|
+
force_diagnose: bool = False,
|
|
2721
|
+
trigger_context_path: Path | None = None,
|
|
2722
|
+
llm_disambiguation: str = "auto",
|
|
2723
|
+
llm_model: str | None = None,
|
|
2724
|
+
llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
|
|
2725
|
+
llm_disambiguator: Callable[..., object] | None = None,
|
|
2726
|
+
version_control_guard_active: bool = False,
|
|
2727
|
+
) -> JsonObject:
|
|
2728
|
+
if diagnose == apply:
|
|
2729
|
+
raise ValidationError("run-linker requires exactly one of diagnose=True or apply=True.")
|
|
2730
|
+
if apply and trigger_context_path is not None:
|
|
2731
|
+
raise ValidationError("run-linker --apply does not accept --trigger-context; pass the saved --diagnosis only.")
|
|
2732
|
+
if diagnose:
|
|
2733
|
+
trigger_context = load_trigger_context(trigger_context_path)
|
|
2734
|
+
return diagnose_links(
|
|
2735
|
+
config,
|
|
2736
|
+
diagnosis_path=diagnosis_path,
|
|
2737
|
+
include_related_notes=include_related_notes,
|
|
2738
|
+
force_diagnose=force_diagnose,
|
|
2739
|
+
trigger_context=trigger_context,
|
|
2740
|
+
llm_disambiguation=llm_disambiguation,
|
|
2741
|
+
llm_model=llm_model,
|
|
2742
|
+
llm_timeout=llm_timeout,
|
|
2743
|
+
llm_disambiguator=llm_disambiguator,
|
|
2744
|
+
)
|
|
2745
|
+
if diagnosis_path is None:
|
|
2746
|
+
raise ValidationError("run-linker --apply requires --diagnosis <link-diagnosis.json>.")
|
|
2747
|
+
return apply_link_diagnosis(
|
|
2748
|
+
config,
|
|
2749
|
+
diagnosis_path=diagnosis_path,
|
|
2750
|
+
receipt_path=receipt_path,
|
|
2751
|
+
include_related_notes=include_related_notes,
|
|
2752
|
+
backup=backup,
|
|
2753
|
+
version_control_guard_active=version_control_guard_active,
|
|
2754
|
+
)
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def graph_audit(config: MedConfig) -> JsonObject:
|
|
2758
|
+
return _json_object(wiki_graph.audit_wiki_graph(config.wiki_dir, catalog_path=config.catalog_path))
|