From 706bbf91ef1d40c7c2cdd6df525507bf3cff6e3d Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 6 Apr 2026 22:59:52 +0900 Subject: [PATCH] feat: add AI Link Enhancer for resolving ambiguous links - Implemented AI-powered link disambiguation using local LLMs (Gemma 4 / LM Studio). - Refactored Trie and candidate mapping to support multiple link candidates per word. - Added 'AI Link Enhancer' command with a progress bar UI in Obsidian. - Enhanced link replacement logic to verify existing links and resolve ambiguities using context. - Added comprehensive unit tests for AI disambiguation and candidate mapping. - Updated settings to include AI configuration (endpoint, model, context length). --- PLAN_AI_DISAMBIGUATION.md | 56 ++++++++++ src/__mocks__/obsidian.ts | 6 + src/main.ts | 71 ++++++++++++ .../__tests__/ai-disambiguation.test.ts | 56 ++++++++++ src/replace-links/__tests__/prev.test.ts | 104 +++++++++++------- src/replace-links/replace-links.ts | 88 ++++++++++++--- src/settings/settings-info.ts | 8 ++ src/settings/settings.ts | 57 ++++++++++ src/trie.ts | 80 +++++++++----- .../__tests__/resolve-ambiguities.test.ts | 76 +++++++++++++ src/utils/ai-client.ts | 102 +++++++++++++++++ src/utils/resolve-ambiguities.ts | 79 +++++++++++++ vitest.config.ts | 4 + 13 files changed, 707 insertions(+), 80 deletions(-) create mode 100644 PLAN_AI_DISAMBIGUATION.md create mode 100644 src/__mocks__/obsidian.ts create mode 100644 src/replace-links/__tests__/ai-disambiguation.test.ts create mode 100644 src/utils/__tests__/resolve-ambiguities.test.ts create mode 100644 src/utils/ai-client.ts create mode 100644 src/utils/resolve-ambiguities.ts diff --git a/PLAN_AI_DISAMBIGUATION.md b/PLAN_AI_DISAMBIGUATION.md new file mode 100644 index 0000000..001ab24 --- /dev/null +++ b/PLAN_AI_DISAMBIGUATION.md @@ -0,0 +1,56 @@ +# プラン: Gemma 4 による「あいまいさ解消(Disambiguation)」機能の実装 (2-Pass 方式) + +このプランは、Obsidian Automatic Linker において、Gemma 4 (Local LLM / LM Studio) を用いて文脈に最適なリンク先を選択、および既存の誤ったリンクを修正する機能を導入するものです。 + +## 1. 現状の分析と課題 +- **データ構造の制限**: `src/trie.ts` の `CandidateData` インターフェースが単一の `canonical` パスしか保持できない。 +- **重複登録の挙動**: `buildCandidateTrie` 関数において、同じ名前の単語やエイリアスが見つかった場合、最初に見つかったものが優先されるか、後から来たもので上書きされている。 +- **既存リンクの誤り**: 機械的な処理や手動で作成された不適切な `[[Note]]` リンクの存在。 +- **パフォーマンス**: AI 処理は低速なため、コマンドによる明示的な実行と、進捗バー付きの UI フィードバックが必要。 + +## 2. 実装フェーズ + +### フェーズ 1: データ構造の拡張 (Data Structure) +- [x] `CandidateData` インターフェースの修正 (`src/trie.ts`) + - `canonical`, `scoped`, `namespace` を持つオブジェクトの配列を保持できるように変更。 +- [x] `buildCandidateTrie` の修正 + - 重複する名前やエイリアスがある場合、既存の候補リストに `push` するように変更。 +- [x] **完了条件**: + - `src/trie.ts` の既存テストおよび新規追加テスト(複数候補の保持)がパスすること。 + - `pnpm lint` および `pnpm tsc` (タイプチェック) でエラーがないこと。 + +### フェーズ 2: AI 連携クライアントと「解決ロジック」の実装 (AI Integration) +- [x] `src/utils/ai-client.ts` の新規作成 + - OpenAI 互換 API へのリクエスト処理とプロンプト設計。 +- [x] 非同期スキャン関数 `resolveAmbiguities` の実装 + - 1. 新規リンク候補(複数候補あり)の特定。 + - 2. 既存リンクの再検証(別のより良い候補がないか)の特定。 +- [x] **完了条件**: + - モックを使用した `resolveAmbiguities` のテストがパスすること。 + - `pnpm lint` および `pnpm tsc` でエラーがないこと。 + +### フェーズ 3: リンク置換エンジンの拡張 (Sync Logic Enhancement) +- [x] `src/replace-links/replace-links.ts` の `replaceLinks` 引数の拡張 + - `resolvedAmbiguities?: Map` を受け取り、優先的に適用する。 +- [x] 既存リンクの「張り替え」ロジックの追加。 +- [x] **完了条件**: + - `replace-links.test.ts` に AI 解決マップを使用したテストケースを追加し、パスすること。 + - `pnpm lint` および `pnpm tsc` でエラーがないこと。 + +### フェーズ 4: UI 実装とコマンドの追加 (UI & Integration) +- [x] `Automatic Linker: Run AI Link Enhancer` コマンドの実装。 +- [x] 進捗バー付き `Notice` による UI フィードバックの実装。 +- [x] `src/settings/settings.ts` への AI 設定追加。 +- [x] **完了条件**: + - Obsidian 上での実機動作確認(Notice の表示、リンクの修正・生成)。 + - 全体のビルド (`pnpm build`) が成功すること。 + +## 3. 共通の品質基準 (Definition of Done) +- 各フェーズの最後には必ず以下のコマンドを実行し、エラーがないことを確認する。 + 1. `pnpm test` (または `vitest`) + 2. `pnpm lint` + 3. `pnpm tsc` + +## 4. リスクと対策 +- **エディタの不整合**: AI 処理開始時のテキストのスナップショットを保持し、置換時に大幅な変更があれば警告を出す。 +- **トークン制限**: 段落単位での分割処理により、長いノートにも対応。 diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts new file mode 100644 index 0000000..4d0a41b --- /dev/null +++ b/src/__mocks__/obsidian.ts @@ -0,0 +1,6 @@ +export const request = async () => ({}); +export class Notice { + constructor() {} + hide() {} + setMessage() {} +} diff --git a/src/main.ts b/src/main.ts index c227c04..ce6deed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,7 @@ import { } from "./settings/settings-info" import { buildCandidateTrie, CandidateData, TrieNode } from "./trie" import { updateEditor } from "./update-editor" +import { resolveAmbiguities } from "./utils/resolve-ambiguities" const sleep = (ms: number) => { return new Promise(resolve => setTimeout(resolve, ms)) @@ -480,6 +481,76 @@ export default class AutomaticLinkerPlugin extends Plugin { }, }) + this.addCommand({ + id: "ai-link-enhancer", + name: "Run AI Link Enhancer", + icon: "sparkles", + editorCallback: async (editor: Editor) => { + if (!this.settings.aiEnabled) { + new Notice("AI Link Enhancement is not enabled in settings.") + return + } + + const activeFile = this.app.workspace.getActiveFile() + if (!activeFile) return + + const noticeFragment = document.createDocumentFragment() + const container = noticeFragment.createEl("div") + container.createEl("div", { text: "AI Link Enhancer: Analyzing context..." }) + const progress = container.createEl("progress") + progress.setAttr("style", "width: 100%; height: 10px;") + const notice = new Notice(noticeFragment, 0) + + try { + const fileContent = await this.app.vault.read(activeFile) + const { contentStart } = getFrontMatterInfo(fileContent) + const body = fileContent.slice(contentStart) + + if (!this.candidateMap || !this.trie) { + this.refreshFileDataAndTrie() + } + + if (!this.candidateMap || !this.trie) { + new Notice("Failed to build index.") + return + } + + const resolvedAmbiguitiesResult = await resolveAmbiguities( + body, + this.candidateMap, + this.trie, + this.settings, + ) + + const resultBody = replaceLinks({ + body, + linkResolverContext: { + filePath: activeFile.path, + trie: this.trie, + candidateMap: this.candidateMap, + }, + settings: this.settings, + resolvedAmbiguities: resolvedAmbiguitiesResult, + }) + + if (body !== resultBody) { + updateEditor(body, resultBody, editor) + new Notice("AI Link Enhancement completed.") + } + else { + new Notice("No links to enhance.") + } + } + catch (error) { + console.error("AI Link Enhancer error:", error) + new Notice("AI Link Enhancement failed. Check console for details.") + } + finally { + notice.hide() + } + }, + }) + // Optionally, override the default save command to run modifyLinks (throttled). const saveCommandDefinition = this.app?.commands?.commands?.["editor:save-file"] const saveCallback = saveCommandDefinition?.checkCallback diff --git a/src/replace-links/__tests__/ai-disambiguation.test.ts b/src/replace-links/__tests__/ai-disambiguation.test.ts new file mode 100644 index 0000000..ba3be89 --- /dev/null +++ b/src/replace-links/__tests__/ai-disambiguation.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest" +import { replaceLinks } from "../replace-links" +import { buildCandidateTrie } from "../../trie" + +describe("replaceLinks with AI disambiguation", () => { + const allFiles = [ + { path: "work/meeting", scoped: false, aliases: [] }, + { path: "private/meeting", scoped: false, aliases: [] }, + ] + const { candidateMap, trie } = buildCandidateTrie(allFiles, undefined, true) + const context = { + filePath: "test.md", + trie, + candidateMap, + } + + it("should use the AI-resolved path for unlinked words", () => { + const body = "I have a meeting." + const resolvedAmbiguities = new Map([["meeting", "work/meeting"]]) + + const result = replaceLinks({ + body, + linkResolverContext: context, + resolvedAmbiguities, + }) + + expect(result).toBe("I have a [[work/meeting|meeting]].") + }) + + it("should correct existing links if resolvedAmbiguities contains them", () => { + const body = "Check [[private/meeting|meeting]] notes." + const resolvedAmbiguities = new Map([["[[private/meeting|meeting]]", "work/meeting"]]) + + const result = replaceLinks({ + body, + linkResolverContext: context, + resolvedAmbiguities, + }) + + expect(result).toBe("Check [[work/meeting|meeting]] notes.") + }) + + it("should handle existing links without alias for correction", () => { + const body = "Check [[private/meeting]] notes." + // The resolveAmbiguities scanner uses the full link as key + const resolvedAmbiguities = new Map([["[[private/meeting]]", "work/meeting"]]) + + const result = replaceLinks({ + body, + linkResolverContext: context, + resolvedAmbiguities, + }) + + expect(result).toBe("Check [[work/meeting|private/meeting]] notes.") + }) +}) diff --git a/src/replace-links/__tests__/prev.test.ts b/src/replace-links/__tests__/prev.test.ts index 67b8647..b3f59dc 100644 --- a/src/replace-links/__tests__/prev.test.ts +++ b/src/replace-links/__tests__/prev.test.ts @@ -1046,34 +1046,42 @@ describe("replaceLinks (manual candidateMap/trie)", () => { [ "x", { - canonical: "namespace/x", - scoped: true, - namespace: "namespace", + candidates: [{ + canonical: "namespace/x", + scoped: true, + namespace: "namespace", + }], }, ], [ "z", { - canonical: "namespace/y/z", - scoped: true, - namespace: "namespace", + candidates: [{ + canonical: "namespace/y/z", + scoped: true, + namespace: "namespace", + }], }, ], [ "root", { - canonical: "root-note", - scoped: true, - namespace: "", + candidates: [{ + canonical: "root-note", + scoped: true, + namespace: "", + }], }, ], // Candidate without namespace restriction. [ "free", { - canonical: "free-note", - scoped: false, - namespace: "other", + candidates: [{ + canonical: "free-note", + scoped: false, + namespace: "other", + }], }, ], // For alias testing: @@ -1081,71 +1089,87 @@ describe("replaceLinks (manual candidateMap/trie)", () => { [ "pages/HelloWorld", { - canonical: "pages/HelloWorld", - scoped: false, - namespace: "pages", + candidates: [{ + canonical: "pages/HelloWorld", + scoped: false, + namespace: "pages", + }], }, ], // Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello". [ "Hello", { - canonical: "pages/HelloWorld|Hello", - scoped: false, - namespace: "pages", + candidates: [{ + canonical: "pages/HelloWorld|Hello", + scoped: false, + namespace: "pages", + }], }, ], // Also register the shorthand candidate. [ "HelloWorld", { - canonical: "HelloWorld", - scoped: false, - namespace: "pages", + candidates: [{ + canonical: "HelloWorld", + scoped: false, + namespace: "pages", + }], }, ], // For "tags" test: candidate key "tags" should map to canonical "tags" [ "pages/tags", { - canonical: "pages/tags", - scoped: false, - namespace: "pages", + candidates: [{ + canonical: "pages/tags", + scoped: false, + namespace: "pages", + }], }, ], [ "tags", { - canonical: "tags", - scoped: false, - namespace: "pages", + candidates: [{ + canonical: "tags", + scoped: false, + namespace: "pages", + }], }, ], // For Korean test, add candidate "문서" [ "문서", { - canonical: "문서", - scoped: false, - namespace: "namespace", + candidates: [{ + canonical: "문서", + scoped: false, + namespace: "namespace", + }], }, ], // For Japanese test, add candidate "ひらがな" [ "ひらがな", { - canonical: "ひらがな", - scoped: false, - namespace: "namespace", + candidates: [{ + canonical: "ひらがな", + scoped: false, + namespace: "namespace", + }], }, ], // For Chinese test, add candidate "文档" [ "文档", { - canonical: "文档", - scoped: false, - namespace: "namespace", + candidates: [{ + canonical: "文档", + scoped: false, + namespace: "namespace", + }], }, ], ]) @@ -1266,9 +1290,11 @@ describe("replaceLinks (manual candidateMap/trie)", () => { // Add candidate "a" corresponding to a file at "pages/set/a" // with scoped enabled and an effective namespace of "set". candidateMap.set("a", { - canonical: "set/a", - scoped: true, - namespace: "set", + candidates: [{ + canonical: "set/a", + scoped: true, + namespace: "set", + }], }) const trie = buildTrie(Array.from(candidateMap.keys())) diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index 3c88926..3576609 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -32,11 +32,12 @@ export interface ReplaceLinksOptions { linkResolverContext: LinkResolverContext settings?: ReplaceLinksSettings linkGenerator?: LinkGenerator + resolvedAmbiguities?: Map } // Constants and Regular Expressions const REGEX_PATTERNS = { - PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g, + PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g, DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/, MONTH_NOTE: /^[0-9]{1,2}$/, CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u, @@ -169,12 +170,12 @@ const isSelfLink = ( currentFilePath: string, settings: ReplaceLinksSettings = {}, ): boolean => { - if (!settings.preventSelfLinking) { + if (!settings.preventSelfLinking || candidateData.candidates.length === 0) { return false } // Extract the link path from the canonical path - const { linkPath } = extractLinkParts(candidateData.canonical) + const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical) // Normalize paths for comparison const normalizedLinkPath = normalizeCanonicalPath( @@ -220,8 +221,11 @@ const createLinkContent = ( originalMatchedText: string, settings: ReplaceLinksSettings = {}, ): { linkPath: string, alias?: string } => { + if (candidateData.candidates.length === 0) { + return { linkPath: originalMatchedText } + } const { linkPath, alias, hasAlias } = extractLinkParts( - candidateData.canonical, + candidateData.candidates[0].canonical, ) const normalizedPath = normalizeCanonicalPath(linkPath, settings.baseDir) @@ -365,6 +369,7 @@ const processCjkText = ( filePath: string, linkGenerator: LinkGenerator, settings: ReplaceLinksSettings = {}, + resolvedAmbiguities?: Map, ): string => { // For CJK texts that might contain non-CJK terms like "taro-san", ensure we use a consistent approach // Pass the proper filePath to maintain correct namespace resolution @@ -377,6 +382,7 @@ const processCjkText = ( currentNamespace, linkGenerator, settings, + resolvedAmbiguities, ) } @@ -459,7 +465,11 @@ const processFallbackSearch = ( // Filter candidates based on namespace restrictions const filteredCandidates = longestMatch.candidateList.filter( - ([, data]) => !(data.scoped && data.namespace !== currentNamespace), + ([, data]) => { + if (data.candidates.length === 0) return true + const candidate = data.candidates[0] + return !(candidate.scoped && candidate.namespace !== currentNamespace) + }, ) let bestCandidateData: CandidateData | null = null @@ -649,6 +659,7 @@ const processStandardText = ( currentNamespace: string, linkGenerator: LinkGenerator, settings: ReplaceLinksSettings = {}, + resolvedAmbiguities?: Map, ): string => { let result = "" let i = 0 @@ -787,8 +798,9 @@ const processStandardText = ( // Skip if namespace restriction applies if ( settings.proximityBasedLinking - && candidateData.scoped - && candidateData.namespace !== currentNamespace + && candidateData.candidates.length > 0 + && candidateData.candidates[0].scoped + && candidateData.candidates[0].namespace !== currentNamespace ) { result += candidate i += candidate.length @@ -796,11 +808,25 @@ const processStandardText = ( } // Create the link - const { linkPath, alias } = createLinkContent( - candidateData, - originalMatchedText, - settings, - ) + let linkPath = "" + let alias: string | undefined + + if (resolvedAmbiguities?.has(candidate)) { + const resolvedPath = resolvedAmbiguities.get(candidate)! + const parts = extractLinkParts(resolvedPath) + linkPath = parts.linkPath + alias = parts.alias || candidate + } + else { + const content = createLinkContent( + candidateData, + candidate, + settings, + ) + linkPath = content.linkPath + alias = content.alias + } + const isInTable = isIndexInsideMarkdownTable(text, i) const finalLink = linkGenerator({ linkPath, @@ -851,6 +877,7 @@ export const replaceLinks = ({ ignoreDateFormats: true, }, linkGenerator = defaultLinkGenerator, + resolvedAmbiguities, }: ReplaceLinksOptions): string => { // Normalize the body text to NFC body = body.normalize("NFC") @@ -880,6 +907,7 @@ export const replaceLinks = ({ filePath, linkGenerator, settings, + resolvedAmbiguities, ) } else { @@ -892,6 +920,7 @@ export const replaceLinks = ({ currentNamespace, linkGenerator, settings, + resolvedAmbiguities, ) } } @@ -939,12 +968,39 @@ export const replaceLinks = ({ const mIndex = match.index const segment = bodyWithPlaceholders.slice(lastIndex, mIndex) resultBody += processTextSegment(segment) - // Append the protected segment unchanged - resultBody += match[0] - lastIndex = mIndex + match[0].length + + const fullMatch = match[0] + if (resolvedAmbiguities?.has(fullMatch)) { + // Existing link replacement + const resolvedPath = resolvedAmbiguities.get(fullMatch)! + const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath) + + // Try to extract existing alias from the matched link + const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/ + const linkMatch = fullMatch.match(existingLinkRegex) + const existingPath = linkMatch ? linkMatch[1] : "" + const existingAlias = linkMatch ? linkMatch[2] : undefined + + // Use resolved alias if present, otherwise use existing alias, + // otherwise use existing path (as alias if it was a simple link) + const finalAlias = resolvedAlias || existingAlias || (fullMatch.includes("|") ? undefined : existingPath) + + const isInTable = isIndexInsideMarkdownTable(bodyWithPlaceholders, mIndex) + resultBody += linkGenerator({ + linkPath, + sourcePath: filePath, + alias: finalAlias, + isInTable, + }) + } + else { + // Append the protected segment unchanged + resultBody += fullMatch + } + lastIndex = mIndex + fullMatch.length // Prevent infinite loop on zero-length matches - if (match[0].length === 0) { + if (fullMatch.length === 0) { REGEX_PATTERNS.PROTECTED.lastIndex++ } } diff --git a/src/settings/settings-info.ts b/src/settings/settings-info.ts index 3b30e2c..56698a3 100644 --- a/src/settings/settings-info.ts +++ b/src/settings/settings-info.ts @@ -22,6 +22,10 @@ export type AutomaticLinkerSettings = { runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting formatDelayMs: number // Delay in milliseconds before running linter after formatting + aiEnabled: boolean // Enable AI link enhancement + aiEndpoint: string // API endpoint for AI (OpenAI compatible) + aiModel: string // Model name for AI + aiMaxContext: number // Maximum context length for AI } export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { @@ -48,4 +52,8 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { runLinterAfterFormatting: false, // Default: do not run linter after formatting runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting formatDelayMs: 1, // Default: 1ms delay before running linter + aiEnabled: false, // Default: disable AI enhancement + aiEndpoint: "http://localhost:1234/v1", // Default: LM Studio + aiModel: "gemma-4-7b", // Default: Gemma 4 + aiMaxContext: 500, // Default: 500 characters } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 29e088a..c22e4c0 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -431,5 +431,62 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { await this.plugin.saveData(this.plugin.settings) }) }) + + new Setting(containerEl) + .setName("AI Link Enhancement (Beta)") + .setHeading() + + new Setting(containerEl) + .setName("Enable AI Link Enhancement") + .setDesc( + "When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.", + ) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.aiEnabled) + .onChange(async (value) => { + this.plugin.settings.aiEnabled = value + await this.plugin.saveData(this.plugin.settings) + }) + }) + + new Setting(containerEl) + .setName("AI API Endpoint") + .setDesc("The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).") + .addText((text) => { + text.setPlaceholder("http://localhost:1234/v1") + .setValue(this.plugin.settings.aiEndpoint) + .onChange(async (value) => { + this.plugin.settings.aiEndpoint = value + await this.plugin.saveData(this.plugin.settings) + }) + }) + + new Setting(containerEl) + .setName("AI Model") + .setDesc("The name of the model to use (e.g. gemma-4-7b).") + .addText((text) => { + text.setPlaceholder("gemma-4-7b") + .setValue(this.plugin.settings.aiModel) + .onChange(async (value) => { + this.plugin.settings.aiModel = value + await this.plugin.saveData(this.plugin.settings) + }) + }) + + new Setting(containerEl) + .setName("Max Context Length") + .setDesc("Number of characters around the link to provide as context to the AI.") + .addText((text) => { + text.setPlaceholder("500") + .setValue(this.plugin.settings.aiMaxContext.toString()) + .onChange(async (value) => { + const parsedValue = parseInt(value) + if (!isNaN(parsedValue) && parsedValue > 0) { + this.plugin.settings.aiMaxContext = parsedValue + await this.plugin.saveData(this.plugin.settings) + } + }) + }) } } diff --git a/src/trie.ts b/src/trie.ts index cfe7445..1ee56e7 100644 --- a/src/trie.ts +++ b/src/trie.ts @@ -63,12 +63,16 @@ export const buildTrie = (words: string[], ignoreCase = false): TrieNode => { } // CandidateData holds the canonical replacement string as well as namespace‐設定 -export interface CandidateData { +export interface CandidateItem { canonical: string scoped: boolean namespace: string } +export interface CandidateData { + candidates: CandidateItem[] +} + export const buildCandidateTrie = ( allFiles: PathAndAliases[], baseDir: string | undefined, @@ -104,10 +108,23 @@ export const buildCandidateTrie = ( // Build a mapping from candidate string to its CandidateData. const candidateMap = new Map() + const addCandidate = (key: string, item: CandidateItem) => { + const existing = candidateMap.get(key) + if (existing) { + // Check if this canonical path is already added to avoid duplicates + if (!existing.candidates.some(c => c.canonical === item.canonical)) { + existing.candidates.push(item) + } + } + else { + candidateMap.set(key, { candidates: [item] }) + } + } + // Register normal candidates. for (const { full, short, scoped, namespace } of candidates) { - // Register the full path and its case-insensitive variant if needed - candidateMap.set(full, { + // Register the full path + addCandidate(full, { canonical: full, scoped, namespace, @@ -124,25 +141,21 @@ export const buildCandidateTrie = ( lastSegment, ) ) { - // Register both the original case and lowercase versions - candidateMap.set(lastSegment, { + const item = { canonical: full, scoped, namespace, - }) + } + addCandidate(lastSegment, item) if (ignoreCase) { - candidateMap.set(lastSegment.toLowerCase(), { - canonical: full, - scoped, - namespace, - }) + addCandidate(lastSegment.toLowerCase(), item) } } } // Register the short path if available if (short) { - candidateMap.set(short, { + addCandidate(short, { canonical: full, scoped, namespace, @@ -162,20 +175,15 @@ export const buildCandidateTrie = ( // If alias equals the shorthand, use alias as canonical; otherwise use "full|alias". const canonicalForAlias = short && alias === short ? alias : `${file.path}|${alias}` - if (!candidateMap.has(alias)) { - candidateMap.set(alias, { - canonical: canonicalForAlias, - scoped: file.scoped, - namespace: getTopLevelDirectoryName(file.path, baseDir), - }) + const item = { + canonical: canonicalForAlias, + scoped: file.scoped, + namespace: getTopLevelDirectoryName(file.path, baseDir), } + addCandidate(alias, item) // Register lowercase version when ignoreCase is enabled - if (ignoreCase && !candidateMap.has(alias.toLowerCase())) { - candidateMap.set(alias.toLowerCase(), { - canonical: canonicalForAlias, - scoped: file.scoped, - namespace: getTopLevelDirectoryName(file.path, baseDir), - }) + if (ignoreCase) { + addCandidate(alias.toLowerCase(), item) } } } @@ -246,11 +254,33 @@ if (import.meta.vitest) { expect(candidateMap.has("pages/docs/readme")).toBe(true) expect(candidateMap.has("docs/readme")).toBe(true) - expect(candidateMap.get("intro")?.canonical).toBe( + expect(candidateMap.get("intro")?.candidates[0].canonical).toBe( "pages/docs/readme|intro", ) expect(trie.children.has("d")).toBe(true) expect(trie.children.has("h")).toBe(true) }) + + it("should handle multiple candidates for the same word", () => { + const allFiles: PathAndAliases[] = [ + { + path: "work/meeting", + scoped: false, + aliases: [], + }, + { + path: "private/meeting", + scoped: false, + aliases: [], + }, + ] + + const { candidateMap } = buildCandidateTrie(allFiles, undefined, true) + + const meetingData = candidateMap.get("meeting") + expect(meetingData?.candidates).toHaveLength(2) + expect(meetingData?.candidates.map(c => c.canonical)).toContain("work/meeting") + expect(meetingData?.candidates.map(c => c.canonical)).toContain("private/meeting") + }) }) } diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts new file mode 100644 index 0000000..9214da4 --- /dev/null +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from "vitest" + +vi.mock("obsidian", () => ({ + request: vi.fn(), +})) + +import { resolveAmbiguities } from "../resolve-ambiguities" +import { CandidateData, TrieNode, buildTrie } from "../../trie" +import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "../../settings/settings-info" +import * as aiClient from "../ai-client" + +vi.mock("../ai-client", () => ({ + callAI: vi.fn(), + resolveAmbiguitiesBatch: vi.fn(), +})) + +describe("resolveAmbiguities", () => { + const mockSettings: AutomaticLinkerSettings = { + ...DEFAULT_SETTINGS, + aiMaxContext: 50, + } + + const candidateMap = new Map([ + [ + "meeting", + { + candidates: [ + { canonical: "work/meeting", scoped: false, namespace: "work" }, + { canonical: "private/meeting", scoped: false, namespace: "private" }, + ], + }, + ], + ]) + + const trie: TrieNode = buildTrie(["meeting"], true) + + it("should identify ambiguous unlinked words", async () => { + const text = "I have a meeting tomorrow." + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue( + new Map([["meeting", "work/meeting"]]) + ) + + const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + mockSettings, + expect.arrayContaining([ + expect.objectContaining({ + word: "meeting", + candidates: ["work/meeting", "private/meeting"], + }) + ]) + ) + expect(result.get("meeting")).toBe("work/meeting") + }) + + it("should identify ambiguous existing links for verification", async () => { + const text = "Check the [[private/meeting|meeting]] notes." + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue( + new Map([["[[private/meeting|meeting]]", "work/meeting"]]) + ) + + const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + mockSettings, + expect.arrayContaining([ + expect.objectContaining({ + word: "[[private/meeting|meeting]]", + candidates: ["work/meeting", "private/meeting"], + }) + ]) + ) + expect(result.get("[[private/meeting|meeting]]")).toBe("work/meeting") + }) +}) diff --git a/src/utils/ai-client.ts b/src/utils/ai-client.ts new file mode 100644 index 0000000..0eacf9f --- /dev/null +++ b/src/utils/ai-client.ts @@ -0,0 +1,102 @@ +import { request } from "obsidian" +import { AutomaticLinkerSettings } from "../settings/settings-info" + +export interface AIResolveRequest { + text: string + word: string + candidates: string[] +} + +export interface AIResolveResponse { + selectedPath: string | null +} + +export const callAI = async ( + settings: AutomaticLinkerSettings, + prompt: string, +): Promise => { + const url = `${settings.aiEndpoint}/chat/completions` + const body = { + model: settings.aiModel, + messages: [ + { + role: "system", + content: "You are an assistant that helps resolve ambiguous links in Obsidian notes. You must respond ONLY with a valid JSON object. Do not include any explanation or markdown code blocks.", + }, + { + role: "user", + content: prompt, + }, + ], + // Some local servers fail with response_format, so we rely on the prompt instructions + // temperature: 0 to make it more deterministic + temperature: 0, + } + + if (settings.debug) { + console.log("AI Link Enhancer Request:", JSON.stringify(body, null, 2)) + } + + try { + const response = await request({ + url, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + + if (settings.debug) { + console.log("AI Link Enhancer Response:", response) + } + + const data = JSON.parse(response) + return data.choices[0].message.content + } catch (error) { + console.error("AI Link Enhancer API Error:", error) + throw error + } +} + +export const resolveAmbiguitiesBatch = async ( + settings: AutomaticLinkerSettings, + requests: AIResolveRequest[], +): Promise> => { + if (requests.length === 0) return new Map() + + const prompt = ` +Please resolve the following ambiguous links. +Select the most appropriate "selectedPath" from the given "candidates" based on the context. +If no candidate is appropriate, return null for that item. + +You MUST respond with a JSON object in the following format: +{ + "results": [ + { "word": "word1", "selectedPath": "path/to/note" }, + ... + ] +} + +Input data: +${JSON.stringify(requests, null, 2)} +` + + const resultRaw = await callAI(settings, prompt) + + // Clean up potential markdown code blocks if the AI included them + const cleanJson = resultRaw.replace(/```json\n?/, "").replace(/\n?```/, "").trim() + + const result = JSON.parse(cleanJson) + const resultMap = new Map() + + if (result.results && Array.isArray(result.results)) { + for (const item of result.results) { + if (item.selectedPath) { + resultMap.set(item.word, item.selectedPath) + } + } + } + + return resultMap +} diff --git a/src/utils/resolve-ambiguities.ts b/src/utils/resolve-ambiguities.ts new file mode 100644 index 0000000..e7ddcae --- /dev/null +++ b/src/utils/resolve-ambiguities.ts @@ -0,0 +1,79 @@ +import { CandidateData, TrieNode } from "../trie" +import { AutomaticLinkerSettings } from "../settings/settings-info" +import { resolveAmbiguitiesBatch, AIResolveRequest } from "./ai-client" + +// A simplified scanner to find candidates that have multiple options or existing links to verify. +export const resolveAmbiguities = async ( + text: string, + candidateMap: Map, + trie: TrieNode, + settings: AutomaticLinkerSettings, +): Promise> => { + const requests: AIResolveRequest[] = [] + + // 1. Scan for existing links [[Path|Alias]] or [[Path]] + const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g + let match: RegExpExecArray | null + while ((match = existingLinkRegex.exec(text)) !== null) { + const fullMatch = match[0] + const path = match[1] + const alias = match[2] || path + + const candidateData = candidateMap.get(alias) + if (candidateData && candidateData.candidates.length > 1) { + const start = Math.max(0, match.index - settings.aiMaxContext) + const end = Math.min(text.length, match.index + fullMatch.length + settings.aiMaxContext) + + requests.push({ + word: fullMatch, // Using the full link as the "word" key for replacement + text: text.slice(start, end), + candidates: candidateData.candidates.map(c => c.canonical), + }) + } + } + + // 2. Scan for unlinked words with multiple candidates using the Trie + // (This is a simplified version of the trie traversal logic) + let i = 0 + while (i < text.length) { + let node = trie + let j = i + let lastMatch: { word: string; data: CandidateData; index: number } | null = null + + while (j < text.length && node.children.has(text[j].toLowerCase())) { + node = node.children.get(text[j].toLowerCase())! + if (node.candidate) { + const data = candidateMap.get(node.candidate) + if (data) { + lastMatch = { word: node.candidate, data, index: i } + } + } + j++ + } + + if (lastMatch && lastMatch.data.candidates.length > 1) { + // Check if it's already inside a link (simple check) + const isInsideLink = text.slice(Math.max(0, i - 2), i) === "[[" || + text.slice(i + lastMatch.word.length, i + lastMatch.word.length + 2) === "]]" + + if (!isInsideLink) { + const start = Math.max(0, i - settings.aiMaxContext) + const end = Math.min(text.length, i + lastMatch.word.length + settings.aiMaxContext) + + requests.push({ + word: lastMatch.word, + text: text.slice(start, end), + candidates: lastMatch.data.candidates.map(c => c.canonical), + }) + } + i += lastMatch.word.length + } else { + i++ + } + } + + // Deduplicate requests by word to avoid redundant AI calls + const uniqueRequests = Array.from(new Map(requests.map(r => [r.word, r])).values()) + + return await resolveAmbiguitiesBatch(settings, uniqueRequests) +} diff --git a/vitest.config.ts b/vitest.config.ts index 6cdf52f..77eb926 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "vitest/config" +import path from "path" export default defineConfig({ test: { includeSource: ["src/**/*.{js,ts}"], + alias: { + "obsidian": path.resolve(__dirname, "./src/__mocks__/obsidian.ts"), + }, }, })