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).
This commit is contained in:
Kodai Nakamura 2026-04-06 22:59:52 +09:00
parent 7855027117
commit 706bbf91ef
13 changed files with 707 additions and 80 deletions

56
PLAN_AI_DISAMBIGUATION.md Normal file
View file

@ -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<string, string>` を受け取り、優先的に適用する。
- [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 処理開始時のテキストのスナップショットを保持し、置換時に大幅な変更があれば警告を出す。
- **トークン制限**: 段落単位での分割処理により、長いノートにも対応。

View file

@ -0,0 +1,6 @@
export const request = async () => ({});
export class Notice {
constructor() {}
hide() {}
setMessage() {}
}

View file

@ -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

View file

@ -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.")
})
})

View file

@ -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()))

View file

@ -32,11 +32,12 @@ export interface ReplaceLinksOptions {
linkResolverContext: LinkResolverContext
settings?: ReplaceLinksSettings
linkGenerator?: LinkGenerator
resolvedAmbiguities?: Map<string, string>
}
// 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, string>,
): 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, string>,
): 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++
}
}

View file

@ -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
}

View file

@ -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)
}
})
})
}
}

View file

@ -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<string, CandidateData>()
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")
})
})
}

View file

@ -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<string, CandidateData>([
[
"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")
})
})

102
src/utils/ai-client.ts Normal file
View file

@ -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<string> => {
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<Map<string, string>> => {
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<string, string>()
if (result.results && Array.isArray(result.results)) {
for (const item of result.results) {
if (item.selectedPath) {
resultMap.set(item.word, item.selectedPath)
}
}
}
return resultMap
}

View file

@ -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<string, CandidateData>,
trie: TrieNode,
settings: AutomaticLinkerSettings,
): Promise<Map<string, string>> => {
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)
}

View file

@ -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"),
},
},
})