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
package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py
ADDED
|
@@ -0,0 +1,1920 @@
|
|
|
1
|
+
"""Related Notes plugin export adapter for Wiki_Medicina."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from typing import Literal, Protocol
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, StrictBool, StrictStr
|
|
15
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
16
|
+
|
|
17
|
+
from mednotes.domains.wiki.batch_state import canonical_json_hash, file_sha256
|
|
18
|
+
from mednotes.domains.wiki.capabilities.graph.graph import NO_STRONG_LINKS_MARKER
|
|
19
|
+
from mednotes.domains.wiki.capabilities.notes.note_iter import iter_notes
|
|
20
|
+
from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
|
|
21
|
+
from mednotes.domains.wiki.capabilities.related_notes.related_notes_headless import (
|
|
22
|
+
EmbeddingClient,
|
|
23
|
+
HeadlessRelatedNotesExportError,
|
|
24
|
+
generate_headless_related_notes_export,
|
|
25
|
+
headless_plugin_settings_available,
|
|
26
|
+
normalize_related_notes_profile_id,
|
|
27
|
+
related_notes_content_hash,
|
|
28
|
+
related_notes_legacy_clean_v1_content_hash,
|
|
29
|
+
)
|
|
30
|
+
from mednotes.domains.wiki.capabilities.vocabulary.link_terms import is_index_note as _is_index_note
|
|
31
|
+
from mednotes.domains.wiki.capabilities.vocabulary.link_terms import (
|
|
32
|
+
is_index_target,
|
|
33
|
+
normalize_key,
|
|
34
|
+
obsidian_target_name,
|
|
35
|
+
)
|
|
36
|
+
from mednotes.domains.wiki.common import _now_iso, wiki_cli_command
|
|
37
|
+
from mednotes.domains.wiki.config import MedConfig
|
|
38
|
+
from mednotes.domains.wiki.contracts.related_notes import RelatedNotesExport, RelatedNotesHeadlessExportSummary
|
|
39
|
+
from mednotes.domains.wiki.contracts.related_notes_runtime import LinkRelatedSyncResult
|
|
40
|
+
from mednotes.domains.wiki.flows.link.related_notes_fsm import (
|
|
41
|
+
build_related_notes_recovery_projection,
|
|
42
|
+
link_related_fsm_payload_from_sync_result,
|
|
43
|
+
)
|
|
44
|
+
from mednotes.domains.wiki.performance import cooperative_cpu_yield
|
|
45
|
+
from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
|
|
46
|
+
from mednotes.platform.paths import user_state_dir
|
|
47
|
+
|
|
48
|
+
RELATED_NOTES_EXPORT_SCHEMA = "medical-notes-workbench.related-notes-export.v1"
|
|
49
|
+
RELATED_NOTES_SYNC_SCHEMA = "medical-notes-workbench.related-notes-sync.v1"
|
|
50
|
+
RELATED_NOTES_SYNC_RECEIPT_SCHEMA = "medical-notes-workbench.related-notes-sync-receipt.v1"
|
|
51
|
+
RELATED_NOTES_EXPORT_RECOVERY_SCHEMA = "medical-notes-workbench.related-notes-export-recovery.v1"
|
|
52
|
+
RELATED_NOTES_SAFETY_CLEANUP_SCHEMA = "medical-notes-workbench.related-notes-safety-cleanup.v1"
|
|
53
|
+
RELATED_NOTES_RESUMABLE_BLOCKERS = {
|
|
54
|
+
"related_notes_headless_quota_exhausted",
|
|
55
|
+
"related_notes_headless_time_budget_exhausted",
|
|
56
|
+
}
|
|
57
|
+
DEFAULT_RELATED_NOTES_EXPORT = ".obsidian/plugins/related-notes-obsidian/medical-notes-export.json"
|
|
58
|
+
DEFAULT_MIN_SCORE = 0.78
|
|
59
|
+
DEFAULT_MAX_LINKS = 10
|
|
60
|
+
RELATED_NOTES_REQUIRED_INPUTS = ["wiki_dir", "related_notes_export"]
|
|
61
|
+
RELATED_NOTES_PLUGIN_ID = "related-notes-obsidian"
|
|
62
|
+
RELATED_NOTES_COMMANDS = {
|
|
63
|
+
"reindex_vault": "related-notes-obsidian:reindex-vault",
|
|
64
|
+
"index_missing_notes": "related-notes-obsidian:index-missing-notes",
|
|
65
|
+
"export_only_diagnostic": "related-notes-obsidian:export-workbench-related-notes",
|
|
66
|
+
}
|
|
67
|
+
OBSIDIAN_PROBE_TIMEOUT_SECONDS = 30
|
|
68
|
+
OBSIDIAN_COMMAND_TIMEOUT_SECONDS = 120
|
|
69
|
+
OBSIDIAN_TIMEOUT_RETURNCODE = 124
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _RelatedNotesExportCacheKey:
|
|
74
|
+
export_path: str
|
|
75
|
+
wiki_dir: str
|
|
76
|
+
mtime_ns: int
|
|
77
|
+
size: int
|
|
78
|
+
max_age_hours: float
|
|
79
|
+
allow_stale_note_hashes: bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_RELATED_NOTES_EXPORT_CACHE: dict[_RelatedNotesExportCacheKey, _RelatedNotesExportValidation] = {}
|
|
83
|
+
_RELATED_NOTES_EXPORT_CACHE_MAX_ENTRIES = 8
|
|
84
|
+
|
|
85
|
+
_RELATED_HEADING_RE = re.compile(r"(?m)^##\s+(?:🔗\s+)?Notas Relacionadas\s*$")
|
|
86
|
+
_NEXT_H2_RE = re.compile(r"(?m)^##\s+")
|
|
87
|
+
_FOOTER_RE = re.compile(r"(?m)^---\s*$")
|
|
88
|
+
_WIKILINK_RE = re.compile(r"(?<!!)\[\[([^\]]+)\]\]")
|
|
89
|
+
_WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
90
|
+
_FORBIDDEN_EXPORT_KEYS = {
|
|
91
|
+
"apikey",
|
|
92
|
+
"geminiapikey",
|
|
93
|
+
"token",
|
|
94
|
+
"secret",
|
|
95
|
+
"password",
|
|
96
|
+
"content",
|
|
97
|
+
"markdown",
|
|
98
|
+
"rawmarkdown",
|
|
99
|
+
"body",
|
|
100
|
+
"vector",
|
|
101
|
+
"preview",
|
|
102
|
+
"embedding",
|
|
103
|
+
"embeddings",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _ObsidianCommandRunner(Protocol):
|
|
108
|
+
"""Boundary protocol for the Obsidian CLI runner used by recovery."""
|
|
109
|
+
|
|
110
|
+
def __call__(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class RelatedNote:
|
|
116
|
+
rel_path: str
|
|
117
|
+
abs_path: Path
|
|
118
|
+
title: str
|
|
119
|
+
content_hash: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class RelatedEdge:
|
|
124
|
+
source_path: str
|
|
125
|
+
target_path: str
|
|
126
|
+
score: float
|
|
127
|
+
rank: int
|
|
128
|
+
source: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class _RelatedNotesParseBlocked:
|
|
133
|
+
"""Typed parse failure used before a sync operation can plan mutations."""
|
|
134
|
+
|
|
135
|
+
blocked_reason: str
|
|
136
|
+
next_action: str
|
|
137
|
+
validation_errors: list[JsonObject]
|
|
138
|
+
stale_notes: list[JsonObject]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class _RelatedNotesParsedNotes:
|
|
143
|
+
"""Validated note map from the Related Notes export."""
|
|
144
|
+
|
|
145
|
+
notes: dict[str, RelatedNote]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class _RelatedNotesParsedEdges:
|
|
150
|
+
"""Validated graph edge list from the Related Notes export."""
|
|
151
|
+
|
|
152
|
+
edges: list[RelatedEdge]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_RelatedNotesNotesParseResult = _RelatedNotesParsedNotes | _RelatedNotesParseBlocked
|
|
156
|
+
_RelatedNotesEdgesParseResult = _RelatedNotesParsedEdges | _RelatedNotesParseBlocked
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _RelatedNotesBlockedParseInput(ContractModel):
|
|
160
|
+
"""Boundary model for tests/tooling that still pass raw parse failures."""
|
|
161
|
+
|
|
162
|
+
blocked_reason: StrictStr
|
|
163
|
+
next_action: StrictStr
|
|
164
|
+
validation_errors: list[JsonObject] = Field(default_factory=list)
|
|
165
|
+
stale_notes: list[JsonObject] = Field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
def to_result(self) -> _RelatedNotesParseBlocked:
|
|
168
|
+
return _RelatedNotesParseBlocked(
|
|
169
|
+
blocked_reason=self.blocked_reason,
|
|
170
|
+
next_action=self.next_action,
|
|
171
|
+
validation_errors=self.validation_errors,
|
|
172
|
+
stale_notes=self.stale_notes,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _RelatedNotesExportValidation(BaseModel):
|
|
177
|
+
"""Internal typed result for export preflight.
|
|
178
|
+
|
|
179
|
+
The external export is JSON, but the workflow must not branch on loose
|
|
180
|
+
dictionaries. This model is the boundary: callers read attributes and only
|
|
181
|
+
serialize back to payload at adapter edges.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
|
185
|
+
|
|
186
|
+
status: Literal["ready", "blocked"]
|
|
187
|
+
export_path: Path
|
|
188
|
+
wiki_dir: Path
|
|
189
|
+
payload: JsonObject = Field(default_factory=dict)
|
|
190
|
+
notes: dict[str, RelatedNote] = Field(default_factory=dict)
|
|
191
|
+
edges: list[RelatedEdge] = Field(default_factory=list)
|
|
192
|
+
blocked_reason: StrictStr = ""
|
|
193
|
+
next_action: StrictStr = ""
|
|
194
|
+
hash_errors: list[JsonObject] = Field(default_factory=list)
|
|
195
|
+
stale_notes: list[JsonObject] = Field(default_factory=list)
|
|
196
|
+
validation_errors: list[JsonObject] = Field(default_factory=list)
|
|
197
|
+
hash_warnings: list[JsonObject] = Field(default_factory=list)
|
|
198
|
+
export_relocation: JsonObject = Field(default_factory=dict)
|
|
199
|
+
extra_payload: JsonObject = Field(default_factory=dict)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def is_blocked(self) -> bool:
|
|
203
|
+
return self.status == "blocked"
|
|
204
|
+
|
|
205
|
+
def blocked_payload(self) -> JsonObject:
|
|
206
|
+
"""Serialize a blocked preflight result using the public sync shape."""
|
|
207
|
+
|
|
208
|
+
extra: dict[str, object] = dict(self.extra_payload)
|
|
209
|
+
if self.hash_errors:
|
|
210
|
+
extra["hash_errors"] = self.hash_errors
|
|
211
|
+
if self.stale_notes:
|
|
212
|
+
extra["stale_notes"] = self.stale_notes
|
|
213
|
+
if self.validation_errors:
|
|
214
|
+
extra["validation_errors"] = self.validation_errors
|
|
215
|
+
if self.hash_warnings:
|
|
216
|
+
extra["hash_warnings"] = self.hash_warnings
|
|
217
|
+
if self.export_relocation:
|
|
218
|
+
extra["export_relocation"] = self.export_relocation
|
|
219
|
+
return _base_payload(
|
|
220
|
+
self.export_path,
|
|
221
|
+
self.wiki_dir,
|
|
222
|
+
status="blocked",
|
|
223
|
+
phase="related_notes_preflight",
|
|
224
|
+
blocked_reason=self.blocked_reason,
|
|
225
|
+
next_action=self.next_action,
|
|
226
|
+
extra=JsonObjectAdapter.validate_python(extra),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _RelatedNotesProposedLink(ContractModel):
|
|
231
|
+
"""One generated Related Notes wikilink candidate."""
|
|
232
|
+
|
|
233
|
+
target_path: StrictStr
|
|
234
|
+
target_title: StrictStr
|
|
235
|
+
score: float = Field(ge=0)
|
|
236
|
+
rank: int = Field(ge=0)
|
|
237
|
+
source: StrictStr
|
|
238
|
+
content_hash: StrictStr
|
|
239
|
+
line: StrictStr
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class _RelatedNotesSkippedEdge(ContractModel):
|
|
243
|
+
"""One graph edge intentionally excluded from the rendered section."""
|
|
244
|
+
|
|
245
|
+
source_path: StrictStr
|
|
246
|
+
target_path: StrictStr = ""
|
|
247
|
+
reason: StrictStr
|
|
248
|
+
score: StrictStr = ""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _RelatedNotesPlannedUpdate(ContractModel):
|
|
252
|
+
"""Private mutation plan for one note; `new_content` never enters preview."""
|
|
253
|
+
|
|
254
|
+
file: StrictStr
|
|
255
|
+
relative_path: StrictStr
|
|
256
|
+
source_title: StrictStr
|
|
257
|
+
content_hash: StrictStr
|
|
258
|
+
cleared_links: list[StrictStr] = Field(default_factory=list)
|
|
259
|
+
cleared_link_count: int = Field(default=0, ge=0)
|
|
260
|
+
proposed_links: list[_RelatedNotesProposedLink] = Field(default_factory=list)
|
|
261
|
+
new_content: StrictStr
|
|
262
|
+
changed: bool = Field(strict=True)
|
|
263
|
+
min_score: float = Field(ge=0)
|
|
264
|
+
|
|
265
|
+
def public_update(self) -> _RelatedNotesPublicUpdate:
|
|
266
|
+
return _RelatedNotesPublicUpdate.model_validate(
|
|
267
|
+
{
|
|
268
|
+
"file": self.file,
|
|
269
|
+
"relative_path": self.relative_path,
|
|
270
|
+
"source_title": self.source_title,
|
|
271
|
+
"content_hash": self.content_hash,
|
|
272
|
+
"cleared_links": self.cleared_links,
|
|
273
|
+
"cleared_link_count": self.cleared_link_count,
|
|
274
|
+
"proposed_links": [link.to_payload() for link in self.proposed_links],
|
|
275
|
+
"changed": self.changed,
|
|
276
|
+
"min_score": self.min_score,
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _RelatedNotesPublicUpdate(ContractModel):
|
|
282
|
+
"""Preview/apply receipt shape for a Related Notes section update."""
|
|
283
|
+
|
|
284
|
+
file: StrictStr
|
|
285
|
+
relative_path: StrictStr
|
|
286
|
+
source_title: StrictStr
|
|
287
|
+
content_hash: StrictStr
|
|
288
|
+
cleared_links: list[StrictStr] = Field(default_factory=list)
|
|
289
|
+
cleared_link_count: int = Field(default=0, ge=0)
|
|
290
|
+
proposed_links: list[JsonObject] = Field(default_factory=list)
|
|
291
|
+
changed: bool = Field(strict=True)
|
|
292
|
+
min_score: float = Field(ge=0)
|
|
293
|
+
backup_path: StrictStr = ""
|
|
294
|
+
applied: bool = Field(default=False, strict=True)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class _RelatedNotesUpdatePlan(BaseModel):
|
|
298
|
+
"""Typed sync plan; private content stays private until the apply adapter."""
|
|
299
|
+
|
|
300
|
+
model_config = ConfigDict(extra="forbid")
|
|
301
|
+
|
|
302
|
+
status: Literal["preview_ready"] = "preview_ready"
|
|
303
|
+
wiki_dir: StrictStr
|
|
304
|
+
updates: list[_RelatedNotesPublicUpdate] = Field(default_factory=list)
|
|
305
|
+
private_updates: list[_RelatedNotesPlannedUpdate] = Field(default_factory=list)
|
|
306
|
+
skipped_edges: list[_RelatedNotesSkippedEdge] = Field(default_factory=list)
|
|
307
|
+
|
|
308
|
+
def summary(self) -> dict[str, int]:
|
|
309
|
+
return {
|
|
310
|
+
"planned_note_count": len(self.updates),
|
|
311
|
+
"proposed_link_count": sum(len(update.proposed_links) for update in self.updates),
|
|
312
|
+
"cleared_link_count": sum(update.cleared_link_count for update in self.updates),
|
|
313
|
+
"skipped_edge_count": len(self.skipped_edges),
|
|
314
|
+
"applied_note_count": 0,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
def public_updates_payload(self) -> list[JsonObject]:
|
|
318
|
+
return [update.to_payload() for update in self.updates]
|
|
319
|
+
|
|
320
|
+
def skipped_edges_payload(self) -> list[JsonObject]:
|
|
321
|
+
return [edge.to_payload() for edge in self.skipped_edges]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class _RelatedNotesSyncReceiptPayload(ContractModel):
|
|
325
|
+
schema_: StrictStr = Field(alias="schema", serialization_alias="schema")
|
|
326
|
+
generated_at: StrictStr
|
|
327
|
+
status: StrictStr
|
|
328
|
+
phase: StrictStr
|
|
329
|
+
dry_run: StrictBool
|
|
330
|
+
no_resource_mutation: StrictBool
|
|
331
|
+
wiki_dir: StrictStr
|
|
332
|
+
export_path: StrictStr
|
|
333
|
+
export_hash: StrictStr
|
|
334
|
+
export_generated_at: StrictStr = ""
|
|
335
|
+
plugin: JsonObject = Field(default_factory=dict)
|
|
336
|
+
model: JsonObject = Field(default_factory=dict)
|
|
337
|
+
api_calls: NonNegativeInt = 0
|
|
338
|
+
api_failures: NonNegativeInt = 0
|
|
339
|
+
plan_hash: StrictStr
|
|
340
|
+
applied_note_count: NonNegativeInt = 0
|
|
341
|
+
update_count: NonNegativeInt = 0
|
|
342
|
+
updates: list[JsonObject] = Field(default_factory=list)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class _RelatedNotesAppliedUpdatesPayload(ContractModel):
|
|
346
|
+
updates: list[JsonObject] = Field(default_factory=list)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def default_export_path(wiki_dir: Path) -> Path:
|
|
350
|
+
return wiki_dir / DEFAULT_RELATED_NOTES_EXPORT
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _link_related_run_id(result: LinkRelatedSyncResult) -> str:
|
|
354
|
+
basis = result.export_path or result.receipt_path or result.blocked_reason or "run"
|
|
355
|
+
safe = re.sub(r"[^A-Za-z0-9_.:-]+", "-", basis)[:48].strip("-")
|
|
356
|
+
return f"link-related-{safe or 'run'}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _link_related_version_control_safety(result: LinkRelatedSyncResult, *, applying: bool) -> dict[str, object]:
|
|
360
|
+
changed_update_count = sum(1 for update in result.updates if update.changed)
|
|
361
|
+
mutated = applying and (result.applied_note_count > 0 or changed_update_count > 0)
|
|
362
|
+
return {
|
|
363
|
+
"resource_guard_active": mutated,
|
|
364
|
+
"run_start_seen": mutated,
|
|
365
|
+
"run_finish_seen": mutated,
|
|
366
|
+
"restore_point_before": "vault-guard" if mutated else "",
|
|
367
|
+
"restore_point_after": "vault-guard" if mutated else "",
|
|
368
|
+
"sync_status": "not_checked",
|
|
369
|
+
"backup_online": "not_checked",
|
|
370
|
+
"direct_mutation_forbidden": True,
|
|
371
|
+
"mutation_without_guard": False,
|
|
372
|
+
"rollback_declared": mutated,
|
|
373
|
+
"no_resource_mutation": not mutated,
|
|
374
|
+
"changed_file_count": changed_update_count,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _json_str_field(payload: JsonObject, key: str, default: str = "") -> str:
|
|
379
|
+
"""Read a public JSON string after validating the object boundary."""
|
|
380
|
+
|
|
381
|
+
if key not in payload:
|
|
382
|
+
return default
|
|
383
|
+
value = payload[key]
|
|
384
|
+
if value is None:
|
|
385
|
+
return default
|
|
386
|
+
return str(value)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def recover_related_notes_export(
|
|
390
|
+
config: MedConfig,
|
|
391
|
+
*,
|
|
392
|
+
export_path: Path | None = None,
|
|
393
|
+
mode: str = "auto",
|
|
394
|
+
command_runner: _ObsidianCommandRunner | None = None,
|
|
395
|
+
headless_embedding_client: EmbeddingClient | None = None,
|
|
396
|
+
headless_now_iso: str | None = None,
|
|
397
|
+
headless_now_ms: int | None = None,
|
|
398
|
+
workflow: str = "/mednotes:link-related",
|
|
399
|
+
run_id: str = "related-notes-recovery",
|
|
400
|
+
) -> JsonObject:
|
|
401
|
+
result = recover_related_notes_export_operation_result(
|
|
402
|
+
config,
|
|
403
|
+
export_path=export_path,
|
|
404
|
+
mode=mode,
|
|
405
|
+
command_runner=command_runner,
|
|
406
|
+
headless_embedding_client=headless_embedding_client,
|
|
407
|
+
headless_now_iso=headless_now_iso,
|
|
408
|
+
headless_now_ms=headless_now_ms,
|
|
409
|
+
workflow=workflow,
|
|
410
|
+
run_id=run_id,
|
|
411
|
+
)
|
|
412
|
+
sync_result = LinkRelatedSyncResult.from_payload(result)
|
|
413
|
+
return link_related_fsm_payload_from_sync_result(
|
|
414
|
+
JsonObjectAdapter.validate_python(result),
|
|
415
|
+
run_id=_link_related_run_id(sync_result),
|
|
416
|
+
mode="recover_export",
|
|
417
|
+
version_control_safety=_link_related_version_control_safety(sync_result, applying=False),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def recover_related_notes_export_operation_result(
|
|
422
|
+
config: MedConfig,
|
|
423
|
+
*,
|
|
424
|
+
export_path: Path | None = None,
|
|
425
|
+
mode: str = "auto",
|
|
426
|
+
command_runner: _ObsidianCommandRunner | None = None,
|
|
427
|
+
headless_embedding_client: EmbeddingClient | None = None,
|
|
428
|
+
headless_now_iso: str | None = None,
|
|
429
|
+
headless_now_ms: int | None = None,
|
|
430
|
+
workflow: str = "/mednotes:link-related",
|
|
431
|
+
run_id: str = "related-notes-recovery",
|
|
432
|
+
) -> JsonObject:
|
|
433
|
+
export = export_path or default_export_path(config.wiki_dir)
|
|
434
|
+
preflight = _load_and_validate_export(export, config.wiki_dir, max_age_hours=168)
|
|
435
|
+
stale_notes = [
|
|
436
|
+
{
|
|
437
|
+
"path": str(item["path"]),
|
|
438
|
+
"expected_hash": str(item["expected"]),
|
|
439
|
+
"actual_hash": str(item["actual"]),
|
|
440
|
+
}
|
|
441
|
+
for item in preflight.hash_errors
|
|
442
|
+
]
|
|
443
|
+
for item in preflight.stale_notes:
|
|
444
|
+
stale_notes.append(
|
|
445
|
+
{
|
|
446
|
+
"path": str(item["path"]),
|
|
447
|
+
"expected_hash": str(item["expected_hash"]),
|
|
448
|
+
"actual_hash": str(item["actual_hash"]),
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
if not preflight.is_blocked:
|
|
452
|
+
return _recovery_payload(
|
|
453
|
+
export,
|
|
454
|
+
config.wiki_dir,
|
|
455
|
+
status="recovered",
|
|
456
|
+
blocked_reason="",
|
|
457
|
+
recovery_mode="not_needed",
|
|
458
|
+
stale_notes=[],
|
|
459
|
+
next_action="",
|
|
460
|
+
extra={"retry_command": wiki_cli_command("related-notes-sync", "--dry-run", "--json")},
|
|
461
|
+
)
|
|
462
|
+
blocked_reason = preflight.blocked_reason
|
|
463
|
+
if blocked_reason not in {
|
|
464
|
+
"related_notes_hash_mismatch",
|
|
465
|
+
"related_notes_export_stale",
|
|
466
|
+
"related_notes_vault_mismatch",
|
|
467
|
+
}:
|
|
468
|
+
return _recovery_payload(
|
|
469
|
+
export,
|
|
470
|
+
config.wiki_dir,
|
|
471
|
+
status="blocked",
|
|
472
|
+
blocked_reason=blocked_reason or "related_notes_recovery_not_applicable",
|
|
473
|
+
recovery_mode="manual_required",
|
|
474
|
+
stale_notes=stale_notes,
|
|
475
|
+
next_action=preflight.next_action,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
recovery_mode = _select_recovery_mode(mode=mode, stale_notes=stale_notes, blocked_reason=blocked_reason)
|
|
479
|
+
if recovery_mode == "export_only_diagnostic" and stale_notes:
|
|
480
|
+
return _recovery_payload(
|
|
481
|
+
export,
|
|
482
|
+
config.wiki_dir,
|
|
483
|
+
status="blocked",
|
|
484
|
+
blocked_reason="related_notes_export_only_unsafe_for_changed_notes",
|
|
485
|
+
recovery_mode=recovery_mode,
|
|
486
|
+
stale_notes=stale_notes,
|
|
487
|
+
next_action="Usar --mode reindex-vault para notas existentes editadas; export-only não corrige índice stale.",
|
|
488
|
+
)
|
|
489
|
+
runner = command_runner or _run_obsidian_command
|
|
490
|
+
if command_runner is None and shutil.which("obsidian") is None:
|
|
491
|
+
if headless_plugin_settings_available(config.wiki_dir):
|
|
492
|
+
return _recover_related_notes_export_headless(
|
|
493
|
+
config,
|
|
494
|
+
export,
|
|
495
|
+
recovery_mode=recovery_mode,
|
|
496
|
+
stale_notes=stale_notes,
|
|
497
|
+
embedding_client=headless_embedding_client,
|
|
498
|
+
now_iso=headless_now_iso,
|
|
499
|
+
now_ms=headless_now_ms,
|
|
500
|
+
workflow=workflow,
|
|
501
|
+
run_id=run_id,
|
|
502
|
+
)
|
|
503
|
+
return _recovery_payload(
|
|
504
|
+
export,
|
|
505
|
+
config.wiki_dir,
|
|
506
|
+
status="blocked",
|
|
507
|
+
blocked_reason=blocked_reason,
|
|
508
|
+
recovery_mode=recovery_mode,
|
|
509
|
+
stale_notes=stale_notes,
|
|
510
|
+
next_action=preflight.next_action or "Conferir o export do Related Notes pela rota oficial.",
|
|
511
|
+
extra={
|
|
512
|
+
"export_relocation": preflight.export_relocation,
|
|
513
|
+
"obsidian_cli_available": False,
|
|
514
|
+
"obsidian_running": False,
|
|
515
|
+
"automatic_recovery_unavailable_reason": "obsidian_cli_unavailable",
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
ready = runner(["obsidian", "help"])
|
|
520
|
+
if int(getattr(ready, "returncode", 1) or 0) != 0:
|
|
521
|
+
timed_out = int(getattr(ready, "returncode", 1) or 0) == OBSIDIAN_TIMEOUT_RETURNCODE
|
|
522
|
+
extra = {
|
|
523
|
+
"command_discovery_status": "timeout" if timed_out else "not_ready",
|
|
524
|
+
"command_returncode": int(getattr(ready, "returncode", 1) or 0),
|
|
525
|
+
}
|
|
526
|
+
if timed_out:
|
|
527
|
+
extra["command_timeout_seconds"] = OBSIDIAN_PROBE_TIMEOUT_SECONDS
|
|
528
|
+
return _recovery_payload(
|
|
529
|
+
export,
|
|
530
|
+
config.wiki_dir,
|
|
531
|
+
status="blocked",
|
|
532
|
+
blocked_reason="obsidian_cli_timeout" if timed_out else "obsidian_not_ready",
|
|
533
|
+
recovery_mode=recovery_mode,
|
|
534
|
+
stale_notes=stale_notes,
|
|
535
|
+
next_action=(
|
|
536
|
+
"Obsidian CLI demorou demais para responder; verifique se o Obsidian está aberto e repita o recovery."
|
|
537
|
+
if timed_out
|
|
538
|
+
else "Abrir o Obsidian no vault configurado e repetir related-notes-sync --recover-export --mode auto --json."
|
|
539
|
+
),
|
|
540
|
+
extra=extra,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
discovered, discovery_status = _discover_related_notes_commands(runner)
|
|
544
|
+
if discovery_status == "timeout":
|
|
545
|
+
return _recovery_payload(
|
|
546
|
+
export,
|
|
547
|
+
config.wiki_dir,
|
|
548
|
+
status="blocked",
|
|
549
|
+
blocked_reason="obsidian_cli_timeout",
|
|
550
|
+
recovery_mode=recovery_mode,
|
|
551
|
+
stale_notes=stale_notes,
|
|
552
|
+
next_action="Obsidian CLI demorou demais para listar comandos; verifique o Obsidian e repita o recovery.",
|
|
553
|
+
extra={"command_discovery_status": discovery_status, "command_timeout_seconds": OBSIDIAN_PROBE_TIMEOUT_SECONDS},
|
|
554
|
+
)
|
|
555
|
+
command_id = RELATED_NOTES_COMMANDS[recovery_mode] if recovery_mode in RELATED_NOTES_COMMANDS else RELATED_NOTES_COMMANDS["reindex_vault"]
|
|
556
|
+
if discovery_status == "discovered" and command_id not in discovered:
|
|
557
|
+
blocked_reason = (
|
|
558
|
+
"related_notes_plugin_unavailable"
|
|
559
|
+
if not discovered
|
|
560
|
+
else "related_notes_export_command_missing"
|
|
561
|
+
if recovery_mode == "export_only_diagnostic"
|
|
562
|
+
else "related_notes_reindex_command_missing"
|
|
563
|
+
)
|
|
564
|
+
return _recovery_payload(
|
|
565
|
+
export,
|
|
566
|
+
config.wiki_dir,
|
|
567
|
+
status="blocked",
|
|
568
|
+
blocked_reason=blocked_reason,
|
|
569
|
+
recovery_mode=recovery_mode,
|
|
570
|
+
stale_notes=stale_notes,
|
|
571
|
+
next_action="Habilitar related-notes-obsidian no vault e repetir o recovery.",
|
|
572
|
+
extra={"discovered_commands": sorted(discovered), "command_discovery_status": discovery_status},
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
command = ["obsidian", f"vault={config.wiki_dir.name}", "command", f"id={command_id}"]
|
|
576
|
+
result = runner(command)
|
|
577
|
+
if int(getattr(result, "returncode", 1) or 0) != 0:
|
|
578
|
+
timed_out = int(getattr(result, "returncode", 1) or 0) == OBSIDIAN_TIMEOUT_RETURNCODE
|
|
579
|
+
extra = {
|
|
580
|
+
"discovered_commands": sorted(discovered),
|
|
581
|
+
"command_discovery_status": discovery_status,
|
|
582
|
+
"command_returncode": int(getattr(result, "returncode", 1) or 0),
|
|
583
|
+
}
|
|
584
|
+
if timed_out:
|
|
585
|
+
extra["command_timeout_seconds"] = OBSIDIAN_COMMAND_TIMEOUT_SECONDS
|
|
586
|
+
return _recovery_payload(
|
|
587
|
+
export,
|
|
588
|
+
config.wiki_dir,
|
|
589
|
+
status="blocked",
|
|
590
|
+
blocked_reason="obsidian_cli_timeout" if timed_out else "related_notes_reindex_failed",
|
|
591
|
+
recovery_mode=recovery_mode,
|
|
592
|
+
stale_notes=stale_notes,
|
|
593
|
+
next_action=(
|
|
594
|
+
"Obsidian CLI demorou demais para executar o comando do plugin; verifique o Obsidian e repita o recovery."
|
|
595
|
+
if timed_out
|
|
596
|
+
else "Corrigir erro do plugin/Obsidian CLI e repetir related-notes-sync --recover-export."
|
|
597
|
+
),
|
|
598
|
+
extra=extra,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
validation = sync_related_notes_operation_result(config, export_path=export, apply=False)
|
|
602
|
+
validation_result = LinkRelatedSyncResult.from_payload(validation)
|
|
603
|
+
if validation_result.status == "blocked" or validation_result.blocked_reason:
|
|
604
|
+
return _recovery_payload(
|
|
605
|
+
export,
|
|
606
|
+
config.wiki_dir,
|
|
607
|
+
status="blocked",
|
|
608
|
+
blocked_reason="related_notes_export_still_stale"
|
|
609
|
+
if validation_result.blocked_reason == "related_notes_hash_mismatch"
|
|
610
|
+
else validation_result.blocked_reason or "related_notes_revalidation_failed",
|
|
611
|
+
recovery_mode=recovery_mode,
|
|
612
|
+
stale_notes=stale_notes,
|
|
613
|
+
next_action=validation_result.next_action or "Revalidar export do Related Notes antes do apply.",
|
|
614
|
+
extra={"discovered_commands": sorted(discovered), "command_discovery_status": discovery_status, "revalidation": validation},
|
|
615
|
+
)
|
|
616
|
+
return _recovery_payload(
|
|
617
|
+
export,
|
|
618
|
+
config.wiki_dir,
|
|
619
|
+
status="recovered",
|
|
620
|
+
blocked_reason="",
|
|
621
|
+
recovery_mode=recovery_mode,
|
|
622
|
+
stale_notes=stale_notes,
|
|
623
|
+
next_action=wiki_cli_command("related-notes-sync", "--dry-run", "--json"),
|
|
624
|
+
extra={"discovered_commands": sorted(discovered), "command_discovery_status": discovery_status, "revalidation": validation},
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _recover_related_notes_export_headless(
|
|
629
|
+
config: MedConfig,
|
|
630
|
+
export: Path,
|
|
631
|
+
*,
|
|
632
|
+
recovery_mode: str,
|
|
633
|
+
stale_notes: list[dict[str, str]],
|
|
634
|
+
embedding_client: EmbeddingClient | None,
|
|
635
|
+
now_iso: str | None,
|
|
636
|
+
now_ms: int | None,
|
|
637
|
+
workflow: str,
|
|
638
|
+
run_id: str,
|
|
639
|
+
) -> JsonObject:
|
|
640
|
+
try:
|
|
641
|
+
headless = generate_headless_related_notes_export(
|
|
642
|
+
config.wiki_dir,
|
|
643
|
+
export_path=export,
|
|
644
|
+
embedding_client=embedding_client,
|
|
645
|
+
now_iso=now_iso,
|
|
646
|
+
now_ms=now_ms,
|
|
647
|
+
)
|
|
648
|
+
except HeadlessRelatedNotesExportError as exc:
|
|
649
|
+
next_action = exc.next_action
|
|
650
|
+
if exc.partial_record_count:
|
|
651
|
+
label = "registro" if exc.partial_record_count == 1 else "registros"
|
|
652
|
+
next_action = (
|
|
653
|
+
f"{exc.next_action} O índice parcial já tem {exc.partial_record_count} {label}; "
|
|
654
|
+
"a próxima tentativa retoma desse ponto."
|
|
655
|
+
)
|
|
656
|
+
recovery_state = {
|
|
657
|
+
"schema": "medical-notes-workbench.related-notes-recovery-state.v1",
|
|
658
|
+
"status": "waiting_for_retry" if exc.blocked_reason in RELATED_NOTES_RESUMABLE_BLOCKERS else "blocked",
|
|
659
|
+
"blocked_reason": exc.blocked_reason,
|
|
660
|
+
"resume_supported": bool(exc.partial_record_count),
|
|
661
|
+
"partial_record_count": exc.partial_record_count,
|
|
662
|
+
"fresh_record_count": exc.fresh_record_count or exc.partial_record_count,
|
|
663
|
+
"stale_record_count": exc.stale_record_count,
|
|
664
|
+
"record_count": exc.record_count,
|
|
665
|
+
"total_note_count": exc.total_note_count,
|
|
666
|
+
"remaining_count": exc.remaining_count,
|
|
667
|
+
"embedded_count": exc.embedded_count,
|
|
668
|
+
"reused_count": exc.reused_count,
|
|
669
|
+
"next_retry_after_seconds": exc.next_retry_after_seconds,
|
|
670
|
+
"attempt_count": 1,
|
|
671
|
+
}
|
|
672
|
+
projection = build_related_notes_recovery_projection(
|
|
673
|
+
workflow=workflow,
|
|
674
|
+
run_id=run_id,
|
|
675
|
+
recovery_state=recovery_state,
|
|
676
|
+
next_action=next_action,
|
|
677
|
+
)
|
|
678
|
+
return _recovery_payload(
|
|
679
|
+
export,
|
|
680
|
+
config.wiki_dir,
|
|
681
|
+
status="blocked",
|
|
682
|
+
blocked_reason=exc.blocked_reason,
|
|
683
|
+
recovery_mode="headless_reindex_vault",
|
|
684
|
+
stale_notes=stale_notes,
|
|
685
|
+
next_action=next_action,
|
|
686
|
+
extra={
|
|
687
|
+
"headless_export": {
|
|
688
|
+
"status": "blocked",
|
|
689
|
+
"phase": "related_notes_headless_export",
|
|
690
|
+
"blocked_reason": exc.blocked_reason,
|
|
691
|
+
"detail": exc.detail,
|
|
692
|
+
"partial_record_count": exc.partial_record_count,
|
|
693
|
+
"fresh_record_count": exc.fresh_record_count or exc.partial_record_count,
|
|
694
|
+
"stale_record_count": exc.stale_record_count,
|
|
695
|
+
"record_count": exc.record_count,
|
|
696
|
+
"total_note_count": exc.total_note_count,
|
|
697
|
+
"remaining_count": exc.remaining_count,
|
|
698
|
+
"embedded_count": exc.embedded_count,
|
|
699
|
+
"reused_count": exc.reused_count,
|
|
700
|
+
"next_retry_after_seconds": exc.next_retry_after_seconds,
|
|
701
|
+
"resume_supported": bool(exc.partial_record_count),
|
|
702
|
+
},
|
|
703
|
+
"related_notes_recovery_state": recovery_state,
|
|
704
|
+
"progress_state": projection.progress_state.to_payload(),
|
|
705
|
+
"progress_view_model": projection.progress_view_model.to_payload(),
|
|
706
|
+
"state_machine_snapshot": projection.snapshot.to_payload(),
|
|
707
|
+
"obsidian_cli_available": False,
|
|
708
|
+
"obsidian_running": False,
|
|
709
|
+
"fallback_from_recovery_mode": recovery_mode,
|
|
710
|
+
},
|
|
711
|
+
)
|
|
712
|
+
validation = sync_related_notes_operation_result(config, export_path=export, apply=False)
|
|
713
|
+
validation_result = LinkRelatedSyncResult.from_payload(validation)
|
|
714
|
+
if validation_result.status == "blocked" or validation_result.blocked_reason:
|
|
715
|
+
return _recovery_payload(
|
|
716
|
+
export,
|
|
717
|
+
config.wiki_dir,
|
|
718
|
+
status="blocked",
|
|
719
|
+
blocked_reason="related_notes_export_still_stale"
|
|
720
|
+
if validation_result.blocked_reason == "related_notes_hash_mismatch"
|
|
721
|
+
else validation_result.blocked_reason or "related_notes_revalidation_failed",
|
|
722
|
+
recovery_mode="headless_reindex_vault",
|
|
723
|
+
stale_notes=stale_notes,
|
|
724
|
+
next_action=validation_result.next_action or "Revalidar export do Related Notes antes do apply.",
|
|
725
|
+
extra={
|
|
726
|
+
"headless_export": headless,
|
|
727
|
+
"obsidian_cli_available": False,
|
|
728
|
+
"obsidian_running": False,
|
|
729
|
+
"fallback_from_recovery_mode": recovery_mode,
|
|
730
|
+
"revalidation": validation,
|
|
731
|
+
},
|
|
732
|
+
)
|
|
733
|
+
return _recovery_payload(
|
|
734
|
+
export,
|
|
735
|
+
config.wiki_dir,
|
|
736
|
+
status="recovered",
|
|
737
|
+
blocked_reason="",
|
|
738
|
+
recovery_mode="headless_reindex_vault",
|
|
739
|
+
stale_notes=stale_notes,
|
|
740
|
+
next_action=wiki_cli_command("related-notes-sync", "--dry-run", "--json"),
|
|
741
|
+
extra={
|
|
742
|
+
"headless_export": headless,
|
|
743
|
+
"obsidian_cli_available": False,
|
|
744
|
+
"obsidian_running": False,
|
|
745
|
+
"fallback_from_recovery_mode": recovery_mode,
|
|
746
|
+
"revalidation": validation,
|
|
747
|
+
},
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _select_recovery_mode(*, mode: str, stale_notes: list[dict[str, str]], blocked_reason: str = "") -> str:
|
|
752
|
+
normalized = mode.replace("-", "_")
|
|
753
|
+
if normalized == "auto":
|
|
754
|
+
return (
|
|
755
|
+
"reindex_vault"
|
|
756
|
+
if stale_notes or blocked_reason in {"related_notes_export_stale", "related_notes_vault_mismatch"}
|
|
757
|
+
else "manual_required"
|
|
758
|
+
)
|
|
759
|
+
if normalized in {"reindex_vault", "index_missing", "index_missing_notes", "export_only_diagnostic"}:
|
|
760
|
+
return "index_missing_notes" if normalized == "index_missing" else normalized
|
|
761
|
+
return "manual_required"
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _public_recovery_mode(recovery_mode: str, *, blocked_reason: str = "") -> str:
|
|
765
|
+
mapping = {
|
|
766
|
+
"headless_reindex_vault": "headless-reindex-vault",
|
|
767
|
+
"reindex_vault": "reindex-vault",
|
|
768
|
+
"index_missing_notes": "index-missing",
|
|
769
|
+
"export_only_diagnostic": "export-only-diagnostic",
|
|
770
|
+
"manual_required": "manual",
|
|
771
|
+
"not_needed": "manual",
|
|
772
|
+
}
|
|
773
|
+
if not recovery_mode and blocked_reason == "related_notes_hash_mismatch":
|
|
774
|
+
return "reindex-vault"
|
|
775
|
+
return mapping[recovery_mode] if recovery_mode in mapping else "manual"
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _manual_instruction_allowed(blocked_reason: str) -> bool:
|
|
779
|
+
return blocked_reason in {
|
|
780
|
+
"obsidian_cli_unavailable",
|
|
781
|
+
"obsidian_not_ready",
|
|
782
|
+
"obsidian_cli_timeout",
|
|
783
|
+
"related_notes_plugin_unavailable",
|
|
784
|
+
"related_notes_export_command_missing",
|
|
785
|
+
"related_notes_headless_quota_exhausted",
|
|
786
|
+
"related_notes_reindex_command_missing",
|
|
787
|
+
"plugin_command_unavailable",
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _run_obsidian_command(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
792
|
+
timeout = (
|
|
793
|
+
OBSIDIAN_PROBE_TIMEOUT_SECONDS
|
|
794
|
+
if tuple(argv[:2]) in {("obsidian", "help"), ("obsidian", "commands")}
|
|
795
|
+
else OBSIDIAN_COMMAND_TIMEOUT_SECONDS
|
|
796
|
+
)
|
|
797
|
+
command = list(argv)
|
|
798
|
+
if command[:1] == ["obsidian"]:
|
|
799
|
+
command[0] = shutil.which("obsidian") or command[0]
|
|
800
|
+
try:
|
|
801
|
+
return subprocess.run(command, text=True, capture_output=True, check=False, timeout=timeout)
|
|
802
|
+
except subprocess.TimeoutExpired as exc:
|
|
803
|
+
stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
804
|
+
stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
805
|
+
return subprocess.CompletedProcess(
|
|
806
|
+
command,
|
|
807
|
+
OBSIDIAN_TIMEOUT_RETURNCODE,
|
|
808
|
+
stdout=stdout,
|
|
809
|
+
stderr=stderr or f"Obsidian CLI timed out after {timeout} seconds",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _discover_related_notes_commands(command_runner: _ObsidianCommandRunner) -> tuple[set[str], str]:
|
|
814
|
+
result = command_runner(["obsidian", "commands", "--json"])
|
|
815
|
+
if int(getattr(result, "returncode", 1) or 0) != 0:
|
|
816
|
+
if int(getattr(result, "returncode", 1) or 0) == OBSIDIAN_TIMEOUT_RETURNCODE:
|
|
817
|
+
return set(), "timeout"
|
|
818
|
+
return set(RELATED_NOTES_COMMANDS.values()), "known_ids"
|
|
819
|
+
try:
|
|
820
|
+
parsed = json.loads(result.stdout or "")
|
|
821
|
+
except json.JSONDecodeError:
|
|
822
|
+
return set(RELATED_NOTES_COMMANDS.values()), "known_ids"
|
|
823
|
+
commands: set[str] = set()
|
|
824
|
+
if isinstance(parsed, list):
|
|
825
|
+
for item in parsed:
|
|
826
|
+
if isinstance(item, dict):
|
|
827
|
+
payload = JsonObjectAdapter.validate_python(item)
|
|
828
|
+
command_id = _json_str_field(payload, "id")
|
|
829
|
+
if command_id:
|
|
830
|
+
commands.add(command_id)
|
|
831
|
+
elif isinstance(item, str):
|
|
832
|
+
commands.add(item)
|
|
833
|
+
elif isinstance(parsed, dict):
|
|
834
|
+
payload = JsonObjectAdapter.validate_python(parsed)
|
|
835
|
+
for key, value in payload.items():
|
|
836
|
+
if key == "id" and value:
|
|
837
|
+
commands.add(str(value))
|
|
838
|
+
elif isinstance(value, dict):
|
|
839
|
+
command_id = _json_str_field(JsonObjectAdapter.validate_python(value), "id")
|
|
840
|
+
if command_id:
|
|
841
|
+
commands.add(command_id)
|
|
842
|
+
elif isinstance(value, list):
|
|
843
|
+
for item in value:
|
|
844
|
+
if not isinstance(item, dict):
|
|
845
|
+
continue
|
|
846
|
+
command_id = _json_str_field(JsonObjectAdapter.validate_python(item), "id")
|
|
847
|
+
if command_id:
|
|
848
|
+
commands.add(command_id)
|
|
849
|
+
return {command for command in commands if command.startswith(f"{RELATED_NOTES_PLUGIN_ID}:")}, "discovered"
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _blocked_export_validation(
|
|
853
|
+
export_path: Path,
|
|
854
|
+
wiki_dir: Path,
|
|
855
|
+
*,
|
|
856
|
+
blocked_reason: str,
|
|
857
|
+
next_action: str,
|
|
858
|
+
hash_errors: list[JsonObject] | None = None,
|
|
859
|
+
stale_notes: list[JsonObject] | None = None,
|
|
860
|
+
validation_errors: list[JsonObject] | None = None,
|
|
861
|
+
export_relocation: JsonObject | None = None,
|
|
862
|
+
extra: JsonObject | None = None,
|
|
863
|
+
) -> _RelatedNotesExportValidation:
|
|
864
|
+
return _RelatedNotesExportValidation.model_validate(
|
|
865
|
+
{
|
|
866
|
+
"status": "blocked",
|
|
867
|
+
"export_path": export_path,
|
|
868
|
+
"wiki_dir": wiki_dir,
|
|
869
|
+
"blocked_reason": blocked_reason,
|
|
870
|
+
"next_action": next_action,
|
|
871
|
+
"hash_errors": hash_errors or [],
|
|
872
|
+
"stale_notes": stale_notes or [],
|
|
873
|
+
"validation_errors": validation_errors or [],
|
|
874
|
+
"export_relocation": export_relocation or {},
|
|
875
|
+
"extra_payload": extra or {},
|
|
876
|
+
}
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _json_object_list(items: Iterable[object]) -> list[JsonObject]:
|
|
881
|
+
"""Validate a sequence of dict-like records before embedding in payloads."""
|
|
882
|
+
|
|
883
|
+
return [JsonObjectAdapter.validate_python(item) for item in items]
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _ready_export_validation(
|
|
887
|
+
export_path: Path,
|
|
888
|
+
wiki_dir: Path,
|
|
889
|
+
*,
|
|
890
|
+
payload: JsonObject,
|
|
891
|
+
notes: dict[str, RelatedNote],
|
|
892
|
+
edges: list[RelatedEdge],
|
|
893
|
+
hash_warnings: list[JsonObject] | None = None,
|
|
894
|
+
export_relocation: JsonObject | None = None,
|
|
895
|
+
) -> _RelatedNotesExportValidation:
|
|
896
|
+
return _RelatedNotesExportValidation.model_validate(
|
|
897
|
+
{
|
|
898
|
+
"status": "ready",
|
|
899
|
+
"export_path": export_path,
|
|
900
|
+
"wiki_dir": wiki_dir,
|
|
901
|
+
"payload": payload,
|
|
902
|
+
"notes": notes,
|
|
903
|
+
"edges": edges,
|
|
904
|
+
"hash_warnings": hash_warnings or [],
|
|
905
|
+
"export_relocation": export_relocation or {},
|
|
906
|
+
}
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _recovery_payload(
|
|
911
|
+
export_path: Path,
|
|
912
|
+
wiki_dir: Path,
|
|
913
|
+
*,
|
|
914
|
+
status: str,
|
|
915
|
+
blocked_reason: str,
|
|
916
|
+
recovery_mode: str,
|
|
917
|
+
stale_notes: list[dict[str, str]],
|
|
918
|
+
next_action: str,
|
|
919
|
+
extra: JsonObject | None = None,
|
|
920
|
+
) -> JsonObject:
|
|
921
|
+
reindex_id = RELATED_NOTES_COMMANDS["reindex_vault"]
|
|
922
|
+
export_id = RELATED_NOTES_COMMANDS["export_only_diagnostic"]
|
|
923
|
+
extra_payload = extra or {}
|
|
924
|
+
raw_headless = extra_payload["headless_export"] if "headless_export" in extra_payload else None
|
|
925
|
+
headless = (
|
|
926
|
+
RelatedNotesHeadlessExportSummary.model_validate(raw_headless)
|
|
927
|
+
if isinstance(raw_headless, dict)
|
|
928
|
+
else RelatedNotesHeadlessExportSummary()
|
|
929
|
+
)
|
|
930
|
+
return {
|
|
931
|
+
"schema": RELATED_NOTES_EXPORT_RECOVERY_SCHEMA,
|
|
932
|
+
"phase": "related_notes_export_recovery",
|
|
933
|
+
"status": status,
|
|
934
|
+
"blocked_reason": blocked_reason,
|
|
935
|
+
"next_action": next_action,
|
|
936
|
+
"required_inputs": RELATED_NOTES_REQUIRED_INPUTS,
|
|
937
|
+
"human_decision_required": False,
|
|
938
|
+
"wiki_dir": str(wiki_dir),
|
|
939
|
+
"export_path": str(export_path),
|
|
940
|
+
"stale_notes": stale_notes,
|
|
941
|
+
"stale_note_count": len(stale_notes),
|
|
942
|
+
"obsidian_cli_available": blocked_reason != "obsidian_cli_unavailable",
|
|
943
|
+
"obsidian_running": blocked_reason not in {
|
|
944
|
+
"obsidian_cli_unavailable",
|
|
945
|
+
"obsidian_not_ready",
|
|
946
|
+
"obsidian_cli_timeout",
|
|
947
|
+
},
|
|
948
|
+
"plugin_id": RELATED_NOTES_PLUGIN_ID,
|
|
949
|
+
"recovery_mode": recovery_mode,
|
|
950
|
+
"selected_recovery_mode": _public_recovery_mode(recovery_mode, blocked_reason=blocked_reason),
|
|
951
|
+
"manual_instruction_allowed": _manual_instruction_allowed(blocked_reason),
|
|
952
|
+
"api_calls": headless.embedded_count,
|
|
953
|
+
"api_failures": 0,
|
|
954
|
+
"obsidian_cli_reindex_command": f'obsidian vault="{wiki_dir.name}" command id="{reindex_id}"',
|
|
955
|
+
"obsidian_cli_export_only_command": f'obsidian vault="{wiki_dir.name}" command id="{export_id}"',
|
|
956
|
+
"retry_command": wiki_cli_command("run-linker", "--diagnose", "--json"),
|
|
957
|
+
"body_only_fallback": None,
|
|
958
|
+
**extra_payload,
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def sync_related_notes(
|
|
963
|
+
config: MedConfig,
|
|
964
|
+
*,
|
|
965
|
+
export_path: Path | None = None,
|
|
966
|
+
apply: bool = False,
|
|
967
|
+
backup: bool = False,
|
|
968
|
+
receipt_path: Path | None = None,
|
|
969
|
+
min_score: float = DEFAULT_MIN_SCORE,
|
|
970
|
+
max_links: int = DEFAULT_MAX_LINKS,
|
|
971
|
+
max_age_hours: float = 168.0,
|
|
972
|
+
allow_stale_note_hashes: bool = False,
|
|
973
|
+
) -> JsonObject:
|
|
974
|
+
result = sync_related_notes_operation_result(
|
|
975
|
+
config,
|
|
976
|
+
export_path=export_path,
|
|
977
|
+
apply=apply,
|
|
978
|
+
backup=backup,
|
|
979
|
+
receipt_path=receipt_path,
|
|
980
|
+
min_score=min_score,
|
|
981
|
+
max_links=max_links,
|
|
982
|
+
max_age_hours=max_age_hours,
|
|
983
|
+
allow_stale_note_hashes=allow_stale_note_hashes,
|
|
984
|
+
)
|
|
985
|
+
sync_result = LinkRelatedSyncResult.from_payload(result)
|
|
986
|
+
return link_related_fsm_payload_from_sync_result(
|
|
987
|
+
JsonObjectAdapter.validate_python(result),
|
|
988
|
+
run_id=_link_related_run_id(sync_result),
|
|
989
|
+
mode="apply" if apply else "dry_run",
|
|
990
|
+
version_control_safety=_link_related_version_control_safety(sync_result, applying=apply),
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def sync_related_notes_operation_result(
|
|
995
|
+
config: MedConfig,
|
|
996
|
+
*,
|
|
997
|
+
export_path: Path | None = None,
|
|
998
|
+
apply: bool = False,
|
|
999
|
+
backup: bool = False,
|
|
1000
|
+
receipt_path: Path | None = None,
|
|
1001
|
+
min_score: float = DEFAULT_MIN_SCORE,
|
|
1002
|
+
max_links: int = DEFAULT_MAX_LINKS,
|
|
1003
|
+
max_age_hours: float = 168.0,
|
|
1004
|
+
allow_stale_note_hashes: bool = False,
|
|
1005
|
+
) -> JsonObject:
|
|
1006
|
+
backup = False
|
|
1007
|
+
export = export_path or default_export_path(config.wiki_dir)
|
|
1008
|
+
blocked = _load_and_validate_export(
|
|
1009
|
+
export,
|
|
1010
|
+
config.wiki_dir,
|
|
1011
|
+
max_age_hours=max_age_hours,
|
|
1012
|
+
allow_stale_note_hashes=allow_stale_note_hashes,
|
|
1013
|
+
)
|
|
1014
|
+
if blocked.is_blocked:
|
|
1015
|
+
return blocked.blocked_payload()
|
|
1016
|
+
|
|
1017
|
+
payload = blocked.payload
|
|
1018
|
+
notes = blocked.notes
|
|
1019
|
+
edges = blocked.edges
|
|
1020
|
+
plan = _plan_related_note_updates(config.wiki_dir, notes, edges, min_score=min_score, max_links=max_links)
|
|
1021
|
+
|
|
1022
|
+
result = _base_payload(
|
|
1023
|
+
export,
|
|
1024
|
+
config.wiki_dir,
|
|
1025
|
+
status="preview_ready" if not apply else "completed",
|
|
1026
|
+
phase="related_notes_dry_run" if not apply else "related_notes_apply",
|
|
1027
|
+
blocked_reason="",
|
|
1028
|
+
next_action=(
|
|
1029
|
+
"Revisar updates e repetir com --apply --receipt para gravar."
|
|
1030
|
+
if not apply and plan.updates
|
|
1031
|
+
else ""
|
|
1032
|
+
),
|
|
1033
|
+
extra={
|
|
1034
|
+
"source_export_schema": payload["schema"],
|
|
1035
|
+
"source_export_generated_at": payload["generated_at"],
|
|
1036
|
+
"plugin": payload["plugin"],
|
|
1037
|
+
"model": payload["model"],
|
|
1038
|
+
"min_score": min_score,
|
|
1039
|
+
"max_links": max_links,
|
|
1040
|
+
**_plan_summary(plan),
|
|
1041
|
+
"updates": plan.public_updates_payload(),
|
|
1042
|
+
"skipped_edges": plan.skipped_edges_payload(),
|
|
1043
|
+
"hash_warnings": blocked.hash_warnings,
|
|
1044
|
+
"export_relocation": blocked.export_relocation,
|
|
1045
|
+
},
|
|
1046
|
+
)
|
|
1047
|
+
if not apply:
|
|
1048
|
+
return result
|
|
1049
|
+
|
|
1050
|
+
applied_updates = _apply_updates(
|
|
1051
|
+
[update for update in plan.private_updates if update.changed],
|
|
1052
|
+
backup=backup,
|
|
1053
|
+
)
|
|
1054
|
+
receipt = _write_receipt(
|
|
1055
|
+
receipt_path or _default_receipt_path(),
|
|
1056
|
+
export_path=export,
|
|
1057
|
+
wiki_dir=config.wiki_dir,
|
|
1058
|
+
export_payload=payload,
|
|
1059
|
+
plan=result,
|
|
1060
|
+
applied_updates=applied_updates,
|
|
1061
|
+
)
|
|
1062
|
+
result.update(
|
|
1063
|
+
{
|
|
1064
|
+
"applied_note_count": len(applied_updates),
|
|
1065
|
+
"receipt_path": str(receipt),
|
|
1066
|
+
"updates": applied_updates,
|
|
1067
|
+
}
|
|
1068
|
+
)
|
|
1069
|
+
return result
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def cleanup_invalid_related_notes_links(
|
|
1073
|
+
config: MedConfig,
|
|
1074
|
+
*,
|
|
1075
|
+
backup: bool = False,
|
|
1076
|
+
cleanup_reason: str = "",
|
|
1077
|
+
) -> JsonObject:
|
|
1078
|
+
"""Remove broken links from generated Related Notes sections.
|
|
1079
|
+
|
|
1080
|
+
This is a degraded safety path for apply workflows when the plugin export
|
|
1081
|
+
cannot be refreshed. It does not invent new recommendations; it only keeps
|
|
1082
|
+
links that already point to one unique existing note.
|
|
1083
|
+
"""
|
|
1084
|
+
notes_by_target = _notes_by_target(config.wiki_dir)
|
|
1085
|
+
reports: list[JsonObject] = []
|
|
1086
|
+
changed_files: list[str] = []
|
|
1087
|
+
backup_paths: list[str] = []
|
|
1088
|
+
removed_link_count = 0
|
|
1089
|
+
kept_link_count = 0
|
|
1090
|
+
for path in iter_notes(config.wiki_dir):
|
|
1091
|
+
relative = path.relative_to(config.wiki_dir).as_posix()
|
|
1092
|
+
text = path.read_text(encoding="utf-8")
|
|
1093
|
+
if _is_index_note(path, text):
|
|
1094
|
+
continue
|
|
1095
|
+
span = _related_section_span(text)
|
|
1096
|
+
if span is None:
|
|
1097
|
+
continue
|
|
1098
|
+
updated, report = _clean_related_section_text(
|
|
1099
|
+
text,
|
|
1100
|
+
span,
|
|
1101
|
+
source_relative_path=relative,
|
|
1102
|
+
notes_by_target=notes_by_target,
|
|
1103
|
+
)
|
|
1104
|
+
if not report.removed_links:
|
|
1105
|
+
continue
|
|
1106
|
+
removed_link_count += len(report.removed_links)
|
|
1107
|
+
kept_link_count += report.kept_link_count
|
|
1108
|
+
if updated == text:
|
|
1109
|
+
reports.append({"path": relative, "changed": False, **report.to_payload()})
|
|
1110
|
+
continue
|
|
1111
|
+
atomic_write_text(path, updated)
|
|
1112
|
+
changed_files.append(str(path))
|
|
1113
|
+
reports.append({"path": relative, "changed": True, "backup_path": "", **report.to_payload()})
|
|
1114
|
+
return {
|
|
1115
|
+
"schema": RELATED_NOTES_SAFETY_CLEANUP_SCHEMA,
|
|
1116
|
+
"phase": "related_notes_safety_cleanup",
|
|
1117
|
+
"status": "completed" if changed_files else "skipped",
|
|
1118
|
+
"cleanup_reason": cleanup_reason,
|
|
1119
|
+
"backup": backup,
|
|
1120
|
+
"changed_file_count": len(changed_files),
|
|
1121
|
+
"changed_files": changed_files,
|
|
1122
|
+
"backup_paths": backup_paths,
|
|
1123
|
+
"removed_link_count": removed_link_count,
|
|
1124
|
+
"kept_link_count": kept_link_count,
|
|
1125
|
+
"reports": reports,
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _load_and_validate_export(
|
|
1130
|
+
export_path: Path,
|
|
1131
|
+
wiki_dir: Path,
|
|
1132
|
+
*,
|
|
1133
|
+
max_age_hours: float,
|
|
1134
|
+
allow_stale_note_hashes: bool = False,
|
|
1135
|
+
) -> _RelatedNotesExportValidation:
|
|
1136
|
+
if not export_path.is_file():
|
|
1137
|
+
return _blocked_export_validation(
|
|
1138
|
+
export_path,
|
|
1139
|
+
wiki_dir,
|
|
1140
|
+
blocked_reason="related_notes_export_missing",
|
|
1141
|
+
next_action=(
|
|
1142
|
+
"Exportar .obsidian/plugins/related-notes-obsidian/medical-notes-export.json "
|
|
1143
|
+
"ou passar --export para um arquivo related-notes-export.v1."
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
cache_key = _related_notes_export_cache_key(
|
|
1147
|
+
export_path,
|
|
1148
|
+
wiki_dir,
|
|
1149
|
+
max_age_hours=max_age_hours,
|
|
1150
|
+
allow_stale_note_hashes=allow_stale_note_hashes,
|
|
1151
|
+
)
|
|
1152
|
+
if cache_key is not None and cache_key in _RELATED_NOTES_EXPORT_CACHE:
|
|
1153
|
+
return _clone_export_validation_result(_RELATED_NOTES_EXPORT_CACHE[cache_key])
|
|
1154
|
+
try:
|
|
1155
|
+
payload = json.loads(export_path.read_text(encoding="utf-8"))
|
|
1156
|
+
except json.JSONDecodeError as exc:
|
|
1157
|
+
return _blocked_export_validation(
|
|
1158
|
+
export_path,
|
|
1159
|
+
wiki_dir,
|
|
1160
|
+
blocked_reason="related_notes_export_invalid_json",
|
|
1161
|
+
next_action=f"Corrigir JSON do export antes de repetir. Detalhe: {exc}",
|
|
1162
|
+
)
|
|
1163
|
+
if not isinstance(payload, dict):
|
|
1164
|
+
return _blocked_export_validation(
|
|
1165
|
+
export_path,
|
|
1166
|
+
wiki_dir,
|
|
1167
|
+
blocked_reason="related_notes_export_schema_invalid",
|
|
1168
|
+
next_action=f"Gerar export no schema {RELATED_NOTES_EXPORT_SCHEMA}.",
|
|
1169
|
+
)
|
|
1170
|
+
raw_payload = JsonObjectAdapter.validate_python(payload)
|
|
1171
|
+
if _json_str_field(raw_payload, "schema") != RELATED_NOTES_EXPORT_SCHEMA:
|
|
1172
|
+
return _blocked_export_validation(
|
|
1173
|
+
export_path,
|
|
1174
|
+
wiki_dir,
|
|
1175
|
+
blocked_reason="related_notes_export_schema_invalid",
|
|
1176
|
+
next_action=f"Gerar export no schema {RELATED_NOTES_EXPORT_SCHEMA}.",
|
|
1177
|
+
)
|
|
1178
|
+
forbidden = _find_forbidden_export_keys(payload)
|
|
1179
|
+
if forbidden:
|
|
1180
|
+
return _blocked_export_validation(
|
|
1181
|
+
export_path,
|
|
1182
|
+
wiki_dir,
|
|
1183
|
+
blocked_reason="related_notes_export_contains_private_payload",
|
|
1184
|
+
next_action="Gerar export sem API keys, tokens, conteúdo bruto, markdown ou embeddings.",
|
|
1185
|
+
extra={"forbidden_keys": forbidden[:12]},
|
|
1186
|
+
)
|
|
1187
|
+
try:
|
|
1188
|
+
contract = RelatedNotesExport.model_validate(payload)
|
|
1189
|
+
except PydanticValidationError as exc:
|
|
1190
|
+
return _blocked_export_validation(
|
|
1191
|
+
export_path,
|
|
1192
|
+
wiki_dir,
|
|
1193
|
+
blocked_reason="related_notes_export_contract_invalid",
|
|
1194
|
+
next_action=f"Gerar export no schema tipado {RELATED_NOTES_EXPORT_SCHEMA} pela rota oficial do plugin.",
|
|
1195
|
+
extra={"contract_errors": _contract_errors(exc)},
|
|
1196
|
+
)
|
|
1197
|
+
payload = contract.to_payload()
|
|
1198
|
+
vault_root = contract.vault_root.strip()
|
|
1199
|
+
vault_root_mismatch = not _same_root(vault_root, wiki_dir)
|
|
1200
|
+
age_error = _staleness_error(contract.generated_at.isoformat(), max_age_hours=max_age_hours)
|
|
1201
|
+
if age_error:
|
|
1202
|
+
return _blocked_export_validation(
|
|
1203
|
+
export_path,
|
|
1204
|
+
wiki_dir,
|
|
1205
|
+
blocked_reason="related_notes_export_stale",
|
|
1206
|
+
next_action=wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json"),
|
|
1207
|
+
extra={"generated_at": payload["generated_at"], "max_age_hours": max_age_hours, "detail": age_error},
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
notes_result = _parse_export_notes(contract, wiki_dir)
|
|
1211
|
+
if isinstance(notes_result, _RelatedNotesParseBlocked):
|
|
1212
|
+
return _blocked_parse_payload(export_path, wiki_dir, notes_result)
|
|
1213
|
+
notes = notes_result.notes
|
|
1214
|
+
profile_id = _export_profile_id(contract)
|
|
1215
|
+
hash_errors = _hash_errors(notes, profile_id=profile_id)
|
|
1216
|
+
if hash_errors and not allow_stale_note_hashes:
|
|
1217
|
+
export_relocation: JsonObject = {}
|
|
1218
|
+
if vault_root_mismatch:
|
|
1219
|
+
export_relocation = _export_relocation_payload(
|
|
1220
|
+
status="rejected",
|
|
1221
|
+
proof="relative_paths_and_representation_hashes",
|
|
1222
|
+
reason="hash_mismatch",
|
|
1223
|
+
note_count=len(notes),
|
|
1224
|
+
errors=hash_errors[:12],
|
|
1225
|
+
)
|
|
1226
|
+
return _blocked_export_validation(
|
|
1227
|
+
export_path,
|
|
1228
|
+
wiki_dir,
|
|
1229
|
+
blocked_reason="related_notes_hash_mismatch",
|
|
1230
|
+
next_action=wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json"),
|
|
1231
|
+
hash_errors=_json_object_list(hash_errors[:12]),
|
|
1232
|
+
export_relocation=export_relocation,
|
|
1233
|
+
)
|
|
1234
|
+
if vault_root_mismatch:
|
|
1235
|
+
relocation_errors = _relocated_export_verification_errors(
|
|
1236
|
+
export_path=export_path,
|
|
1237
|
+
wiki_dir=wiki_dir,
|
|
1238
|
+
notes=notes,
|
|
1239
|
+
)
|
|
1240
|
+
if relocation_errors:
|
|
1241
|
+
return _blocked_export_validation(
|
|
1242
|
+
export_path,
|
|
1243
|
+
wiki_dir,
|
|
1244
|
+
blocked_reason="related_notes_vault_mismatch",
|
|
1245
|
+
next_action=(
|
|
1246
|
+
"Conferir o export do Related Notes: o caminho do vault mudou e a validação local "
|
|
1247
|
+
"não conseguiu provar que o export cobre esta Wiki."
|
|
1248
|
+
),
|
|
1249
|
+
export_relocation=_export_relocation_payload(
|
|
1250
|
+
status="rejected",
|
|
1251
|
+
proof="relative_paths_and_representation_hashes",
|
|
1252
|
+
reason="coverage_or_location_mismatch",
|
|
1253
|
+
note_count=len(notes),
|
|
1254
|
+
errors=relocation_errors,
|
|
1255
|
+
),
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
edges_result = _parse_export_edges(contract, notes)
|
|
1259
|
+
if isinstance(edges_result, _RelatedNotesParseBlocked):
|
|
1260
|
+
return _blocked_parse_payload(export_path, wiki_dir, edges_result)
|
|
1261
|
+
export_relocation: JsonObject = {}
|
|
1262
|
+
if vault_root_mismatch:
|
|
1263
|
+
export_relocation = _export_relocation_payload(
|
|
1264
|
+
status="accepted",
|
|
1265
|
+
proof="relative_paths_and_representation_hashes",
|
|
1266
|
+
reason="vault_root_changed_but_relative_export_matches_current_wiki",
|
|
1267
|
+
note_count=len(notes),
|
|
1268
|
+
errors=[],
|
|
1269
|
+
)
|
|
1270
|
+
result = _ready_export_validation(
|
|
1271
|
+
export_path,
|
|
1272
|
+
wiki_dir,
|
|
1273
|
+
payload=payload,
|
|
1274
|
+
notes=notes,
|
|
1275
|
+
edges=edges_result.edges,
|
|
1276
|
+
hash_warnings=_json_object_list(hash_errors[:12]) if hash_errors else [],
|
|
1277
|
+
export_relocation=export_relocation,
|
|
1278
|
+
)
|
|
1279
|
+
if cache_key is not None:
|
|
1280
|
+
_store_export_validation_result(cache_key, result)
|
|
1281
|
+
return _clone_export_validation_result(result)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _related_notes_export_cache_key(
|
|
1285
|
+
export_path: Path,
|
|
1286
|
+
wiki_dir: Path,
|
|
1287
|
+
*,
|
|
1288
|
+
max_age_hours: float,
|
|
1289
|
+
allow_stale_note_hashes: bool,
|
|
1290
|
+
) -> _RelatedNotesExportCacheKey | None:
|
|
1291
|
+
try:
|
|
1292
|
+
stat = export_path.stat()
|
|
1293
|
+
except OSError:
|
|
1294
|
+
return None
|
|
1295
|
+
return _RelatedNotesExportCacheKey(
|
|
1296
|
+
export_path=str(export_path.resolve(strict=False)),
|
|
1297
|
+
wiki_dir=str(wiki_dir.resolve(strict=False)),
|
|
1298
|
+
mtime_ns=stat.st_mtime_ns,
|
|
1299
|
+
size=stat.st_size,
|
|
1300
|
+
max_age_hours=float(max_age_hours),
|
|
1301
|
+
allow_stale_note_hashes=allow_stale_note_hashes,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _store_export_validation_result(key: _RelatedNotesExportCacheKey, result: _RelatedNotesExportValidation) -> None:
|
|
1306
|
+
if len(_RELATED_NOTES_EXPORT_CACHE) >= _RELATED_NOTES_EXPORT_CACHE_MAX_ENTRIES:
|
|
1307
|
+
_RELATED_NOTES_EXPORT_CACHE.clear()
|
|
1308
|
+
_RELATED_NOTES_EXPORT_CACHE[key] = _clone_export_validation_result(result)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _clone_export_validation_result(result: _RelatedNotesExportValidation) -> _RelatedNotesExportValidation:
|
|
1312
|
+
return result.model_copy(
|
|
1313
|
+
update={
|
|
1314
|
+
"payload": dict(result.payload),
|
|
1315
|
+
"notes": dict(result.notes),
|
|
1316
|
+
"edges": list(result.edges),
|
|
1317
|
+
"hash_errors": list(result.hash_errors),
|
|
1318
|
+
"stale_notes": list(result.stale_notes),
|
|
1319
|
+
"validation_errors": list(result.validation_errors),
|
|
1320
|
+
"hash_warnings": list(result.hash_warnings),
|
|
1321
|
+
"export_relocation": dict(result.export_relocation),
|
|
1322
|
+
"extra_payload": dict(result.extra_payload),
|
|
1323
|
+
}
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _parse_export_notes(contract: RelatedNotesExport, wiki_dir: Path) -> _RelatedNotesNotesParseResult:
|
|
1328
|
+
"""Validate exported note paths against the current vault."""
|
|
1329
|
+
|
|
1330
|
+
notes: dict[str, RelatedNote] = {}
|
|
1331
|
+
errors: list[JsonObject] = []
|
|
1332
|
+
stale_notes: list[JsonObject] = []
|
|
1333
|
+
for item in contract.notes:
|
|
1334
|
+
rel = _safe_relative_path(item.path)
|
|
1335
|
+
if rel is None:
|
|
1336
|
+
errors.append({"path": item.path, "error": "path must be relative inside wiki"})
|
|
1337
|
+
continue
|
|
1338
|
+
abs_path = (wiki_dir / rel).resolve(strict=False)
|
|
1339
|
+
if not _is_inside(abs_path, wiki_dir):
|
|
1340
|
+
errors.append({"path": item.path, "error": "path escapes wiki_dir"})
|
|
1341
|
+
continue
|
|
1342
|
+
if not abs_path.is_file():
|
|
1343
|
+
stale_notes.append(
|
|
1344
|
+
{
|
|
1345
|
+
"path": rel.as_posix(),
|
|
1346
|
+
"expected_hash": _normalize_hash(item.content_hash),
|
|
1347
|
+
"actual_hash": "missing",
|
|
1348
|
+
}
|
|
1349
|
+
)
|
|
1350
|
+
errors.append({"path": rel.as_posix(), "error": "note file missing"})
|
|
1351
|
+
continue
|
|
1352
|
+
notes[rel.as_posix()] = RelatedNote(
|
|
1353
|
+
rel_path=rel.as_posix(),
|
|
1354
|
+
abs_path=abs_path,
|
|
1355
|
+
title=item.title or abs_path.stem,
|
|
1356
|
+
content_hash=_normalize_hash(item.content_hash),
|
|
1357
|
+
)
|
|
1358
|
+
if errors:
|
|
1359
|
+
if stale_notes and len(stale_notes) == len(errors):
|
|
1360
|
+
return _RelatedNotesParseBlocked(
|
|
1361
|
+
blocked_reason="related_notes_export_stale",
|
|
1362
|
+
next_action=wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json"),
|
|
1363
|
+
validation_errors=errors[:20],
|
|
1364
|
+
stale_notes=stale_notes[:20],
|
|
1365
|
+
)
|
|
1366
|
+
return _RelatedNotesParseBlocked(
|
|
1367
|
+
blocked_reason="related_notes_note_path_invalid",
|
|
1368
|
+
next_action="Corrigir paths relativos e hashes no export do plugin.",
|
|
1369
|
+
validation_errors=errors[:20],
|
|
1370
|
+
stale_notes=[],
|
|
1371
|
+
)
|
|
1372
|
+
return _RelatedNotesParsedNotes(notes=notes)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _parse_export_edges(
|
|
1376
|
+
contract: RelatedNotesExport,
|
|
1377
|
+
notes: dict[str, RelatedNote],
|
|
1378
|
+
) -> _RelatedNotesEdgesParseResult:
|
|
1379
|
+
"""Validate exported graph edges against the typed note map."""
|
|
1380
|
+
|
|
1381
|
+
edges: list[RelatedEdge] = []
|
|
1382
|
+
errors: list[JsonObject] = []
|
|
1383
|
+
for item in contract.edges:
|
|
1384
|
+
source_path = _safe_relative_path_string(item.source_path)
|
|
1385
|
+
target_path = _safe_relative_path_string(item.target_path)
|
|
1386
|
+
if not source_path or not target_path:
|
|
1387
|
+
errors.append({"edge": f"{item.source_path}->{item.target_path}", "error": "source_path and target_path must be relative"})
|
|
1388
|
+
continue
|
|
1389
|
+
if source_path not in notes or target_path not in notes:
|
|
1390
|
+
errors.append({"edge": f"{source_path}->{target_path}", "error": "edge references note missing from notes[]"})
|
|
1391
|
+
continue
|
|
1392
|
+
edges.append(
|
|
1393
|
+
RelatedEdge(
|
|
1394
|
+
source_path=source_path,
|
|
1395
|
+
target_path=target_path,
|
|
1396
|
+
score=item.score,
|
|
1397
|
+
rank=item.rank,
|
|
1398
|
+
source=item.source,
|
|
1399
|
+
)
|
|
1400
|
+
)
|
|
1401
|
+
if errors:
|
|
1402
|
+
return _RelatedNotesParseBlocked(
|
|
1403
|
+
blocked_reason="related_notes_edge_invalid",
|
|
1404
|
+
next_action="Corrigir edges para apontar apenas para notes[] válidas.",
|
|
1405
|
+
validation_errors=errors[:20],
|
|
1406
|
+
stale_notes=[],
|
|
1407
|
+
)
|
|
1408
|
+
return _RelatedNotesParsedEdges(edges=edges)
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _relocated_export_verification_errors(
|
|
1412
|
+
*,
|
|
1413
|
+
export_path: Path,
|
|
1414
|
+
wiki_dir: Path,
|
|
1415
|
+
notes: dict[str, RelatedNote],
|
|
1416
|
+
) -> list[JsonObject]:
|
|
1417
|
+
errors: list[JsonObject] = []
|
|
1418
|
+
if not _is_inside(export_path.resolve(strict=False), wiki_dir):
|
|
1419
|
+
errors.append({"code": "export_not_inside_current_wiki"})
|
|
1420
|
+
|
|
1421
|
+
exported_paths = set(notes)
|
|
1422
|
+
current_paths: set[str] = set()
|
|
1423
|
+
for path in iter_notes(wiki_dir):
|
|
1424
|
+
text = path.read_text(encoding="utf-8")
|
|
1425
|
+
if _is_index_note(path, text):
|
|
1426
|
+
continue
|
|
1427
|
+
current_paths.add(path.relative_to(wiki_dir).as_posix())
|
|
1428
|
+
|
|
1429
|
+
missing_paths = sorted(current_paths - exported_paths)
|
|
1430
|
+
extra_paths = sorted(exported_paths - current_paths)
|
|
1431
|
+
if not exported_paths and current_paths:
|
|
1432
|
+
errors.append({"code": "export_has_no_notes", "current_note_count": len(current_paths)})
|
|
1433
|
+
if missing_paths:
|
|
1434
|
+
errors.append({"code": "export_missing_current_notes", "paths": missing_paths[:20]})
|
|
1435
|
+
if extra_paths:
|
|
1436
|
+
errors.append({"code": "export_contains_non_current_notes", "paths": extra_paths[:20]})
|
|
1437
|
+
return errors
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _export_relocation_payload(
|
|
1441
|
+
*,
|
|
1442
|
+
status: str,
|
|
1443
|
+
proof: str,
|
|
1444
|
+
reason: str,
|
|
1445
|
+
note_count: int,
|
|
1446
|
+
errors: list[JsonObject],
|
|
1447
|
+
) -> JsonObject:
|
|
1448
|
+
return JsonObjectAdapter.validate_python(
|
|
1449
|
+
{
|
|
1450
|
+
"schema": "medical-notes-workbench.related-notes-export-relocation.v1",
|
|
1451
|
+
"status": status,
|
|
1452
|
+
"proof": proof,
|
|
1453
|
+
"reason": reason,
|
|
1454
|
+
"note_count": note_count,
|
|
1455
|
+
"uses_absolute_path_for_hash": False,
|
|
1456
|
+
"api_calls": 0,
|
|
1457
|
+
"embedding_calls": 0,
|
|
1458
|
+
"errors": errors,
|
|
1459
|
+
}
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _blocked_parse_payload(
|
|
1464
|
+
export_path: Path,
|
|
1465
|
+
wiki_dir: Path,
|
|
1466
|
+
parse_result: _RelatedNotesParseBlocked | object,
|
|
1467
|
+
) -> _RelatedNotesExportValidation:
|
|
1468
|
+
typed_result = (
|
|
1469
|
+
parse_result
|
|
1470
|
+
if isinstance(parse_result, _RelatedNotesParseBlocked)
|
|
1471
|
+
else _RelatedNotesBlockedParseInput.model_validate(parse_result).to_result()
|
|
1472
|
+
)
|
|
1473
|
+
return _blocked_export_validation(
|
|
1474
|
+
export_path,
|
|
1475
|
+
wiki_dir,
|
|
1476
|
+
blocked_reason=typed_result.blocked_reason or "related_notes_export_invalid",
|
|
1477
|
+
next_action=typed_result.next_action or "Corrigir o export do plugin Related Notes.",
|
|
1478
|
+
validation_errors=typed_result.validation_errors,
|
|
1479
|
+
stale_notes=typed_result.stale_notes,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _plan_related_note_updates(
|
|
1484
|
+
wiki_dir: Path,
|
|
1485
|
+
notes: dict[str, RelatedNote],
|
|
1486
|
+
edges: list[RelatedEdge],
|
|
1487
|
+
*,
|
|
1488
|
+
min_score: float,
|
|
1489
|
+
max_links: int,
|
|
1490
|
+
) -> _RelatedNotesUpdatePlan:
|
|
1491
|
+
updates: list[_RelatedNotesPlannedUpdate] = []
|
|
1492
|
+
skipped_edges: list[_RelatedNotesSkippedEdge] = []
|
|
1493
|
+
by_source: dict[str, list[RelatedEdge]] = {}
|
|
1494
|
+
for edge in edges:
|
|
1495
|
+
by_source.setdefault(edge.source_path, []).append(edge)
|
|
1496
|
+
|
|
1497
|
+
title_counts: dict[str, int] = {}
|
|
1498
|
+
for note in notes.values():
|
|
1499
|
+
title_key = _link_key(note.title or note.abs_path.stem)
|
|
1500
|
+
title_counts[title_key] = (title_counts[title_key] if title_key in title_counts else 0) + 1
|
|
1501
|
+
|
|
1502
|
+
for index, (source_path, note) in enumerate(sorted(notes.items()), start=1):
|
|
1503
|
+
cooperative_cpu_yield(index)
|
|
1504
|
+
source_edges = by_source[source_path] if source_path in by_source else []
|
|
1505
|
+
text = note.abs_path.read_text(encoding="utf-8")
|
|
1506
|
+
span = _related_section_span(text)
|
|
1507
|
+
if span is None:
|
|
1508
|
+
skipped_edges.extend(
|
|
1509
|
+
_RelatedNotesSkippedEdge(
|
|
1510
|
+
source_path=edge.source_path,
|
|
1511
|
+
target_path=edge.target_path,
|
|
1512
|
+
reason="missing_related_section",
|
|
1513
|
+
)
|
|
1514
|
+
for edge in source_edges
|
|
1515
|
+
)
|
|
1516
|
+
if not source_edges:
|
|
1517
|
+
skipped_edges.append(_RelatedNotesSkippedEdge(source_path=source_path, reason="missing_related_section"))
|
|
1518
|
+
continue
|
|
1519
|
+
existing_targets = _existing_related_targets(text[span[1] : span[2]])
|
|
1520
|
+
proposed: list[_RelatedNotesProposedLink] = []
|
|
1521
|
+
for edge in sorted(
|
|
1522
|
+
source_edges,
|
|
1523
|
+
key=lambda item: (-item.score, item.rank, _link_key(notes[item.target_path].title), item.target_path),
|
|
1524
|
+
):
|
|
1525
|
+
target = notes[edge.target_path]
|
|
1526
|
+
target_key = _link_key(target.title or target.abs_path.stem)
|
|
1527
|
+
if _link_key(note.title) == target_key:
|
|
1528
|
+
skipped_edges.append(
|
|
1529
|
+
_RelatedNotesSkippedEdge(
|
|
1530
|
+
source_path=edge.source_path,
|
|
1531
|
+
target_path=edge.target_path,
|
|
1532
|
+
reason="self_link",
|
|
1533
|
+
)
|
|
1534
|
+
)
|
|
1535
|
+
continue
|
|
1536
|
+
if edge.score < min_score:
|
|
1537
|
+
skipped_edges.append(
|
|
1538
|
+
_RelatedNotesSkippedEdge(
|
|
1539
|
+
source_path=edge.source_path,
|
|
1540
|
+
target_path=edge.target_path,
|
|
1541
|
+
reason="below_min_score",
|
|
1542
|
+
score=f"{edge.score:.4f}",
|
|
1543
|
+
)
|
|
1544
|
+
)
|
|
1545
|
+
continue
|
|
1546
|
+
if len(proposed) >= max_links:
|
|
1547
|
+
skipped_edges.append(
|
|
1548
|
+
_RelatedNotesSkippedEdge(
|
|
1549
|
+
source_path=edge.source_path,
|
|
1550
|
+
target_path=edge.target_path,
|
|
1551
|
+
reason="max_links_reached",
|
|
1552
|
+
)
|
|
1553
|
+
)
|
|
1554
|
+
continue
|
|
1555
|
+
proposed.append(
|
|
1556
|
+
_RelatedNotesProposedLink(
|
|
1557
|
+
target_path=target.rel_path,
|
|
1558
|
+
target_title=target.title,
|
|
1559
|
+
score=edge.score,
|
|
1560
|
+
rank=edge.rank,
|
|
1561
|
+
source=edge.source,
|
|
1562
|
+
content_hash=target.content_hash,
|
|
1563
|
+
line=_render_link_line(target, title_counts),
|
|
1564
|
+
)
|
|
1565
|
+
)
|
|
1566
|
+
new_text = _render_related_section_update(text, span, proposed)
|
|
1567
|
+
updates.append(
|
|
1568
|
+
_RelatedNotesPlannedUpdate(
|
|
1569
|
+
file=str(note.abs_path),
|
|
1570
|
+
relative_path=note.rel_path,
|
|
1571
|
+
source_title=note.title,
|
|
1572
|
+
content_hash=note.content_hash,
|
|
1573
|
+
cleared_links=sorted(existing_targets),
|
|
1574
|
+
cleared_link_count=len(existing_targets),
|
|
1575
|
+
proposed_links=proposed,
|
|
1576
|
+
new_content=new_text,
|
|
1577
|
+
changed=new_text != text,
|
|
1578
|
+
min_score=min_score,
|
|
1579
|
+
)
|
|
1580
|
+
)
|
|
1581
|
+
public_updates = [item.public_update() for item in updates if item.changed]
|
|
1582
|
+
return _RelatedNotesUpdatePlan(
|
|
1583
|
+
wiki_dir=str(wiki_dir),
|
|
1584
|
+
updates=public_updates,
|
|
1585
|
+
private_updates=updates,
|
|
1586
|
+
skipped_edges=skipped_edges,
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _related_section_span(text: str) -> tuple[int, int, int, int] | None:
|
|
1591
|
+
match = _RELATED_HEADING_RE.search(text)
|
|
1592
|
+
if not match:
|
|
1593
|
+
return None
|
|
1594
|
+
next_h2 = _NEXT_H2_RE.search(text, match.end())
|
|
1595
|
+
footer = _FOOTER_RE.search(text, match.end())
|
|
1596
|
+
candidates = [item.start() for item in (next_h2, footer) if item is not None]
|
|
1597
|
+
end = min(candidates) if candidates else len(text)
|
|
1598
|
+
return match.start(), match.end(), end, match.end()
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _existing_related_targets(section_body: str) -> set[str]:
|
|
1602
|
+
targets: set[str] = set()
|
|
1603
|
+
for match in _WIKILINK_RE.finditer(section_body):
|
|
1604
|
+
raw = match.group(1).split("|", 1)[0].split("#", 1)[0].strip()
|
|
1605
|
+
if raw:
|
|
1606
|
+
targets.add(_link_key(obsidian_target_name(raw)))
|
|
1607
|
+
return targets
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _notes_by_target(wiki_dir: Path) -> dict[str, list[str]]:
|
|
1611
|
+
notes: dict[str, list[str]] = {}
|
|
1612
|
+
for path in iter_notes(wiki_dir):
|
|
1613
|
+
text = path.read_text(encoding="utf-8")
|
|
1614
|
+
if _is_index_note(path, text):
|
|
1615
|
+
continue
|
|
1616
|
+
relative = path.relative_to(wiki_dir).as_posix()
|
|
1617
|
+
notes.setdefault(normalize_key(path.stem), []).append(relative)
|
|
1618
|
+
return notes
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
class _RelatedSectionCleanupReport(ContractModel):
|
|
1622
|
+
"""Report for deterministic cleanup of invalid Related Notes links."""
|
|
1623
|
+
|
|
1624
|
+
removed_links: list[JsonObject] = Field(default_factory=list)
|
|
1625
|
+
kept_link_count: int = Field(default=0, ge=0)
|
|
1626
|
+
cleared_to_marker: bool = Field(default=False, strict=True)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _clean_related_section_text(
|
|
1630
|
+
text: str,
|
|
1631
|
+
span: tuple[int, int, int, int],
|
|
1632
|
+
*,
|
|
1633
|
+
source_relative_path: str,
|
|
1634
|
+
notes_by_target: dict[str, list[str]],
|
|
1635
|
+
) -> tuple[str, _RelatedSectionCleanupReport]:
|
|
1636
|
+
heading_start, _heading_end, section_end, content_start = span
|
|
1637
|
+
section_body = text[content_start:section_end]
|
|
1638
|
+
kept_lines: list[str] = []
|
|
1639
|
+
removed_links: list[JsonObject] = []
|
|
1640
|
+
seen: set[str] = set()
|
|
1641
|
+
for match in _WIKILINK_RE.finditer(section_body):
|
|
1642
|
+
raw = match.group(1).strip()
|
|
1643
|
+
target = obsidian_target_name(raw.split("|", 1)[0].split("#", 1)[0].strip())
|
|
1644
|
+
target_key = normalize_key(target)
|
|
1645
|
+
target_paths = notes_by_target[target_key] if target_key in notes_by_target else []
|
|
1646
|
+
reason = ""
|
|
1647
|
+
if not target or is_index_target(target):
|
|
1648
|
+
reason = "not_note_target"
|
|
1649
|
+
elif not target_paths:
|
|
1650
|
+
reason = "dangling_link"
|
|
1651
|
+
elif len(target_paths) > 1:
|
|
1652
|
+
reason = "ambiguous_link"
|
|
1653
|
+
elif target_paths[0] == source_relative_path:
|
|
1654
|
+
reason = "self_link"
|
|
1655
|
+
elif target_key in seen:
|
|
1656
|
+
reason = "duplicate_related_link"
|
|
1657
|
+
if reason:
|
|
1658
|
+
removed_links.append({"target": target, "raw": raw, "reason": reason})
|
|
1659
|
+
continue
|
|
1660
|
+
seen.add(target_key)
|
|
1661
|
+
kept_lines.append(f"- [[{raw}]]")
|
|
1662
|
+
rendered_lines = kept_lines or [f"- {NO_STRONG_LINKS_MARKER}"]
|
|
1663
|
+
section = "## 🔗 Notas Relacionadas\n" + "\n".join(rendered_lines).rstrip() + "\n\n"
|
|
1664
|
+
report = _RelatedSectionCleanupReport(
|
|
1665
|
+
removed_links=removed_links,
|
|
1666
|
+
kept_link_count=len(kept_lines),
|
|
1667
|
+
cleared_to_marker=not kept_lines,
|
|
1668
|
+
)
|
|
1669
|
+
return text[:heading_start] + section + text[section_end:], report
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def _render_link_line(target: RelatedNote, title_counts: dict[str, int]) -> str:
|
|
1673
|
+
title = target.title or target.abs_path.stem
|
|
1674
|
+
title_key = _link_key(title)
|
|
1675
|
+
if (title_counts[title_key] if title_key in title_counts else 0) > 1:
|
|
1676
|
+
target_without_suffix = target.rel_path[:-3] if target.rel_path.endswith(".md") else target.rel_path
|
|
1677
|
+
return f"- [[{target_without_suffix}|{title}]]"
|
|
1678
|
+
return f"- [[{title}]]"
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def _render_related_section_update(
|
|
1682
|
+
text: str,
|
|
1683
|
+
span: tuple[int, int, int, int],
|
|
1684
|
+
proposed: list[_RelatedNotesProposedLink],
|
|
1685
|
+
) -> str:
|
|
1686
|
+
heading_start, _heading_end, section_end, content_start = span
|
|
1687
|
+
lines = [item.line for item in proposed] or [f"- {NO_STRONG_LINKS_MARKER}"]
|
|
1688
|
+
section = "## 🔗 Notas Relacionadas\n" + "\n".join(lines).rstrip() + "\n\n"
|
|
1689
|
+
return text[:heading_start] + section + text[section_end:]
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def _apply_updates(public_updates: list[_RelatedNotesPlannedUpdate], *, backup: bool) -> list[JsonObject]:
|
|
1693
|
+
applied: list[JsonObject] = []
|
|
1694
|
+
for update in public_updates:
|
|
1695
|
+
path = Path(update.file)
|
|
1696
|
+
atomic_write_text(path, update.new_content)
|
|
1697
|
+
public_update = update.public_update().to_payload()
|
|
1698
|
+
applied.append(
|
|
1699
|
+
{
|
|
1700
|
+
**public_update,
|
|
1701
|
+
"backup_path": "",
|
|
1702
|
+
"applied": True,
|
|
1703
|
+
}
|
|
1704
|
+
)
|
|
1705
|
+
return applied
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def _write_receipt(
|
|
1709
|
+
path: Path,
|
|
1710
|
+
*,
|
|
1711
|
+
export_path: Path,
|
|
1712
|
+
wiki_dir: Path,
|
|
1713
|
+
export_payload: JsonObject,
|
|
1714
|
+
plan: JsonObject,
|
|
1715
|
+
applied_updates: list[JsonObject],
|
|
1716
|
+
) -> Path:
|
|
1717
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1718
|
+
typed_plan = JsonObjectAdapter.validate_python(plan)
|
|
1719
|
+
typed_updates = _RelatedNotesAppliedUpdatesPayload.model_validate({"updates": applied_updates}).updates
|
|
1720
|
+
typed_export = RelatedNotesExport.model_validate(export_payload)
|
|
1721
|
+
sync_result = LinkRelatedSyncResult.from_payload(typed_plan)
|
|
1722
|
+
receipt = _RelatedNotesSyncReceiptPayload(
|
|
1723
|
+
schema=RELATED_NOTES_SYNC_RECEIPT_SCHEMA,
|
|
1724
|
+
generated_at=_now_iso(),
|
|
1725
|
+
status=sync_result.status or "completed",
|
|
1726
|
+
phase="related_notes_apply",
|
|
1727
|
+
dry_run=False,
|
|
1728
|
+
no_resource_mutation=len(typed_updates) == 0,
|
|
1729
|
+
wiki_dir=str(wiki_dir),
|
|
1730
|
+
export_path=str(export_path),
|
|
1731
|
+
export_hash="sha256:" + file_sha256(export_path),
|
|
1732
|
+
export_generated_at=typed_export.generated_at.isoformat(),
|
|
1733
|
+
plugin=typed_export.plugin.to_payload(),
|
|
1734
|
+
model=typed_export.model_info.to_payload(),
|
|
1735
|
+
api_calls=0,
|
|
1736
|
+
api_failures=0,
|
|
1737
|
+
plan_hash="sha256:" + canonical_json_hash({key: value for key, value in typed_plan.items() if key != "updates"}),
|
|
1738
|
+
applied_note_count=len(typed_updates),
|
|
1739
|
+
update_count=len(typed_updates),
|
|
1740
|
+
updates=typed_updates,
|
|
1741
|
+
).to_payload()
|
|
1742
|
+
atomic_write_text(path, json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
|
|
1743
|
+
return path
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _base_payload(
|
|
1747
|
+
export_path: Path,
|
|
1748
|
+
wiki_dir: Path,
|
|
1749
|
+
*,
|
|
1750
|
+
status: str,
|
|
1751
|
+
phase: str,
|
|
1752
|
+
blocked_reason: str,
|
|
1753
|
+
next_action: str,
|
|
1754
|
+
extra: JsonObject | None = None,
|
|
1755
|
+
) -> JsonObject:
|
|
1756
|
+
selected_recovery_mode = (
|
|
1757
|
+
"reindex-vault"
|
|
1758
|
+
if blocked_reason in {"related_notes_hash_mismatch", "related_notes_export_stale"}
|
|
1759
|
+
else "manual"
|
|
1760
|
+
)
|
|
1761
|
+
extra_payload = JsonObjectAdapter.validate_python(extra or {})
|
|
1762
|
+
return JsonObjectAdapter.validate_python({
|
|
1763
|
+
"schema": RELATED_NOTES_SYNC_SCHEMA,
|
|
1764
|
+
"phase": phase,
|
|
1765
|
+
"status": status,
|
|
1766
|
+
"blocked_reason": blocked_reason,
|
|
1767
|
+
"next_action": next_action,
|
|
1768
|
+
"required_inputs": RELATED_NOTES_REQUIRED_INPUTS,
|
|
1769
|
+
"human_decision_required": False,
|
|
1770
|
+
"manual_instruction_allowed": _manual_instruction_allowed(blocked_reason),
|
|
1771
|
+
"selected_recovery_mode": selected_recovery_mode,
|
|
1772
|
+
"retry_command": wiki_cli_command("run-linker", "--diagnose", "--json"),
|
|
1773
|
+
"wiki_dir": str(wiki_dir),
|
|
1774
|
+
"export_path": str(export_path),
|
|
1775
|
+
**extra_payload,
|
|
1776
|
+
})
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _plan_summary(plan: _RelatedNotesUpdatePlan) -> dict[str, int]:
|
|
1780
|
+
return plan.summary()
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _contract_errors(exc: PydanticValidationError) -> list[JsonObject]:
|
|
1784
|
+
errors: list[JsonObject] = []
|
|
1785
|
+
for item in exc.errors()[:20]:
|
|
1786
|
+
loc_value = item["loc"] if "loc" in item else ()
|
|
1787
|
+
loc = ".".join(str(part) for part in loc_value) or "$"
|
|
1788
|
+
errors.append(
|
|
1789
|
+
{
|
|
1790
|
+
"loc": loc,
|
|
1791
|
+
"message": str(item["msg"] if "msg" in item else ""),
|
|
1792
|
+
"type": str(item["type"] if "type" in item else ""),
|
|
1793
|
+
}
|
|
1794
|
+
)
|
|
1795
|
+
return errors
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _export_profile_id(contract: RelatedNotesExport) -> str:
|
|
1799
|
+
"""Read the embedding profile from the validated export contract."""
|
|
1800
|
+
|
|
1801
|
+
return normalize_related_notes_profile_id(contract.model_info.embedding_profile_id)
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _hash_errors(notes: dict[str, RelatedNote], *, profile_id: str) -> list[dict[str, str]]:
|
|
1805
|
+
errors: list[dict[str, str]] = []
|
|
1806
|
+
for note in notes.values():
|
|
1807
|
+
markdown = note.abs_path.read_text(encoding="utf-8")
|
|
1808
|
+
actual = related_notes_content_hash(
|
|
1809
|
+
path=note.rel_path,
|
|
1810
|
+
title=note.title or note.abs_path.stem,
|
|
1811
|
+
markdown=markdown,
|
|
1812
|
+
profile_id=profile_id,
|
|
1813
|
+
)
|
|
1814
|
+
if actual.lower() != note.content_hash.lower():
|
|
1815
|
+
legacy = (
|
|
1816
|
+
related_notes_legacy_clean_v1_content_hash(
|
|
1817
|
+
path=note.rel_path,
|
|
1818
|
+
title=note.title or note.abs_path.stem,
|
|
1819
|
+
markdown=markdown,
|
|
1820
|
+
)
|
|
1821
|
+
if profile_id == "clean_v1"
|
|
1822
|
+
else ""
|
|
1823
|
+
)
|
|
1824
|
+
if legacy and legacy.lower() == note.content_hash.lower():
|
|
1825
|
+
continue
|
|
1826
|
+
errors.append(
|
|
1827
|
+
{
|
|
1828
|
+
"path": note.rel_path,
|
|
1829
|
+
"expected": note.content_hash,
|
|
1830
|
+
"actual": actual,
|
|
1831
|
+
"hash_basis": "representation_hash",
|
|
1832
|
+
"embedding_profile_id": profile_id,
|
|
1833
|
+
}
|
|
1834
|
+
)
|
|
1835
|
+
return errors
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
def _safe_relative_path(value: str) -> PurePosixPath | None:
|
|
1839
|
+
text = value.strip().replace("\\", "/")
|
|
1840
|
+
if not text or text.startswith(("/", "../")) or _WINDOWS_ABSOLUTE_RE.match(text):
|
|
1841
|
+
return None
|
|
1842
|
+
rel = PurePosixPath(text)
|
|
1843
|
+
if any(part in {"", ".", ".."} for part in rel.parts):
|
|
1844
|
+
return None
|
|
1845
|
+
return rel
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _safe_relative_path_string(value: str) -> str:
|
|
1849
|
+
rel = _safe_relative_path(value)
|
|
1850
|
+
return rel.as_posix() if rel is not None else ""
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _is_inside(path: Path, root: Path) -> bool:
|
|
1854
|
+
try:
|
|
1855
|
+
path.resolve(strict=False).relative_to(root.resolve(strict=False))
|
|
1856
|
+
return True
|
|
1857
|
+
except ValueError:
|
|
1858
|
+
return False
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def _same_root(value: str, wiki_dir: Path) -> bool:
|
|
1862
|
+
if not value:
|
|
1863
|
+
return False
|
|
1864
|
+
if value in {".", "./"}:
|
|
1865
|
+
return True
|
|
1866
|
+
if _WINDOWS_ABSOLUTE_RE.match(value) and not _WINDOWS_ABSOLUTE_RE.match(str(wiki_dir)):
|
|
1867
|
+
return False
|
|
1868
|
+
try:
|
|
1869
|
+
return Path(value).expanduser().resolve(strict=False) == wiki_dir.resolve(strict=False)
|
|
1870
|
+
except OSError:
|
|
1871
|
+
return False
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _normalize_hash(value: str) -> str:
|
|
1875
|
+
text = value.strip().lower()
|
|
1876
|
+
if not text:
|
|
1877
|
+
return ""
|
|
1878
|
+
return text if text.startswith("sha256:") else "sha256:" + text
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def _find_forbidden_export_keys(value: object, *, prefix: str = "") -> list[str]:
|
|
1882
|
+
found: list[str] = []
|
|
1883
|
+
if isinstance(value, dict):
|
|
1884
|
+
for key, item in value.items():
|
|
1885
|
+
key_text = str(key)
|
|
1886
|
+
normalized = re.sub(r"[^a-z0-9]", "", key_text.lower())
|
|
1887
|
+
path = f"{prefix}.{key_text}" if prefix else key_text
|
|
1888
|
+
if normalized in _FORBIDDEN_EXPORT_KEYS:
|
|
1889
|
+
found.append(path)
|
|
1890
|
+
found.extend(_find_forbidden_export_keys(item, prefix=path))
|
|
1891
|
+
elif isinstance(value, list):
|
|
1892
|
+
for index, item in enumerate(value[:100]):
|
|
1893
|
+
found.extend(_find_forbidden_export_keys(item, prefix=f"{prefix}[{index}]"))
|
|
1894
|
+
return found
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def _staleness_error(value: str, *, max_age_hours: float) -> str:
|
|
1898
|
+
if max_age_hours <= 0:
|
|
1899
|
+
return ""
|
|
1900
|
+
try:
|
|
1901
|
+
generated = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1902
|
+
except ValueError:
|
|
1903
|
+
return "generated_at is not valid ISO-8601"
|
|
1904
|
+
if generated.tzinfo is None:
|
|
1905
|
+
generated = generated.replace(tzinfo=UTC)
|
|
1906
|
+
age_seconds = (datetime.now(UTC) - generated).total_seconds()
|
|
1907
|
+
if age_seconds < 0:
|
|
1908
|
+
return ""
|
|
1909
|
+
if age_seconds > max_age_hours * 3600:
|
|
1910
|
+
return f"export age is {age_seconds / 3600:.1f}h"
|
|
1911
|
+
return ""
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def _link_key(value: str) -> str:
|
|
1915
|
+
return re.sub(r"\s+", " ", str(value).strip()).casefold()
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _default_receipt_path() -> Path:
|
|
1919
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
1920
|
+
return user_state_dir() / "runs" / stamp / "related-notes-receipt.json"
|