mednotes-opencode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/agents/med-chat-triager.md +204 -0
- package/.opencode/agents/med-flashcard-maker.md +63 -0
- package/.opencode/agents/med-knowledge-architect.md +230 -0
- package/.opencode/agents/med-link-graph-curator.md +177 -0
- package/.opencode/agents/med-publish-guard.md +62 -0
- package/.opencode/commands/flashcards.md +25 -0
- package/.opencode/commands/mednotes/create.md +25 -0
- package/.opencode/commands/mednotes/enrich.md +27 -0
- package/.opencode/commands/mednotes/fix-wiki.md +27 -0
- package/.opencode/commands/mednotes/history.md +22 -0
- package/.opencode/commands/mednotes/link-body.md +25 -0
- package/.opencode/commands/mednotes/link-related.md +27 -0
- package/.opencode/commands/mednotes/link.md +27 -0
- package/.opencode/commands/mednotes/pdf-library.md +27 -0
- package/.opencode/commands/mednotes/process-chats.md +23 -0
- package/.opencode/commands/mednotes/setup.md +21 -0
- package/.opencode/commands/mednotes/status.md +27 -0
- package/.opencode/commands/mednotes/telemetry.md +27 -0
- package/.opencode/commands/report.md +26 -0
- package/.opencode/mednotes/AGENTS.md +57 -0
- package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
- package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
- package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
- package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
- package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
- package/.opencode/mednotes/contracts/.gitkeep +1 -0
- package/.opencode/mednotes/contracts/agents.json +116 -0
- package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
- package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
- package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
- package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
- package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
- package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
- package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
- package/.opencode/mednotes/docs/extension-docs.md +40 -0
- package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
- package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
- package/.opencode/mednotes/docs/merge-policy.md +110 -0
- package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
- package/.opencode/mednotes/docs/semantic-linker.md +141 -0
- package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
- package/.opencode/mednotes/docs/triage-policy.md +187 -0
- package/.opencode/mednotes/docs/vault-version-control.md +758 -0
- package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
- package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
- package/.opencode/mednotes/hooks/hooks.json +79 -0
- package/.opencode/mednotes/package-lock.json +6361 -0
- package/.opencode/mednotes/package.json +15 -0
- package/.opencode/mednotes/pyproject.toml +48 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
- package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
- package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
- package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
- package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
- package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
- package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
- package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
- package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
- package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
- package/.opencode/mednotes/scripts/run_python.mjs +109 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
- package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
- package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
- package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
- package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
- package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
- package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
- package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
- package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
- package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
- package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
- package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
- package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
- package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
- package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
- package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
- package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
- package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
- package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
- package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
- package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
- package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
- package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
- package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
- package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
- package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
- package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
- package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
- package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
- package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
- package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
- package/.opencode/mednotes/uv.lock +932 -0
- package/.opencode/mednotes.generated.json +395 -0
- package/.opencode/opencode.json +31 -0
- package/.opencode/plugins/mednotes-fsm.mjs +7 -0
- package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
- package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
- package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
- package/AGENTS.md +57 -0
- package/README.md +194 -0
- package/adapters/antigravity/agents.json +80 -0
- package/adapters/antigravity/templates/med-chat-triager.md +214 -0
- package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
- package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
- package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
- package/adapters/antigravity/templates/med-publish-guard.md +71 -0
- package/adapters/gemini-cli/gemini-extension.json +14 -0
- package/adapters/gemini-cli/package.json +15 -0
- package/adapters/gemini-cli/pyproject.toml +48 -0
- package/bin/mednotes-opencode.mjs +155 -0
- package/contracts/agents.json +116 -0
- package/core/agents/med-chat-triager.md +197 -0
- package/core/agents/med-flashcard-maker.md +56 -0
- package/core/agents/med-knowledge-architect.md +224 -0
- package/core/agents/med-link-graph-curator.md +171 -0
- package/core/agents/med-publish-guard.md +55 -0
- package/core/commands/flashcards.toml +22 -0
- package/core/commands/mednotes/create.toml +22 -0
- package/core/commands/mednotes/enrich.toml +24 -0
- package/core/commands/mednotes/fix-wiki.toml +24 -0
- package/core/commands/mednotes/history.toml +19 -0
- package/core/commands/mednotes/link-body.toml +22 -0
- package/core/commands/mednotes/link-related.toml +24 -0
- package/core/commands/mednotes/link.toml +24 -0
- package/core/commands/mednotes/pdf-library.toml +24 -0
- package/core/commands/mednotes/process-chats.toml +20 -0
- package/core/commands/mednotes/setup.toml +18 -0
- package/core/commands/mednotes/status.toml +24 -0
- package/core/commands/mednotes/telemetry.toml +24 -0
- package/core/commands/report.toml +23 -0
- package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/core/skills/create-medical-flashcards/SKILL.md +113 -0
- package/core/skills/create-medical-note/SKILL.md +90 -0
- package/core/skills/enrich-medical-note/SKILL.md +120 -0
- package/core/skills/fix-medical-wiki/SKILL.md +559 -0
- package/core/skills/link-medical-wiki/SKILL.md +224 -0
- package/core/skills/obsidian-cli/SKILL.md +118 -0
- package/core/skills/obsidian-markdown/SKILL.md +207 -0
- package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/core/skills/obsidian-ops/SKILL.md +136 -0
- package/core/skills/pdf-library/SKILL.md +45 -0
- package/core/skills/process-medical-chats/SKILL.md +246 -0
- package/core/skills/workflow-report/SKILL.md +100 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
"""Headless Related Notes export compatible with the Obsidian plugin contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from mednotes.domains.wiki.capabilities.notes.provenance import CHAT_ORIGINAL_LABEL
|
|
20
|
+
from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
|
|
21
|
+
from mednotes.domains.wiki.contracts.related_notes import RelatedNotesExportNote, RelatedNotesHashMigrationExport
|
|
22
|
+
from mednotes.domains.wiki.contracts.related_notes_headless import (
|
|
23
|
+
GeminiBatchEmbeddingResponse,
|
|
24
|
+
GeminiEmbedding,
|
|
25
|
+
GeminiEmbeddingResponse,
|
|
26
|
+
GeminiErrorResponse,
|
|
27
|
+
RelatedNotesHashMigrationCache,
|
|
28
|
+
RelatedNotesHeadlessSettings,
|
|
29
|
+
RelatedNotesVectorIndex,
|
|
30
|
+
RelatedNotesVectorRecord,
|
|
31
|
+
)
|
|
32
|
+
from mednotes.domains.wiki.performance import cooperative_cpu_yield
|
|
33
|
+
from mednotes.kernel.base import JsonObjectAdapter, JsonValue
|
|
34
|
+
from mednotes.platform.paths import user_state_dir
|
|
35
|
+
|
|
36
|
+
RELATED_NOTES_EXPORT_SCHEMA = "medical-notes-workbench.related-notes-export.v1"
|
|
37
|
+
RELATED_NOTES_HASH_MIGRATION_CACHE_SCHEMA = "medical-notes-workbench.related-notes-hash-migration-cache.v1"
|
|
38
|
+
PLUGIN_ID = "related-notes-obsidian"
|
|
39
|
+
PLUGIN_EXPORT_NAME = "medical-notes-export.json"
|
|
40
|
+
PLUGIN_INDEX_NAME = "index.json"
|
|
41
|
+
PLUGIN_SETTINGS_NAME = "data.json"
|
|
42
|
+
DEFAULT_EMBEDDING_MODEL = "gemini-embedding-001"
|
|
43
|
+
DEFAULT_PROFILE_ID = "clean_v1"
|
|
44
|
+
PROFILE_VERSION = 1
|
|
45
|
+
MAX_EMBEDDING_CHARS = 12000
|
|
46
|
+
DEFAULT_BATCH_SIZE = 32
|
|
47
|
+
MIN_EMBEDDING_REQUEST_DELAY_SECONDS = 10.0
|
|
48
|
+
DEFAULT_MAX_EMBEDDING_SECONDS = 120.0
|
|
49
|
+
TRANSIENT_EMBEDDING_RETRY_LIMIT = 3
|
|
50
|
+
|
|
51
|
+
EmbeddingClient = Callable[..., list[list[float]]]
|
|
52
|
+
SleepFn = Callable[[float], None]
|
|
53
|
+
ClockFn = Callable[[], float]
|
|
54
|
+
|
|
55
|
+
_CODE_BLOCK_RE = re.compile(r"```[\s\S]*?```")
|
|
56
|
+
_FRONTMATTER_YAML_RE = re.compile(r"^---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)")
|
|
57
|
+
_FRONTMATTER_TOML_RE = re.compile(r"^\+\+\+\r?\n[\s\S]*?\r?\n\+\+\+\s*(?:\r?\n|$)")
|
|
58
|
+
_RELATED_HEADING_RE = re.compile(r"(?m)^##\s+(?:🔗\s+)?Notas Relacionadas\s*$")
|
|
59
|
+
_NEXT_H2_RE = re.compile(r"(?m)^##\s+")
|
|
60
|
+
_GENERATED_FOOTER_RE = re.compile(
|
|
61
|
+
rf"\n---\s*\n(?:\[[^\]]*{re.escape(CHAT_ORIGINAL_LABEL)}[^\]]*\]\([^)]+\)|{re.escape(CHAT_ORIGINAL_LABEL)}\b|Gerado|Generated|Exportado|Fonte|Source|Criado|Created)[\s\S]*$",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
|
|
65
|
+
_OBSIDIAN_IMAGE_RE = re.compile(r"!\[\[[^\]]+\]\]")
|
|
66
|
+
_MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]+\)")
|
|
67
|
+
_WIKILINK_ALIAS_RE = re.compile(r"\[\[([^\]|]+)\|([^\]]+)\]\]")
|
|
68
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
|
|
69
|
+
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class HeadlessRelatedNotesExportError(Exception):
|
|
74
|
+
blocked_reason: str
|
|
75
|
+
next_action: str
|
|
76
|
+
detail: str = ""
|
|
77
|
+
partial_record_count: int = 0
|
|
78
|
+
fresh_record_count: int = 0
|
|
79
|
+
stale_record_count: int = 0
|
|
80
|
+
record_count: int = 0
|
|
81
|
+
embedded_count: int = 0
|
|
82
|
+
reused_count: int = 0
|
|
83
|
+
total_note_count: int = 0
|
|
84
|
+
remaining_count: int = 0
|
|
85
|
+
next_retry_after_seconds: int = 0
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
return self.detail or self.blocked_reason
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BatchEmbeddingUnavailable(RuntimeError):
|
|
92
|
+
"""Raised when the Gemini batch embedding endpoint cannot handle the request."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, message: str, *, rate_limited: bool = False) -> None:
|
|
95
|
+
super().__init__(message)
|
|
96
|
+
self.rate_limited = rate_limited
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class _MarkdownNote:
|
|
101
|
+
rel_path: str
|
|
102
|
+
abs_path: Path
|
|
103
|
+
title: str
|
|
104
|
+
markdown: str
|
|
105
|
+
raw_hash: str
|
|
106
|
+
representation: str
|
|
107
|
+
representation_hash: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def generate_headless_related_notes_export(
|
|
111
|
+
wiki_dir: Path,
|
|
112
|
+
*,
|
|
113
|
+
export_path: Path | None = None,
|
|
114
|
+
settings_path: Path | None = None,
|
|
115
|
+
index_path: Path | None = None,
|
|
116
|
+
embedding_client: EmbeddingClient | None = None,
|
|
117
|
+
sleep: SleepFn | None = None,
|
|
118
|
+
now_iso: str | None = None,
|
|
119
|
+
now_ms: int | None = None,
|
|
120
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
121
|
+
max_embedding_seconds: float | None = None,
|
|
122
|
+
monotonic: ClockFn | None = None,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Rebuild the plugin index and Workbench export without opening Obsidian."""
|
|
125
|
+
wiki = wiki_dir.resolve(strict=False)
|
|
126
|
+
plugin_dir = wiki / ".obsidian" / "plugins" / PLUGIN_ID
|
|
127
|
+
settings_file = settings_path or plugin_dir / PLUGIN_SETTINGS_NAME
|
|
128
|
+
export_file = export_path or plugin_dir / PLUGIN_EXPORT_NAME
|
|
129
|
+
index_file = index_path or plugin_dir / PLUGIN_INDEX_NAME
|
|
130
|
+
settings = _load_settings(settings_file)
|
|
131
|
+
settings_model = RelatedNotesHeadlessSettings.model_validate(settings)
|
|
132
|
+
api_key = settings_model.gemini_api_key.strip()
|
|
133
|
+
if not api_key:
|
|
134
|
+
raise HeadlessRelatedNotesExportError(
|
|
135
|
+
blocked_reason="related_notes_headless_api_key_missing",
|
|
136
|
+
next_action="Configurar a chave do plugin Related Notes e repetir a recuperação do export.",
|
|
137
|
+
detail="Related Notes plugin data.json exists but geminiApiKey is empty.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
profile_id = _profile_id(settings_model.default_embedding_profile)
|
|
141
|
+
related_limit = _related_limit(settings_model.related_notes_limit)
|
|
142
|
+
delay_seconds = _delay_seconds(settings_model.embedding_request_delay_ms)
|
|
143
|
+
notes = _load_markdown_notes(wiki, profile_id)
|
|
144
|
+
existing_index = _load_vector_index(index_file)
|
|
145
|
+
records = _current_records(existing_index, profile_id)
|
|
146
|
+
reused_count = 0
|
|
147
|
+
missing: list[_MarkdownNote] = []
|
|
148
|
+
index_dirty = False
|
|
149
|
+
for note in notes:
|
|
150
|
+
record = records.get(note.rel_path)
|
|
151
|
+
if _record_is_current(record, note, profile_id):
|
|
152
|
+
reused_count += 1
|
|
153
|
+
continue
|
|
154
|
+
if _record_is_legacy_clean_v1_current(record, note, profile_id):
|
|
155
|
+
vector = _migration_vector(record, note, profile_id)
|
|
156
|
+
if vector is None:
|
|
157
|
+
missing.append(note)
|
|
158
|
+
continue
|
|
159
|
+
records[note.rel_path] = _record(
|
|
160
|
+
note,
|
|
161
|
+
vector,
|
|
162
|
+
profile_id=profile_id,
|
|
163
|
+
updated_at=now_ms if now_ms is not None else _now_ms(),
|
|
164
|
+
)
|
|
165
|
+
reused_count += 1
|
|
166
|
+
index_dirty = True
|
|
167
|
+
continue
|
|
168
|
+
missing.append(note)
|
|
169
|
+
|
|
170
|
+
embedded_count = 0
|
|
171
|
+
using_default_client = embedding_client is None
|
|
172
|
+
client = embedding_client or _default_embedding_client
|
|
173
|
+
sleeper = sleep or time.sleep
|
|
174
|
+
clock = monotonic or time.monotonic
|
|
175
|
+
started_at = clock()
|
|
176
|
+
embedding_time_budget = _max_embedding_seconds(max_embedding_seconds)
|
|
177
|
+
normalized_batch_size = max(1, int(batch_size or DEFAULT_BATCH_SIZE))
|
|
178
|
+
transient_retry_count = 0
|
|
179
|
+
|
|
180
|
+
def flush_vector_index() -> None:
|
|
181
|
+
nonlocal existing_index, index_dirty
|
|
182
|
+
timestamp = now_ms if now_ms is not None else _now_ms()
|
|
183
|
+
vector_index = _vector_index(
|
|
184
|
+
existing_index,
|
|
185
|
+
records,
|
|
186
|
+
profile_id=profile_id,
|
|
187
|
+
updated_at=timestamp,
|
|
188
|
+
)
|
|
189
|
+
index_file.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
atomic_write_text(index_file, json.dumps(vector_index, ensure_ascii=False, indent=2) + "\n")
|
|
191
|
+
existing_index = vector_index
|
|
192
|
+
index_dirty = False
|
|
193
|
+
|
|
194
|
+
def time_budget_exhausted() -> bool:
|
|
195
|
+
return embedding_time_budget > 0 and (clock() - started_at) >= embedding_time_budget
|
|
196
|
+
|
|
197
|
+
def raise_time_budget_exhausted() -> None:
|
|
198
|
+
progress = _recovery_progress_counts(
|
|
199
|
+
records=records,
|
|
200
|
+
notes=notes,
|
|
201
|
+
reused_count=reused_count,
|
|
202
|
+
embedded_count=embedded_count,
|
|
203
|
+
)
|
|
204
|
+
raise HeadlessRelatedNotesExportError(
|
|
205
|
+
blocked_reason="related_notes_headless_time_budget_exhausted",
|
|
206
|
+
next_action="Retomar a recuperação do Related Notes pela rota oficial; o índice parcial será reaproveitado.",
|
|
207
|
+
detail="Related Notes headless export paused after reaching the execution time budget.",
|
|
208
|
+
partial_record_count=progress["fresh_record_count"],
|
|
209
|
+
fresh_record_count=progress["fresh_record_count"],
|
|
210
|
+
stale_record_count=progress["stale_record_count"],
|
|
211
|
+
record_count=progress["record_count"],
|
|
212
|
+
embedded_count=embedded_count,
|
|
213
|
+
reused_count=reused_count,
|
|
214
|
+
total_note_count=len(notes),
|
|
215
|
+
remaining_count=progress["remaining_count"],
|
|
216
|
+
next_retry_after_seconds=int(math.ceil(delay_seconds)),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
index = 0
|
|
221
|
+
while index < len(missing):
|
|
222
|
+
if embedded_count and time_budget_exhausted():
|
|
223
|
+
raise_time_budget_exhausted()
|
|
224
|
+
batch = missing[index : index + normalized_batch_size]
|
|
225
|
+
batch_transient_retries = 0
|
|
226
|
+
try:
|
|
227
|
+
while True:
|
|
228
|
+
try:
|
|
229
|
+
vectors = client(
|
|
230
|
+
[note.representation for note in batch],
|
|
231
|
+
api_key=api_key,
|
|
232
|
+
model=DEFAULT_EMBEDDING_MODEL,
|
|
233
|
+
)
|
|
234
|
+
break
|
|
235
|
+
except httpx.TransportError:
|
|
236
|
+
if not using_default_client or batch_transient_retries >= TRANSIENT_EMBEDDING_RETRY_LIMIT:
|
|
237
|
+
raise
|
|
238
|
+
batch_transient_retries += 1
|
|
239
|
+
transient_retry_count += 1
|
|
240
|
+
sleeper(delay_seconds)
|
|
241
|
+
except BatchEmbeddingUnavailable as exc:
|
|
242
|
+
if using_default_client and normalized_batch_size > 1:
|
|
243
|
+
if exc.rate_limited and delay_seconds:
|
|
244
|
+
sleeper(delay_seconds)
|
|
245
|
+
normalized_batch_size = max(1, len(batch) // 2) if exc.rate_limited else 1
|
|
246
|
+
continue
|
|
247
|
+
raise
|
|
248
|
+
if len(vectors) != len(batch):
|
|
249
|
+
raise HeadlessRelatedNotesExportError(
|
|
250
|
+
blocked_reason="related_notes_headless_embedding_failed",
|
|
251
|
+
next_action="Repetir a recuperação; o provedor de embeddings retornou contagem inconsistente.",
|
|
252
|
+
detail="Embedding response count did not match request count.",
|
|
253
|
+
)
|
|
254
|
+
timestamp = now_ms if now_ms is not None else _now_ms()
|
|
255
|
+
for note, vector in zip(batch, vectors, strict=True):
|
|
256
|
+
records[note.rel_path] = _record(note, vector, profile_id=profile_id, updated_at=timestamp)
|
|
257
|
+
embedded_count += 1
|
|
258
|
+
index_dirty = True
|
|
259
|
+
if index_dirty and embedded_count % 10 == 0:
|
|
260
|
+
flush_vector_index()
|
|
261
|
+
if embedded_count < len(missing) and time_budget_exhausted():
|
|
262
|
+
raise_time_budget_exhausted()
|
|
263
|
+
if delay_seconds and embedded_count < len(missing):
|
|
264
|
+
sleeper(delay_seconds)
|
|
265
|
+
index += len(batch)
|
|
266
|
+
except HeadlessRelatedNotesExportError as exc:
|
|
267
|
+
if index_dirty:
|
|
268
|
+
flush_vector_index()
|
|
269
|
+
progress = _recovery_progress_counts(
|
|
270
|
+
records=records,
|
|
271
|
+
notes=notes,
|
|
272
|
+
reused_count=reused_count,
|
|
273
|
+
embedded_count=embedded_count,
|
|
274
|
+
)
|
|
275
|
+
exc.record_count = exc.record_count or progress["record_count"]
|
|
276
|
+
exc.fresh_record_count = exc.fresh_record_count or progress["fresh_record_count"]
|
|
277
|
+
exc.stale_record_count = exc.stale_record_count or progress["stale_record_count"]
|
|
278
|
+
exc.partial_record_count = exc.partial_record_count or progress["fresh_record_count"]
|
|
279
|
+
exc.embedded_count = exc.embedded_count or embedded_count
|
|
280
|
+
exc.reused_count = exc.reused_count or reused_count
|
|
281
|
+
exc.total_note_count = exc.total_note_count or len(notes)
|
|
282
|
+
exc.remaining_count = exc.remaining_count or progress["remaining_count"]
|
|
283
|
+
exc.next_retry_after_seconds = exc.next_retry_after_seconds or int(math.ceil(delay_seconds))
|
|
284
|
+
raise
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
if index_dirty:
|
|
287
|
+
flush_vector_index()
|
|
288
|
+
progress = _recovery_progress_counts(
|
|
289
|
+
records=records,
|
|
290
|
+
notes=notes,
|
|
291
|
+
reused_count=reused_count,
|
|
292
|
+
embedded_count=embedded_count,
|
|
293
|
+
)
|
|
294
|
+
raise HeadlessRelatedNotesExportError(
|
|
295
|
+
blocked_reason="related_notes_headless_embedding_failed",
|
|
296
|
+
next_action="Verificar chave/quota/rede do Gemini embeddings e repetir a recuperação do export.",
|
|
297
|
+
detail=_redact_error(str(exc)),
|
|
298
|
+
partial_record_count=progress["fresh_record_count"],
|
|
299
|
+
fresh_record_count=progress["fresh_record_count"],
|
|
300
|
+
stale_record_count=progress["stale_record_count"],
|
|
301
|
+
record_count=progress["record_count"],
|
|
302
|
+
embedded_count=embedded_count,
|
|
303
|
+
reused_count=reused_count,
|
|
304
|
+
total_note_count=len(notes),
|
|
305
|
+
remaining_count=progress["remaining_count"],
|
|
306
|
+
next_retry_after_seconds=int(math.ceil(delay_seconds)),
|
|
307
|
+
) from exc
|
|
308
|
+
|
|
309
|
+
timestamp = now_ms if now_ms is not None else _now_ms()
|
|
310
|
+
vector_index = _vector_index(existing_index, records, profile_id=profile_id, updated_at=timestamp)
|
|
311
|
+
payload = _export_payload(
|
|
312
|
+
notes,
|
|
313
|
+
records,
|
|
314
|
+
wiki,
|
|
315
|
+
profile_id=profile_id,
|
|
316
|
+
related_limit=related_limit,
|
|
317
|
+
generated_at=now_iso or _now_iso(),
|
|
318
|
+
)
|
|
319
|
+
index_file.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
export_file.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
atomic_write_text(index_file, json.dumps(vector_index, ensure_ascii=False, indent=2) + "\n")
|
|
322
|
+
atomic_write_text(export_file, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
323
|
+
return {
|
|
324
|
+
"schema": "medical-notes-workbench.related-notes-headless-export.v1",
|
|
325
|
+
"status": "completed",
|
|
326
|
+
"phase": "related_notes_headless_export",
|
|
327
|
+
"export_path": str(export_file),
|
|
328
|
+
"index_path": str(index_file),
|
|
329
|
+
"wiki_dir": str(wiki),
|
|
330
|
+
"note_count": len(payload["notes"]),
|
|
331
|
+
"edge_count": len(payload["edges"]),
|
|
332
|
+
"record_count": len(records),
|
|
333
|
+
"fresh_record_count": len(notes),
|
|
334
|
+
"stale_record_count": max(0, len(records) - len(notes)),
|
|
335
|
+
"remaining_count": 0,
|
|
336
|
+
"embedded_count": embedded_count,
|
|
337
|
+
"reused_count": reused_count,
|
|
338
|
+
"embedding_model": DEFAULT_EMBEDDING_MODEL,
|
|
339
|
+
"embedding_profile_id": profile_id,
|
|
340
|
+
"embedding_request_delay_ms": int(delay_seconds * 1000),
|
|
341
|
+
"embedding_transient_retry_count": transient_retry_count,
|
|
342
|
+
"related_notes_limit": related_limit,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _related_notes_hash_migration_cache_path() -> Path:
|
|
347
|
+
return user_state_dir() / "related-notes-hash-migration-cache.json"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _related_notes_hash_migration_file_identity(path: Path) -> dict[str, object] | None:
|
|
351
|
+
try:
|
|
352
|
+
stat = path.stat()
|
|
353
|
+
except OSError:
|
|
354
|
+
return None
|
|
355
|
+
return {
|
|
356
|
+
"path": str(path.resolve(strict=False)),
|
|
357
|
+
"size": stat.st_size,
|
|
358
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _related_notes_hash_migration_cache_identity(
|
|
363
|
+
*,
|
|
364
|
+
export_file: Path,
|
|
365
|
+
index_file: Path,
|
|
366
|
+
wiki_dir: Path,
|
|
367
|
+
) -> dict[str, object] | None:
|
|
368
|
+
export_identity = _related_notes_hash_migration_file_identity(export_file)
|
|
369
|
+
index_identity = _related_notes_hash_migration_file_identity(index_file)
|
|
370
|
+
if export_identity is None or index_identity is None:
|
|
371
|
+
return None
|
|
372
|
+
return {
|
|
373
|
+
"wiki_dir": str(wiki_dir.resolve(strict=False)),
|
|
374
|
+
"export": export_identity,
|
|
375
|
+
"index": index_identity,
|
|
376
|
+
"profile_id": "clean_v1",
|
|
377
|
+
"profile_version": PROFILE_VERSION,
|
|
378
|
+
"migration": "clean_v1_table_hashes",
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _related_notes_hash_migration_cache_hit(
|
|
383
|
+
*,
|
|
384
|
+
export_file: Path,
|
|
385
|
+
index_file: Path,
|
|
386
|
+
wiki_dir: Path,
|
|
387
|
+
) -> dict[str, object] | None:
|
|
388
|
+
identity = _related_notes_hash_migration_cache_identity(
|
|
389
|
+
export_file=export_file,
|
|
390
|
+
index_file=index_file,
|
|
391
|
+
wiki_dir=wiki_dir,
|
|
392
|
+
)
|
|
393
|
+
if identity is None:
|
|
394
|
+
return None
|
|
395
|
+
try:
|
|
396
|
+
payload = json.loads(_related_notes_hash_migration_cache_path().read_text(encoding="utf-8"))
|
|
397
|
+
except (OSError, json.JSONDecodeError):
|
|
398
|
+
return None
|
|
399
|
+
try:
|
|
400
|
+
cache = RelatedNotesHashMigrationCache.model_validate(payload)
|
|
401
|
+
except ValidationError:
|
|
402
|
+
return None
|
|
403
|
+
if cache.identity != identity:
|
|
404
|
+
return None
|
|
405
|
+
return {
|
|
406
|
+
"status": "skipped",
|
|
407
|
+
"skipped_reason": "cached_clean_v1_hash_migration",
|
|
408
|
+
"cache_status": "hit",
|
|
409
|
+
"cache_path": str(_related_notes_hash_migration_cache_path()),
|
|
410
|
+
"cached_status": cache.status,
|
|
411
|
+
"migrated_note_count": cache.migrated_note_count,
|
|
412
|
+
"skipped_note_count": cache.skipped_note_count,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _write_related_notes_hash_migration_cache(
|
|
417
|
+
*,
|
|
418
|
+
export_file: Path,
|
|
419
|
+
index_file: Path,
|
|
420
|
+
wiki_dir: Path,
|
|
421
|
+
status: str,
|
|
422
|
+
migrated_note_count: int,
|
|
423
|
+
skipped_note_count: int,
|
|
424
|
+
) -> None:
|
|
425
|
+
identity = _related_notes_hash_migration_cache_identity(
|
|
426
|
+
export_file=export_file,
|
|
427
|
+
index_file=index_file,
|
|
428
|
+
wiki_dir=wiki_dir,
|
|
429
|
+
)
|
|
430
|
+
if identity is None:
|
|
431
|
+
return
|
|
432
|
+
payload = {
|
|
433
|
+
"schema": RELATED_NOTES_HASH_MIGRATION_CACHE_SCHEMA,
|
|
434
|
+
"identity": identity,
|
|
435
|
+
"status": status,
|
|
436
|
+
"migrated_note_count": migrated_note_count,
|
|
437
|
+
"skipped_note_count": skipped_note_count,
|
|
438
|
+
}
|
|
439
|
+
try:
|
|
440
|
+
cache_path = _related_notes_hash_migration_cache_path()
|
|
441
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
442
|
+
atomic_write_text(cache_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
443
|
+
except OSError:
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def migrate_related_notes_clean_v1_table_hashes(
|
|
448
|
+
wiki_dir: Path,
|
|
449
|
+
*,
|
|
450
|
+
export_path: Path | None = None,
|
|
451
|
+
index_path: Path | None = None,
|
|
452
|
+
now_ms: int | None = None,
|
|
453
|
+
) -> dict[str, Any]:
|
|
454
|
+
"""Migrate legacy clean_v1 hashes when only table padding normalization changed."""
|
|
455
|
+
wiki = wiki_dir.resolve(strict=False)
|
|
456
|
+
plugin_dir = wiki / ".obsidian" / "plugins" / PLUGIN_ID
|
|
457
|
+
export_file = export_path or plugin_dir / PLUGIN_EXPORT_NAME
|
|
458
|
+
index_file = index_path or plugin_dir / PLUGIN_INDEX_NAME
|
|
459
|
+
base = {
|
|
460
|
+
"schema": "medical-notes-workbench.related-notes-hash-migration.v1",
|
|
461
|
+
"phase": "related_notes_hash_migration",
|
|
462
|
+
"export_path": str(export_file),
|
|
463
|
+
"index_path": str(index_file),
|
|
464
|
+
"wiki_dir": str(wiki),
|
|
465
|
+
"embedding_api_calls": 0,
|
|
466
|
+
"migrated_note_count": 0,
|
|
467
|
+
}
|
|
468
|
+
if not export_file.is_file():
|
|
469
|
+
return {**base, "status": "skipped", "skipped_reason": "export_missing"}
|
|
470
|
+
if not index_file.is_file():
|
|
471
|
+
return {**base, "status": "skipped", "skipped_reason": "index_missing"}
|
|
472
|
+
cached = _related_notes_hash_migration_cache_hit(export_file=export_file, index_file=index_file, wiki_dir=wiki)
|
|
473
|
+
if cached is not None:
|
|
474
|
+
return {**base, **cached}
|
|
475
|
+
try:
|
|
476
|
+
export_payload = json.loads(export_file.read_text(encoding="utf-8"))
|
|
477
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
478
|
+
return {**base, "status": "skipped", "skipped_reason": "export_unreadable", "detail": _redact_error(str(exc))}
|
|
479
|
+
try:
|
|
480
|
+
export = RelatedNotesHashMigrationExport.model_validate(export_payload)
|
|
481
|
+
except ValidationError:
|
|
482
|
+
return {**base, "status": "skipped", "skipped_reason": "export_schema_unsupported"}
|
|
483
|
+
profile_id = _profile_id(export.model_info.embedding_profile_id)
|
|
484
|
+
if profile_id != "clean_v1":
|
|
485
|
+
return {**base, "status": "skipped", "skipped_reason": "embedding_profile_not_clean_v1"}
|
|
486
|
+
current_notes = {note.rel_path: note for note in _load_markdown_notes(wiki, profile_id)}
|
|
487
|
+
# The migration lens is intentionally minimal, but the on-disk export must
|
|
488
|
+
# keep the full plugin contract. Preserve the original JSON and update only
|
|
489
|
+
# note hashes validated through RelatedNotesExportNote.
|
|
490
|
+
export_payload_for_write = JsonObjectAdapter.validate_python(export_payload)
|
|
491
|
+
export_note_indexes: dict[str, int] = {}
|
|
492
|
+
raw_notes_value = export_payload_for_write["notes"] if "notes" in export_payload_for_write else []
|
|
493
|
+
raw_notes_for_write: list[JsonValue] = raw_notes_value if isinstance(raw_notes_value, list) else []
|
|
494
|
+
for index, raw_note in enumerate(raw_notes_for_write):
|
|
495
|
+
if not isinstance(raw_note, dict):
|
|
496
|
+
continue
|
|
497
|
+
typed_note = RelatedNotesExportNote.model_validate(raw_note)
|
|
498
|
+
export_note_indexes[typed_note.path] = index
|
|
499
|
+
migration_candidates: list[tuple[RelatedNotesExportNote, _MarkdownNote, str]] = []
|
|
500
|
+
migrated_count = 0
|
|
501
|
+
skipped_count = 0
|
|
502
|
+
for item in export.notes:
|
|
503
|
+
rel_path = item.path
|
|
504
|
+
note = current_notes[rel_path] if rel_path in current_notes else None
|
|
505
|
+
if note is None:
|
|
506
|
+
skipped_count += 1
|
|
507
|
+
continue
|
|
508
|
+
exported_hash = item.content_hash
|
|
509
|
+
current_hash = "sha256:" + note.representation_hash
|
|
510
|
+
if exported_hash.lower() == current_hash.lower():
|
|
511
|
+
continue
|
|
512
|
+
legacy_hash = related_notes_legacy_clean_v1_content_hash(
|
|
513
|
+
path=note.rel_path,
|
|
514
|
+
title=note.title,
|
|
515
|
+
markdown=note.markdown,
|
|
516
|
+
)
|
|
517
|
+
if exported_hash.lower() != legacy_hash.lower():
|
|
518
|
+
skipped_count += 1
|
|
519
|
+
continue
|
|
520
|
+
migration_candidates.append((item, note, current_hash))
|
|
521
|
+
if not migration_candidates:
|
|
522
|
+
_write_related_notes_hash_migration_cache(
|
|
523
|
+
export_file=export_file,
|
|
524
|
+
index_file=index_file,
|
|
525
|
+
wiki_dir=wiki,
|
|
526
|
+
status="no_legacy_clean_v1_hashes",
|
|
527
|
+
migrated_note_count=0,
|
|
528
|
+
skipped_note_count=skipped_count,
|
|
529
|
+
)
|
|
530
|
+
return {**base, "status": "skipped", "skipped_reason": "no_legacy_clean_v1_hashes", "skipped_note_count": skipped_count}
|
|
531
|
+
existing_index = _load_vector_index(index_file)
|
|
532
|
+
records = _current_records(existing_index, profile_id)
|
|
533
|
+
updated_paths: list[str] = []
|
|
534
|
+
timestamp = now_ms if now_ms is not None else _now_ms()
|
|
535
|
+
for _item, note, current_hash in migration_candidates:
|
|
536
|
+
record = records.get(note.rel_path)
|
|
537
|
+
vector = _migration_vector(record, note, profile_id)
|
|
538
|
+
if vector is None:
|
|
539
|
+
skipped_count += 1
|
|
540
|
+
continue
|
|
541
|
+
records[note.rel_path] = _record(note, vector, profile_id=profile_id, updated_at=timestamp)
|
|
542
|
+
export_note_index = export_note_indexes.get(note.rel_path)
|
|
543
|
+
if export_note_index is None:
|
|
544
|
+
skipped_count += 1
|
|
545
|
+
continue
|
|
546
|
+
raw_note = raw_notes_for_write[export_note_index]
|
|
547
|
+
note_payload = JsonObjectAdapter.validate_python(raw_note if isinstance(raw_note, dict) else {})
|
|
548
|
+
note_payload["content_hash"] = current_hash
|
|
549
|
+
raw_notes_for_write[export_note_index] = note_payload
|
|
550
|
+
migrated_count += 1
|
|
551
|
+
updated_paths.append(note.rel_path)
|
|
552
|
+
if not migrated_count:
|
|
553
|
+
_write_related_notes_hash_migration_cache(
|
|
554
|
+
export_file=export_file,
|
|
555
|
+
index_file=index_file,
|
|
556
|
+
wiki_dir=wiki,
|
|
557
|
+
status="legacy_clean_v1_vectors_missing",
|
|
558
|
+
migrated_note_count=0,
|
|
559
|
+
skipped_note_count=skipped_count,
|
|
560
|
+
)
|
|
561
|
+
return {
|
|
562
|
+
**base,
|
|
563
|
+
"status": "skipped",
|
|
564
|
+
"skipped_reason": "legacy_clean_v1_vectors_missing",
|
|
565
|
+
"skipped_note_count": skipped_count,
|
|
566
|
+
}
|
|
567
|
+
try:
|
|
568
|
+
vector_index = _vector_index(existing_index, records, profile_id=profile_id, updated_at=timestamp)
|
|
569
|
+
atomic_write_text(index_file, json.dumps(vector_index, ensure_ascii=False, indent=2) + "\n")
|
|
570
|
+
atomic_write_text(export_file, json.dumps(export_payload_for_write, ensure_ascii=False, indent=2) + "\n")
|
|
571
|
+
except OSError as exc:
|
|
572
|
+
return {
|
|
573
|
+
**base,
|
|
574
|
+
"status": "blocked",
|
|
575
|
+
"blocked_reason": "related_notes_hash_migration_write_failed",
|
|
576
|
+
"next_action": "Verificar permissões do export/índice do Related Notes antes de aplicar correções na Wiki.",
|
|
577
|
+
"detail": _redact_error(str(exc)),
|
|
578
|
+
"migrated_note_count": migrated_count,
|
|
579
|
+
}
|
|
580
|
+
_write_related_notes_hash_migration_cache(
|
|
581
|
+
export_file=export_file,
|
|
582
|
+
index_file=index_file,
|
|
583
|
+
wiki_dir=wiki,
|
|
584
|
+
status="migrated_clean_v1_hashes",
|
|
585
|
+
migrated_note_count=migrated_count,
|
|
586
|
+
skipped_note_count=skipped_count,
|
|
587
|
+
)
|
|
588
|
+
return {
|
|
589
|
+
**base,
|
|
590
|
+
"status": "completed",
|
|
591
|
+
"migrated_note_count": migrated_count,
|
|
592
|
+
"skipped_note_count": skipped_count,
|
|
593
|
+
"updated_paths": updated_paths[:25],
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def headless_plugin_settings_available(wiki_dir: Path) -> bool:
|
|
598
|
+
return (wiki_dir / ".obsidian" / "plugins" / PLUGIN_ID / PLUGIN_SETTINGS_NAME).is_file()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def normalize_related_notes_profile_id(value: Any) -> str:
|
|
602
|
+
return _profile_id(value)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def related_notes_representation_hash(
|
|
606
|
+
*,
|
|
607
|
+
path: str,
|
|
608
|
+
title: str,
|
|
609
|
+
markdown: str,
|
|
610
|
+
profile_id: str = DEFAULT_PROFILE_ID,
|
|
611
|
+
) -> str:
|
|
612
|
+
representation = _build_representation(
|
|
613
|
+
path=path,
|
|
614
|
+
title=title,
|
|
615
|
+
markdown=markdown,
|
|
616
|
+
profile_id=normalize_related_notes_profile_id(profile_id),
|
|
617
|
+
)
|
|
618
|
+
return _sha256_text(representation)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def related_notes_content_hash(
|
|
622
|
+
*,
|
|
623
|
+
path: str,
|
|
624
|
+
title: str,
|
|
625
|
+
markdown: str,
|
|
626
|
+
profile_id: str = DEFAULT_PROFILE_ID,
|
|
627
|
+
) -> str:
|
|
628
|
+
return "sha256:" + related_notes_representation_hash(
|
|
629
|
+
path=path,
|
|
630
|
+
title=title,
|
|
631
|
+
markdown=markdown,
|
|
632
|
+
profile_id=profile_id,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _load_settings(settings_path: Path) -> dict[str, Any]:
|
|
637
|
+
if not settings_path.is_file():
|
|
638
|
+
raise HeadlessRelatedNotesExportError(
|
|
639
|
+
blocked_reason="related_notes_headless_plugin_settings_missing",
|
|
640
|
+
next_action="Instalar/configurar o plugin Related Notes neste vault e repetir a recuperação do export.",
|
|
641
|
+
detail="Related Notes plugin data.json was not found.",
|
|
642
|
+
)
|
|
643
|
+
try:
|
|
644
|
+
parsed = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
645
|
+
except json.JSONDecodeError as exc:
|
|
646
|
+
raise HeadlessRelatedNotesExportError(
|
|
647
|
+
blocked_reason="related_notes_headless_plugin_settings_invalid",
|
|
648
|
+
next_action="Corrigir data.json do plugin Related Notes e repetir a recuperação do export.",
|
|
649
|
+
detail=str(exc),
|
|
650
|
+
) from exc
|
|
651
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _profile_id(value: Any) -> str:
|
|
655
|
+
text = str(value or DEFAULT_PROFILE_ID)
|
|
656
|
+
return text if text in {"clean_v1", "raw_v1", "legacy_v0"} else DEFAULT_PROFILE_ID
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _related_limit(value: Any) -> int:
|
|
660
|
+
if isinstance(value, int | float) and math.isfinite(value):
|
|
661
|
+
return max(1, min(50, int(value)))
|
|
662
|
+
return 10
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _delay_seconds(value: Any) -> float:
|
|
666
|
+
if isinstance(value, int | float) and math.isfinite(value):
|
|
667
|
+
return max(MIN_EMBEDDING_REQUEST_DELAY_SECONDS, float(int(value)) / 1000.0)
|
|
668
|
+
return MIN_EMBEDDING_REQUEST_DELAY_SECONDS
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _max_embedding_seconds(value: float | None) -> float:
|
|
672
|
+
if value is not None and math.isfinite(value):
|
|
673
|
+
return max(0.0, float(value))
|
|
674
|
+
raw = os.environ.get("MEDNOTES_RELATED_NOTES_HEADLESS_MAX_SECONDS", "").strip()
|
|
675
|
+
if raw:
|
|
676
|
+
try:
|
|
677
|
+
parsed = float(raw)
|
|
678
|
+
except ValueError:
|
|
679
|
+
return DEFAULT_MAX_EMBEDDING_SECONDS
|
|
680
|
+
if math.isfinite(parsed):
|
|
681
|
+
return max(0.0, parsed)
|
|
682
|
+
return DEFAULT_MAX_EMBEDDING_SECONDS
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _load_markdown_notes(wiki_dir: Path, profile_id: str) -> list[_MarkdownNote]:
|
|
686
|
+
notes: list[_MarkdownNote] = []
|
|
687
|
+
for index, path in enumerate(sorted(wiki_dir.rglob("*.md")), start=1):
|
|
688
|
+
cooperative_cpu_yield(index)
|
|
689
|
+
rel = path.relative_to(wiki_dir)
|
|
690
|
+
if any(part.startswith(".") for part in rel.parts):
|
|
691
|
+
continue
|
|
692
|
+
markdown = path.read_text(encoding="utf-8")
|
|
693
|
+
rel_path = rel.as_posix()
|
|
694
|
+
title = path.stem
|
|
695
|
+
representation = _build_representation(path=rel_path, title=title, markdown=markdown, profile_id=profile_id)
|
|
696
|
+
representation_hash = related_notes_representation_hash(
|
|
697
|
+
path=rel_path,
|
|
698
|
+
title=title,
|
|
699
|
+
markdown=markdown,
|
|
700
|
+
profile_id=profile_id,
|
|
701
|
+
)
|
|
702
|
+
notes.append(
|
|
703
|
+
_MarkdownNote(
|
|
704
|
+
rel_path=rel_path,
|
|
705
|
+
abs_path=path,
|
|
706
|
+
title=title,
|
|
707
|
+
markdown=markdown,
|
|
708
|
+
raw_hash=_sha256_text(markdown),
|
|
709
|
+
representation=representation,
|
|
710
|
+
representation_hash=representation_hash,
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
return notes
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _build_representation(*, path: str, title: str, markdown: str, profile_id: str) -> str:
|
|
717
|
+
body = _profile_body(markdown, profile_id)
|
|
718
|
+
truncated = body[:MAX_EMBEDDING_CHARS]
|
|
719
|
+
return f"Título: {title}\nCaminho: {path}\n\nConteúdo:\n{truncated}"
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _profile_body(markdown: str, profile_id: str) -> str:
|
|
723
|
+
if profile_id == "raw_v1":
|
|
724
|
+
return markdown
|
|
725
|
+
if profile_id == "legacy_v0":
|
|
726
|
+
return _clean_markdown_legacy(markdown)
|
|
727
|
+
return _clean_markdown_v1(markdown)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _clean_markdown_legacy(markdown: str) -> str:
|
|
731
|
+
text = _FRONTMATTER_YAML_RE.sub("", markdown)
|
|
732
|
+
text = _CODE_BLOCK_RE.sub("[CODE BLOCK]", text)
|
|
733
|
+
text = _WIKILINK_ALIAS_RE.sub(r"\2", text)
|
|
734
|
+
text = _WIKILINK_RE.sub(r"\1", text)
|
|
735
|
+
text = _MARKDOWN_LINK_RE.sub(r"\1", text)
|
|
736
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _clean_markdown_v1(markdown: str) -> str:
|
|
740
|
+
return _clean_markdown_v1_with_table_normalization(markdown)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _clean_markdown_v1_legacy_table_spacing(markdown: str) -> str:
|
|
744
|
+
return _clean_markdown_v1_base(markdown, normalize_tables=False)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _clean_markdown_v1_with_table_normalization(markdown: str) -> str:
|
|
748
|
+
return _clean_markdown_v1_base(markdown, normalize_tables=True)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _clean_markdown_v1_base(markdown: str, *, normalize_tables: bool) -> str:
|
|
752
|
+
code_blocks: list[str] = []
|
|
753
|
+
|
|
754
|
+
def stash_code_block(match: re.Match[str]) -> str:
|
|
755
|
+
token = f"@@RELATED_NOTES_CODE_BLOCK_{len(code_blocks)}@@"
|
|
756
|
+
code_blocks.append(match.group(0))
|
|
757
|
+
return token
|
|
758
|
+
|
|
759
|
+
text = _CODE_BLOCK_RE.sub(stash_code_block, markdown)
|
|
760
|
+
text = _FRONTMATTER_YAML_RE.sub("", text)
|
|
761
|
+
text = _FRONTMATTER_TOML_RE.sub("", text)
|
|
762
|
+
text = _remove_related_notes_section(text)
|
|
763
|
+
text = _GENERATED_FOOTER_RE.sub("", text)
|
|
764
|
+
text = _COMMENT_RE.sub("", text)
|
|
765
|
+
text = _OBSIDIAN_IMAGE_RE.sub("", text)
|
|
766
|
+
text = _MARKDOWN_IMAGE_RE.sub("", text)
|
|
767
|
+
text = _WIKILINK_ALIAS_RE.sub(r"\2", text)
|
|
768
|
+
text = _WIKILINK_RE.sub(r"\1", text)
|
|
769
|
+
text = _MARKDOWN_LINK_RE.sub(r"\1", text)
|
|
770
|
+
if normalize_tables:
|
|
771
|
+
text = _normalize_markdown_table_padding(text)
|
|
772
|
+
text = _restore_code_blocks(text, code_blocks)
|
|
773
|
+
return _normalize_clean_whitespace(text)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _normalize_markdown_table_padding(text: str) -> str:
|
|
777
|
+
return "\n".join(_normalize_markdown_table_row(line) for line in text.splitlines())
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _normalize_markdown_table_row(line: str) -> str:
|
|
781
|
+
stripped = line.strip()
|
|
782
|
+
if not stripped.startswith("|") or stripped.count("|") < 2:
|
|
783
|
+
return line
|
|
784
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
785
|
+
return "| " + " | ".join(_normalize_markdown_table_cell(cell) for cell in cells) + " |"
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _normalize_markdown_table_cell(cell: str) -> str:
|
|
789
|
+
compact = cell.replace(" ", "").replace("\t", "")
|
|
790
|
+
if re.fullmatch(r":?-{3,}:?", compact):
|
|
791
|
+
return f"{':' if compact.startswith(':') else ''}---{':' if compact.endswith(':') else ''}"
|
|
792
|
+
return cell
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _remove_related_notes_section(text: str) -> str:
|
|
796
|
+
match = _RELATED_HEADING_RE.search(text)
|
|
797
|
+
if not match:
|
|
798
|
+
return text
|
|
799
|
+
next_heading = _NEXT_H2_RE.search(text, match.end())
|
|
800
|
+
end = next_heading.start() if next_heading else len(text)
|
|
801
|
+
return f"{text[: match.start()]}{text[end:]}"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _restore_code_blocks(text: str, code_blocks: list[str]) -> str:
|
|
805
|
+
def restore(match: re.Match[str]) -> str:
|
|
806
|
+
index = int(match.group(1))
|
|
807
|
+
return code_blocks[index] if 0 <= index < len(code_blocks) else ""
|
|
808
|
+
|
|
809
|
+
return re.sub(r"@@RELATED_NOTES_CODE_BLOCK_(\d+)@@", restore, text)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _normalize_clean_whitespace(text: str) -> str:
|
|
813
|
+
return (
|
|
814
|
+
"\n".join(line.rstrip() for line in text.splitlines())
|
|
815
|
+
.replace("\n\n\n", "\n\n")
|
|
816
|
+
.strip()
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _load_vector_index(index_path: Path) -> dict[str, Any]:
|
|
821
|
+
if not index_path.is_file():
|
|
822
|
+
return {}
|
|
823
|
+
try:
|
|
824
|
+
parsed = json.loads(index_path.read_text(encoding="utf-8"))
|
|
825
|
+
except json.JSONDecodeError:
|
|
826
|
+
return {}
|
|
827
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _current_records(index: dict[str, Any], profile_id: str) -> dict[str, RelatedNotesVectorRecord]:
|
|
831
|
+
vector_index = RelatedNotesVectorIndex.model_validate(index)
|
|
832
|
+
return vector_index.records_for_profile(profile_id)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _record_is_current(record: RelatedNotesVectorRecord | None, note: _MarkdownNote, profile_id: str) -> bool:
|
|
836
|
+
if not record:
|
|
837
|
+
return False
|
|
838
|
+
return (
|
|
839
|
+
record.representation_hash == note.representation_hash
|
|
840
|
+
and record.embedding_model == DEFAULT_EMBEDDING_MODEL
|
|
841
|
+
and record.embedding_profile == profile_id
|
|
842
|
+
and record.embedding_profile_version == PROFILE_VERSION
|
|
843
|
+
and _valid_vector(record.vector)
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _record_is_legacy_clean_v1_current(
|
|
848
|
+
record: RelatedNotesVectorRecord | None,
|
|
849
|
+
note: _MarkdownNote,
|
|
850
|
+
profile_id: str,
|
|
851
|
+
) -> bool:
|
|
852
|
+
if profile_id != "clean_v1" or not record or not _valid_vector(record.vector):
|
|
853
|
+
return False
|
|
854
|
+
return (
|
|
855
|
+
record.representation_hash == related_notes_legacy_clean_v1_representation_hash(
|
|
856
|
+
path=note.rel_path,
|
|
857
|
+
title=note.title,
|
|
858
|
+
markdown=note.markdown,
|
|
859
|
+
)
|
|
860
|
+
and record.embedding_model == DEFAULT_EMBEDDING_MODEL
|
|
861
|
+
and record.embedding_profile == profile_id
|
|
862
|
+
and record.embedding_profile_version == PROFILE_VERSION
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _migration_vector(record: RelatedNotesVectorRecord | None, note: _MarkdownNote, profile_id: str) -> list[float] | None:
|
|
867
|
+
if not record or not _valid_vector(record.vector):
|
|
868
|
+
return None
|
|
869
|
+
if not (_record_is_current(record, note, profile_id) or _record_is_legacy_clean_v1_current(record, note, profile_id)):
|
|
870
|
+
return None
|
|
871
|
+
return [float(item) for item in record.vector]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def related_notes_legacy_clean_v1_content_hash(*, path: str, title: str, markdown: str) -> str:
|
|
875
|
+
return "sha256:" + related_notes_legacy_clean_v1_representation_hash(path=path, title=title, markdown=markdown)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def related_notes_legacy_clean_v1_representation_hash(*, path: str, title: str, markdown: str) -> str:
|
|
879
|
+
representation = (
|
|
880
|
+
f"Título: {title}\n"
|
|
881
|
+
f"Caminho: {path}\n\n"
|
|
882
|
+
"Conteúdo:\n"
|
|
883
|
+
f"{_clean_markdown_v1_legacy_table_spacing(markdown)[:MAX_EMBEDDING_CHARS]}"
|
|
884
|
+
)
|
|
885
|
+
return _sha256_text(representation)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _recovery_progress_counts(
|
|
889
|
+
*,
|
|
890
|
+
records: dict[str, RelatedNotesVectorRecord],
|
|
891
|
+
notes: list[_MarkdownNote],
|
|
892
|
+
reused_count: int,
|
|
893
|
+
embedded_count: int,
|
|
894
|
+
) -> dict[str, int]:
|
|
895
|
+
total_note_count = len(notes)
|
|
896
|
+
fresh_record_count = min(total_note_count, max(0, reused_count) + max(0, embedded_count))
|
|
897
|
+
record_count = len(records)
|
|
898
|
+
return {
|
|
899
|
+
"record_count": record_count,
|
|
900
|
+
"fresh_record_count": fresh_record_count,
|
|
901
|
+
"stale_record_count": max(0, record_count - fresh_record_count),
|
|
902
|
+
"remaining_count": max(0, total_note_count - fresh_record_count),
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _record(note: _MarkdownNote, vector: list[float], *, profile_id: str, updated_at: int) -> RelatedNotesVectorRecord:
|
|
907
|
+
return RelatedNotesVectorRecord(
|
|
908
|
+
path=note.rel_path,
|
|
909
|
+
title=note.title,
|
|
910
|
+
folder=str(Path(note.rel_path).parent).replace(".", "") if "/" in note.rel_path else "",
|
|
911
|
+
preview=_make_preview(note.representation),
|
|
912
|
+
rawContentHash=note.raw_hash,
|
|
913
|
+
representationHash=note.representation_hash,
|
|
914
|
+
contentHash=note.representation_hash,
|
|
915
|
+
mtime=int(note.abs_path.stat().st_mtime * 1000),
|
|
916
|
+
embeddingModel=DEFAULT_EMBEDDING_MODEL,
|
|
917
|
+
embeddingProfile=profile_id,
|
|
918
|
+
embeddingProfileVersion=PROFILE_VERSION,
|
|
919
|
+
vector=[float(item) for item in vector],
|
|
920
|
+
updatedAt=updated_at,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _make_preview(representation: str, limit: int = 200) -> str:
|
|
925
|
+
lines = representation.split("\n")
|
|
926
|
+
try:
|
|
927
|
+
content_index = next(index for index, line in enumerate(lines) if line.startswith("Conteúdo:"))
|
|
928
|
+
except StopIteration:
|
|
929
|
+
content_index = -1
|
|
930
|
+
content = " ".join(lines[content_index + 1 :])
|
|
931
|
+
return content[:limit] + ("..." if len(content) > limit else "")
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _vector_index(
|
|
935
|
+
existing_index: dict[str, Any],
|
|
936
|
+
records: dict[str, RelatedNotesVectorRecord],
|
|
937
|
+
*,
|
|
938
|
+
profile_id: str,
|
|
939
|
+
updated_at: int,
|
|
940
|
+
) -> dict[str, Any]:
|
|
941
|
+
profiles = RelatedNotesVectorIndex.model_validate(existing_index).other_profiles_payload(profile_id)
|
|
942
|
+
profiles[profile_id] = {
|
|
943
|
+
"profileId": profile_id,
|
|
944
|
+
"profileVersion": PROFILE_VERSION,
|
|
945
|
+
"embeddingModel": DEFAULT_EMBEDDING_MODEL,
|
|
946
|
+
"updatedAt": updated_at,
|
|
947
|
+
"records": {key: value.to_payload() for key, value in sorted(records.items())},
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
"schema": "related-notes-obsidian.vector-index.v2",
|
|
951
|
+
"updatedAt": updated_at,
|
|
952
|
+
"profiles": profiles,
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _export_payload(
|
|
957
|
+
notes: list[_MarkdownNote],
|
|
958
|
+
records: dict[str, RelatedNotesVectorRecord],
|
|
959
|
+
wiki_dir: Path,
|
|
960
|
+
*,
|
|
961
|
+
profile_id: str,
|
|
962
|
+
related_limit: int,
|
|
963
|
+
generated_at: str,
|
|
964
|
+
) -> dict[str, Any]:
|
|
965
|
+
indexed_notes = [note for note in notes if note.rel_path in records]
|
|
966
|
+
note_paths = {note.rel_path for note in indexed_notes}
|
|
967
|
+
normalized_vectors = {
|
|
968
|
+
note.rel_path: normalized
|
|
969
|
+
for note in indexed_notes
|
|
970
|
+
if (normalized := _normalized_vector(records[note.rel_path].vector)) is not None
|
|
971
|
+
}
|
|
972
|
+
candidates_by_source: dict[str, list[tuple[float, str]]] = {note.rel_path: [] for note in indexed_notes}
|
|
973
|
+
for source_index, source in enumerate(indexed_notes):
|
|
974
|
+
source_vector = normalized_vectors.get(source.rel_path)
|
|
975
|
+
for target in indexed_notes[source_index + 1 :]:
|
|
976
|
+
target_vector = normalized_vectors.get(target.rel_path)
|
|
977
|
+
score = _cosine_normalized(source_vector, target_vector) if source_vector is not None else 0.0
|
|
978
|
+
if source_vector is not None:
|
|
979
|
+
candidates_by_source[source.rel_path].append((score, target.rel_path))
|
|
980
|
+
if target_vector is not None:
|
|
981
|
+
candidates_by_source[target.rel_path].append((score, source.rel_path))
|
|
982
|
+
edges: list[dict[str, Any]] = []
|
|
983
|
+
for note in indexed_notes:
|
|
984
|
+
for rank, (score, target_path) in enumerate(
|
|
985
|
+
sorted(candidates_by_source.get(note.rel_path, []), key=lambda item: (-item[0], item[1]))[:related_limit],
|
|
986
|
+
start=1,
|
|
987
|
+
):
|
|
988
|
+
if target_path in note_paths:
|
|
989
|
+
edges.append(
|
|
990
|
+
{
|
|
991
|
+
"source_path": note.rel_path,
|
|
992
|
+
"target_path": target_path,
|
|
993
|
+
"score": _clamp_score(score),
|
|
994
|
+
"rank": rank,
|
|
995
|
+
"source": PLUGIN_ID,
|
|
996
|
+
}
|
|
997
|
+
)
|
|
998
|
+
return {
|
|
999
|
+
"schema": RELATED_NOTES_EXPORT_SCHEMA,
|
|
1000
|
+
"generated_at": generated_at,
|
|
1001
|
+
"vault_root": ".",
|
|
1002
|
+
"plugin": {"name": PLUGIN_ID, "version": _plugin_version(wiki_dir)},
|
|
1003
|
+
"model": {
|
|
1004
|
+
"embedding_model": DEFAULT_EMBEDDING_MODEL,
|
|
1005
|
+
"embedding_profile_id": profile_id,
|
|
1006
|
+
"embedding_profile_version": PROFILE_VERSION,
|
|
1007
|
+
"representation_hash_basis": _representation_hash_basis(profile_id),
|
|
1008
|
+
},
|
|
1009
|
+
"score_scale": "0_to_1",
|
|
1010
|
+
"notes": [
|
|
1011
|
+
{"path": note.rel_path, "title": note.title, "content_hash": "sha256:" + note.representation_hash}
|
|
1012
|
+
for note in sorted(indexed_notes, key=lambda item: item.rel_path)
|
|
1013
|
+
],
|
|
1014
|
+
"edges": edges,
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _plugin_version(wiki_dir: Path) -> str:
|
|
1019
|
+
manifest = wiki_dir / ".obsidian" / "plugins" / PLUGIN_ID / "manifest.json"
|
|
1020
|
+
try:
|
|
1021
|
+
parsed = json.loads(manifest.read_text(encoding="utf-8"))
|
|
1022
|
+
except (OSError, json.JSONDecodeError):
|
|
1023
|
+
return "headless"
|
|
1024
|
+
return str(parsed.get("version") or "headless") if isinstance(parsed, dict) else "headless"
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _representation_hash_basis(profile_id: str) -> str:
|
|
1028
|
+
if profile_id == "raw_v1":
|
|
1029
|
+
return "raw_markdown"
|
|
1030
|
+
if profile_id == "legacy_v0":
|
|
1031
|
+
return "legacy_hybrid_markdown"
|
|
1032
|
+
return "profile_cleaned_markdown"
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _normalized_vector(value: Any) -> tuple[float, ...] | None:
|
|
1036
|
+
if not _valid_vector(value):
|
|
1037
|
+
return None
|
|
1038
|
+
floats = tuple(float(item) for item in value)
|
|
1039
|
+
norm = math.sqrt(sum(item * item for item in floats))
|
|
1040
|
+
if norm == 0:
|
|
1041
|
+
return None
|
|
1042
|
+
return tuple(item / norm for item in floats)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _cosine_normalized(left: tuple[float, ...], right: tuple[float, ...] | None) -> float:
|
|
1046
|
+
if right is None or len(left) != len(right):
|
|
1047
|
+
return 0.0
|
|
1048
|
+
sumprod = getattr(math, "sumprod", None)
|
|
1049
|
+
if callable(sumprod):
|
|
1050
|
+
typed_sumprod = cast(Callable[[Iterable[float], Iterable[float]], float], sumprod)
|
|
1051
|
+
return float(typed_sumprod(left, right))
|
|
1052
|
+
return sum(a * b for a, b in zip(left, right, strict=True))
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _cosine(left: Any, right: Any) -> float:
|
|
1056
|
+
if not _valid_vector(left) or not _valid_vector(right) or len(left) != len(right):
|
|
1057
|
+
return 0.0
|
|
1058
|
+
dot = sum(float(a) * float(b) for a, b in zip(left, right, strict=True))
|
|
1059
|
+
left_norm = math.sqrt(sum(float(a) * float(a) for a in left))
|
|
1060
|
+
right_norm = math.sqrt(sum(float(b) * float(b) for b in right))
|
|
1061
|
+
if left_norm == 0 or right_norm == 0:
|
|
1062
|
+
return 0.0
|
|
1063
|
+
return dot / (left_norm * right_norm)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _valid_vector(value: Any) -> bool:
|
|
1067
|
+
return isinstance(value, list) and bool(value) and all(isinstance(item, int | float) for item in value)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _clamp_score(value: float) -> float:
|
|
1071
|
+
if not math.isfinite(value):
|
|
1072
|
+
return 0.0
|
|
1073
|
+
return max(0.0, min(1.0, value))
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _chunks(items: list[_MarkdownNote], size: int) -> Iterable[list[_MarkdownNote]]:
|
|
1077
|
+
for index in range(0, len(items), size):
|
|
1078
|
+
yield items[index : index + size]
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _default_embedding_client(texts: list[str], *, api_key: str, model: str) -> list[list[float]]:
|
|
1082
|
+
if len(texts) > 1:
|
|
1083
|
+
return _batch_embed(texts, api_key=api_key, model=model)
|
|
1084
|
+
return [_single_embed(text, api_key=api_key, model=model) for text in texts]
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _batch_embed(texts: list[str], *, api_key: str, model: str) -> list[list[float]]:
|
|
1088
|
+
response = httpx.post(
|
|
1089
|
+
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents",
|
|
1090
|
+
params={"key": api_key},
|
|
1091
|
+
json={
|
|
1092
|
+
"requests": [
|
|
1093
|
+
{
|
|
1094
|
+
"model": f"models/{model}",
|
|
1095
|
+
"content": {"parts": [{"text": text}]},
|
|
1096
|
+
}
|
|
1097
|
+
for text in texts
|
|
1098
|
+
]
|
|
1099
|
+
},
|
|
1100
|
+
timeout=60.0,
|
|
1101
|
+
)
|
|
1102
|
+
if response.status_code in {400, 404}:
|
|
1103
|
+
raise BatchEmbeddingUnavailable("batch embedding endpoint unavailable")
|
|
1104
|
+
if response.status_code == 429:
|
|
1105
|
+
raise BatchEmbeddingUnavailable(_response_error_text(response), rate_limited=True)
|
|
1106
|
+
if response.status_code >= 400:
|
|
1107
|
+
raise HeadlessRelatedNotesExportError(
|
|
1108
|
+
blocked_reason="related_notes_headless_embedding_failed",
|
|
1109
|
+
next_action="Verificar chave/quota/rede do Gemini embeddings e repetir a recuperação do export.",
|
|
1110
|
+
detail=_redact_error(response.text),
|
|
1111
|
+
)
|
|
1112
|
+
try:
|
|
1113
|
+
payload = GeminiBatchEmbeddingResponse.model_validate(response.json())
|
|
1114
|
+
except ValidationError as exc:
|
|
1115
|
+
raise RuntimeError("batch embedding response missing embeddings[]") from exc
|
|
1116
|
+
return [_extract_embedding(item) for item in payload.embeddings]
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _single_embed(text: str, *, api_key: str, model: str) -> list[float]:
|
|
1120
|
+
response = httpx.post(
|
|
1121
|
+
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent",
|
|
1122
|
+
params={"key": api_key},
|
|
1123
|
+
json={"model": f"models/{model}", "content": {"parts": [{"text": text}]}},
|
|
1124
|
+
timeout=60.0,
|
|
1125
|
+
)
|
|
1126
|
+
if response.status_code >= 400:
|
|
1127
|
+
if response.status_code == 429:
|
|
1128
|
+
raise _quota_error(response)
|
|
1129
|
+
raise HeadlessRelatedNotesExportError(
|
|
1130
|
+
blocked_reason="related_notes_headless_embedding_failed",
|
|
1131
|
+
next_action="Verificar chave/quota/rede do Gemini embeddings e repetir a recuperação do export.",
|
|
1132
|
+
detail=_redact_error(response.text),
|
|
1133
|
+
)
|
|
1134
|
+
try:
|
|
1135
|
+
payload = GeminiEmbeddingResponse.model_validate(response.json())
|
|
1136
|
+
except ValidationError as exc:
|
|
1137
|
+
raise RuntimeError("embedding response missing values[]") from exc
|
|
1138
|
+
return _extract_embedding(payload.embedding)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _quota_error(response: httpx.Response) -> HeadlessRelatedNotesExportError:
|
|
1142
|
+
return HeadlessRelatedNotesExportError(
|
|
1143
|
+
blocked_reason="related_notes_headless_quota_exhausted",
|
|
1144
|
+
next_action=(
|
|
1145
|
+
"Aguardar a quota do Gemini embeddings voltar ou trocar a chave no plugin Related Notes; "
|
|
1146
|
+
"depois repetir a atualização das Notas Relacionadas pela rota oficial."
|
|
1147
|
+
),
|
|
1148
|
+
detail=_redact_error(_response_error_text(response)),
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _extract_embedding(value: GeminiEmbedding) -> list[float]:
|
|
1153
|
+
return [float(item) for item in value.values]
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _sha256_text(text: str) -> str:
|
|
1157
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _now_iso() -> str:
|
|
1161
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _now_ms() -> int:
|
|
1165
|
+
return int(time.time() * 1000)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _redact_error(message: str) -> str:
|
|
1169
|
+
return re.sub(r"key=[A-Za-z0-9_\-]+", "key=<redacted>", message)[:500]
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _response_error_text(response: httpx.Response) -> str:
|
|
1173
|
+
try:
|
|
1174
|
+
payload = response.json()
|
|
1175
|
+
except ValueError:
|
|
1176
|
+
return response.text
|
|
1177
|
+
try:
|
|
1178
|
+
error_payload = GeminiErrorResponse.model_validate(payload)
|
|
1179
|
+
except ValidationError:
|
|
1180
|
+
return response.text
|
|
1181
|
+
if error_payload.error is not None:
|
|
1182
|
+
status = error_payload.error.status
|
|
1183
|
+
message = error_payload.error.message
|
|
1184
|
+
code = str(error_payload.error.code or response.status_code)
|
|
1185
|
+
return " ".join(part for part in [code, status, message] if part)
|
|
1186
|
+
return response.text
|