mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
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:
parent
7855027117
commit
706bbf91ef
13 changed files with 707 additions and 80 deletions
56
PLAN_AI_DISAMBIGUATION.md
Normal file
56
PLAN_AI_DISAMBIGUATION.md
Normal 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 処理開始時のテキストのスナップショットを保持し、置換時に大幅な変更があれば警告を出す。
|
||||||
|
- **トークン制限**: 段落単位での分割処理により、長いノートにも対応。
|
||||||
6
src/__mocks__/obsidian.ts
Normal file
6
src/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const request = async () => ({});
|
||||||
|
export class Notice {
|
||||||
|
constructor() {}
|
||||||
|
hide() {}
|
||||||
|
setMessage() {}
|
||||||
|
}
|
||||||
71
src/main.ts
71
src/main.ts
|
|
@ -37,6 +37,7 @@ import {
|
||||||
} from "./settings/settings-info"
|
} from "./settings/settings-info"
|
||||||
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"
|
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"
|
||||||
import { updateEditor } from "./update-editor"
|
import { updateEditor } from "./update-editor"
|
||||||
|
import { resolveAmbiguities } from "./utils/resolve-ambiguities"
|
||||||
|
|
||||||
const sleep = (ms: number) => {
|
const sleep = (ms: number) => {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
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).
|
// Optionally, override the default save command to run modifyLinks (throttled).
|
||||||
const saveCommandDefinition = this.app?.commands?.commands?.["editor:save-file"]
|
const saveCommandDefinition = this.app?.commands?.commands?.["editor:save-file"]
|
||||||
const saveCallback = saveCommandDefinition?.checkCallback
|
const saveCallback = saveCommandDefinition?.checkCallback
|
||||||
|
|
|
||||||
56
src/replace-links/__tests__/ai-disambiguation.test.ts
Normal file
56
src/replace-links/__tests__/ai-disambiguation.test.ts
Normal 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.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1046,34 +1046,42 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
|
||||||
[
|
[
|
||||||
"x",
|
"x",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "namespace/x",
|
canonical: "namespace/x",
|
||||||
scoped: true,
|
scoped: true,
|
||||||
namespace: "namespace",
|
namespace: "namespace",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"z",
|
"z",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "namespace/y/z",
|
canonical: "namespace/y/z",
|
||||||
scoped: true,
|
scoped: true,
|
||||||
namespace: "namespace",
|
namespace: "namespace",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"root",
|
"root",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "root-note",
|
canonical: "root-note",
|
||||||
scoped: true,
|
scoped: true,
|
||||||
namespace: "",
|
namespace: "",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Candidate without namespace restriction.
|
// Candidate without namespace restriction.
|
||||||
[
|
[
|
||||||
"free",
|
"free",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "free-note",
|
canonical: "free-note",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "other",
|
namespace: "other",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// For alias testing:
|
// For alias testing:
|
||||||
|
|
@ -1081,71 +1089,87 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
|
||||||
[
|
[
|
||||||
"pages/HelloWorld",
|
"pages/HelloWorld",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "pages/HelloWorld",
|
canonical: "pages/HelloWorld",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "pages",
|
namespace: "pages",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello".
|
// Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello".
|
||||||
[
|
[
|
||||||
"Hello",
|
"Hello",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "pages/HelloWorld|Hello",
|
canonical: "pages/HelloWorld|Hello",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "pages",
|
namespace: "pages",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Also register the shorthand candidate.
|
// Also register the shorthand candidate.
|
||||||
[
|
[
|
||||||
"HelloWorld",
|
"HelloWorld",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "HelloWorld",
|
canonical: "HelloWorld",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "pages",
|
namespace: "pages",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// For "tags" test: candidate key "tags" should map to canonical "tags"
|
// For "tags" test: candidate key "tags" should map to canonical "tags"
|
||||||
[
|
[
|
||||||
"pages/tags",
|
"pages/tags",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "pages/tags",
|
canonical: "pages/tags",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "pages",
|
namespace: "pages",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"tags",
|
"tags",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "tags",
|
canonical: "tags",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "pages",
|
namespace: "pages",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// For Korean test, add candidate "문서"
|
// For Korean test, add candidate "문서"
|
||||||
[
|
[
|
||||||
"문서",
|
"문서",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "문서",
|
canonical: "문서",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "namespace",
|
namespace: "namespace",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// For Japanese test, add candidate "ひらがな"
|
// For Japanese test, add candidate "ひらがな"
|
||||||
[
|
[
|
||||||
"ひらがな",
|
"ひらがな",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "ひらがな",
|
canonical: "ひらがな",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "namespace",
|
namespace: "namespace",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// For Chinese test, add candidate "文档"
|
// For Chinese test, add candidate "文档"
|
||||||
[
|
[
|
||||||
"文档",
|
"文档",
|
||||||
{
|
{
|
||||||
|
candidates: [{
|
||||||
canonical: "文档",
|
canonical: "文档",
|
||||||
scoped: false,
|
scoped: false,
|
||||||
namespace: "namespace",
|
namespace: "namespace",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
@ -1266,9 +1290,11 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
|
||||||
// Add candidate "a" corresponding to a file at "pages/set/a"
|
// Add candidate "a" corresponding to a file at "pages/set/a"
|
||||||
// with scoped enabled and an effective namespace of "set".
|
// with scoped enabled and an effective namespace of "set".
|
||||||
candidateMap.set("a", {
|
candidateMap.set("a", {
|
||||||
|
candidates: [{
|
||||||
canonical: "set/a",
|
canonical: "set/a",
|
||||||
scoped: true,
|
scoped: true,
|
||||||
namespace: "set",
|
namespace: "set",
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,12 @@ export interface ReplaceLinksOptions {
|
||||||
linkResolverContext: LinkResolverContext
|
linkResolverContext: LinkResolverContext
|
||||||
settings?: ReplaceLinksSettings
|
settings?: ReplaceLinksSettings
|
||||||
linkGenerator?: LinkGenerator
|
linkGenerator?: LinkGenerator
|
||||||
|
resolvedAmbiguities?: Map<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constants and Regular Expressions
|
// Constants and Regular Expressions
|
||||||
const REGEX_PATTERNS = {
|
const REGEX_PATTERNS = {
|
||||||
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
|
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
|
||||||
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
|
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
|
||||||
MONTH_NOTE: /^[0-9]{1,2}$/,
|
MONTH_NOTE: /^[0-9]{1,2}$/,
|
||||||
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
|
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
|
||||||
|
|
@ -169,12 +170,12 @@ const isSelfLink = (
|
||||||
currentFilePath: string,
|
currentFilePath: string,
|
||||||
settings: ReplaceLinksSettings = {},
|
settings: ReplaceLinksSettings = {},
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!settings.preventSelfLinking) {
|
if (!settings.preventSelfLinking || candidateData.candidates.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the link path from the canonical path
|
// Extract the link path from the canonical path
|
||||||
const { linkPath } = extractLinkParts(candidateData.canonical)
|
const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical)
|
||||||
|
|
||||||
// Normalize paths for comparison
|
// Normalize paths for comparison
|
||||||
const normalizedLinkPath = normalizeCanonicalPath(
|
const normalizedLinkPath = normalizeCanonicalPath(
|
||||||
|
|
@ -220,8 +221,11 @@ const createLinkContent = (
|
||||||
originalMatchedText: string,
|
originalMatchedText: string,
|
||||||
settings: ReplaceLinksSettings = {},
|
settings: ReplaceLinksSettings = {},
|
||||||
): { linkPath: string, alias?: string } => {
|
): { linkPath: string, alias?: string } => {
|
||||||
|
if (candidateData.candidates.length === 0) {
|
||||||
|
return { linkPath: originalMatchedText }
|
||||||
|
}
|
||||||
const { linkPath, alias, hasAlias } = extractLinkParts(
|
const { linkPath, alias, hasAlias } = extractLinkParts(
|
||||||
candidateData.canonical,
|
candidateData.candidates[0].canonical,
|
||||||
)
|
)
|
||||||
const normalizedPath = normalizeCanonicalPath(linkPath, settings.baseDir)
|
const normalizedPath = normalizeCanonicalPath(linkPath, settings.baseDir)
|
||||||
|
|
||||||
|
|
@ -365,6 +369,7 @@ const processCjkText = (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
linkGenerator: LinkGenerator,
|
linkGenerator: LinkGenerator,
|
||||||
settings: ReplaceLinksSettings = {},
|
settings: ReplaceLinksSettings = {},
|
||||||
|
resolvedAmbiguities?: Map<string, string>,
|
||||||
): string => {
|
): string => {
|
||||||
// For CJK texts that might contain non-CJK terms like "taro-san", ensure we use a consistent approach
|
// 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
|
// Pass the proper filePath to maintain correct namespace resolution
|
||||||
|
|
@ -377,6 +382,7 @@ const processCjkText = (
|
||||||
currentNamespace,
|
currentNamespace,
|
||||||
linkGenerator,
|
linkGenerator,
|
||||||
settings,
|
settings,
|
||||||
|
resolvedAmbiguities,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -459,7 +465,11 @@ const processFallbackSearch = (
|
||||||
|
|
||||||
// Filter candidates based on namespace restrictions
|
// Filter candidates based on namespace restrictions
|
||||||
const filteredCandidates = longestMatch.candidateList.filter(
|
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
|
let bestCandidateData: CandidateData | null = null
|
||||||
|
|
@ -649,6 +659,7 @@ const processStandardText = (
|
||||||
currentNamespace: string,
|
currentNamespace: string,
|
||||||
linkGenerator: LinkGenerator,
|
linkGenerator: LinkGenerator,
|
||||||
settings: ReplaceLinksSettings = {},
|
settings: ReplaceLinksSettings = {},
|
||||||
|
resolvedAmbiguities?: Map<string, string>,
|
||||||
): string => {
|
): string => {
|
||||||
let result = ""
|
let result = ""
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
@ -787,8 +798,9 @@ const processStandardText = (
|
||||||
// Skip if namespace restriction applies
|
// Skip if namespace restriction applies
|
||||||
if (
|
if (
|
||||||
settings.proximityBasedLinking
|
settings.proximityBasedLinking
|
||||||
&& candidateData.scoped
|
&& candidateData.candidates.length > 0
|
||||||
&& candidateData.namespace !== currentNamespace
|
&& candidateData.candidates[0].scoped
|
||||||
|
&& candidateData.candidates[0].namespace !== currentNamespace
|
||||||
) {
|
) {
|
||||||
result += candidate
|
result += candidate
|
||||||
i += candidate.length
|
i += candidate.length
|
||||||
|
|
@ -796,11 +808,25 @@ const processStandardText = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the link
|
// Create the link
|
||||||
const { linkPath, alias } = createLinkContent(
|
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,
|
candidateData,
|
||||||
originalMatchedText,
|
candidate,
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
|
linkPath = content.linkPath
|
||||||
|
alias = content.alias
|
||||||
|
}
|
||||||
|
|
||||||
const isInTable = isIndexInsideMarkdownTable(text, i)
|
const isInTable = isIndexInsideMarkdownTable(text, i)
|
||||||
const finalLink = linkGenerator({
|
const finalLink = linkGenerator({
|
||||||
linkPath,
|
linkPath,
|
||||||
|
|
@ -851,6 +877,7 @@ export const replaceLinks = ({
|
||||||
ignoreDateFormats: true,
|
ignoreDateFormats: true,
|
||||||
},
|
},
|
||||||
linkGenerator = defaultLinkGenerator,
|
linkGenerator = defaultLinkGenerator,
|
||||||
|
resolvedAmbiguities,
|
||||||
}: ReplaceLinksOptions): string => {
|
}: ReplaceLinksOptions): string => {
|
||||||
// Normalize the body text to NFC
|
// Normalize the body text to NFC
|
||||||
body = body.normalize("NFC")
|
body = body.normalize("NFC")
|
||||||
|
|
@ -880,6 +907,7 @@ export const replaceLinks = ({
|
||||||
filePath,
|
filePath,
|
||||||
linkGenerator,
|
linkGenerator,
|
||||||
settings,
|
settings,
|
||||||
|
resolvedAmbiguities,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|
@ -892,6 +920,7 @@ export const replaceLinks = ({
|
||||||
currentNamespace,
|
currentNamespace,
|
||||||
linkGenerator,
|
linkGenerator,
|
||||||
settings,
|
settings,
|
||||||
|
resolvedAmbiguities,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -939,12 +968,39 @@ export const replaceLinks = ({
|
||||||
const mIndex = match.index
|
const mIndex = match.index
|
||||||
const segment = bodyWithPlaceholders.slice(lastIndex, mIndex)
|
const segment = bodyWithPlaceholders.slice(lastIndex, mIndex)
|
||||||
resultBody += processTextSegment(segment)
|
resultBody += processTextSegment(segment)
|
||||||
|
|
||||||
|
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
|
// Append the protected segment unchanged
|
||||||
resultBody += match[0]
|
resultBody += fullMatch
|
||||||
lastIndex = mIndex + match[0].length
|
}
|
||||||
|
lastIndex = mIndex + fullMatch.length
|
||||||
|
|
||||||
// Prevent infinite loop on zero-length matches
|
// Prevent infinite loop on zero-length matches
|
||||||
if (match[0].length === 0) {
|
if (fullMatch.length === 0) {
|
||||||
REGEX_PATTERNS.PROTECTED.lastIndex++
|
REGEX_PATTERNS.PROTECTED.lastIndex++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export type AutomaticLinkerSettings = {
|
||||||
runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting
|
runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting
|
||||||
runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting
|
runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting
|
||||||
formatDelayMs: number // Delay in milliseconds before running linter after 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 = {
|
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||||
|
|
@ -48,4 +52,8 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||||
runLinterAfterFormatting: false, // Default: do not run linter after formatting
|
runLinterAfterFormatting: false, // Default: do not run linter after formatting
|
||||||
runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting
|
runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting
|
||||||
formatDelayMs: 1, // Default: 1ms delay before running linter
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -431,5 +431,62 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
|
||||||
await this.plugin.saveData(this.plugin.settings)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/trie.ts
74
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‐設定
|
// CandidateData holds the canonical replacement string as well as namespace‐設定
|
||||||
export interface CandidateData {
|
export interface CandidateItem {
|
||||||
canonical: string
|
canonical: string
|
||||||
scoped: boolean
|
scoped: boolean
|
||||||
namespace: string
|
namespace: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CandidateData {
|
||||||
|
candidates: CandidateItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export const buildCandidateTrie = (
|
export const buildCandidateTrie = (
|
||||||
allFiles: PathAndAliases[],
|
allFiles: PathAndAliases[],
|
||||||
baseDir: string | undefined,
|
baseDir: string | undefined,
|
||||||
|
|
@ -104,10 +108,23 @@ export const buildCandidateTrie = (
|
||||||
// Build a mapping from candidate string to its CandidateData.
|
// Build a mapping from candidate string to its CandidateData.
|
||||||
const candidateMap = new Map<string, 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.
|
// Register normal candidates.
|
||||||
for (const { full, short, scoped, namespace } of candidates) {
|
for (const { full, short, scoped, namespace } of candidates) {
|
||||||
// Register the full path and its case-insensitive variant if needed
|
// Register the full path
|
||||||
candidateMap.set(full, {
|
addCandidate(full, {
|
||||||
canonical: full,
|
canonical: full,
|
||||||
scoped,
|
scoped,
|
||||||
namespace,
|
namespace,
|
||||||
|
|
@ -124,25 +141,21 @@ export const buildCandidateTrie = (
|
||||||
lastSegment,
|
lastSegment,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Register both the original case and lowercase versions
|
const item = {
|
||||||
candidateMap.set(lastSegment, {
|
|
||||||
canonical: full,
|
canonical: full,
|
||||||
scoped,
|
scoped,
|
||||||
namespace,
|
namespace,
|
||||||
})
|
}
|
||||||
|
addCandidate(lastSegment, item)
|
||||||
if (ignoreCase) {
|
if (ignoreCase) {
|
||||||
candidateMap.set(lastSegment.toLowerCase(), {
|
addCandidate(lastSegment.toLowerCase(), item)
|
||||||
canonical: full,
|
|
||||||
scoped,
|
|
||||||
namespace,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the short path if available
|
// Register the short path if available
|
||||||
if (short) {
|
if (short) {
|
||||||
candidateMap.set(short, {
|
addCandidate(short, {
|
||||||
canonical: full,
|
canonical: full,
|
||||||
scoped,
|
scoped,
|
||||||
namespace,
|
namespace,
|
||||||
|
|
@ -162,20 +175,15 @@ export const buildCandidateTrie = (
|
||||||
// If alias equals the shorthand, use alias as canonical; otherwise use "full|alias".
|
// If alias equals the shorthand, use alias as canonical; otherwise use "full|alias".
|
||||||
const canonicalForAlias
|
const canonicalForAlias
|
||||||
= short && alias === short ? alias : `${file.path}|${alias}`
|
= short && alias === short ? alias : `${file.path}|${alias}`
|
||||||
if (!candidateMap.has(alias)) {
|
const item = {
|
||||||
candidateMap.set(alias, {
|
|
||||||
canonical: canonicalForAlias,
|
canonical: canonicalForAlias,
|
||||||
scoped: file.scoped,
|
scoped: file.scoped,
|
||||||
namespace: getTopLevelDirectoryName(file.path, baseDir),
|
namespace: getTopLevelDirectoryName(file.path, baseDir),
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
addCandidate(alias, item)
|
||||||
// Register lowercase version when ignoreCase is enabled
|
// Register lowercase version when ignoreCase is enabled
|
||||||
if (ignoreCase && !candidateMap.has(alias.toLowerCase())) {
|
if (ignoreCase) {
|
||||||
candidateMap.set(alias.toLowerCase(), {
|
addCandidate(alias.toLowerCase(), item)
|
||||||
canonical: canonicalForAlias,
|
|
||||||
scoped: file.scoped,
|
|
||||||
namespace: getTopLevelDirectoryName(file.path, baseDir),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,11 +254,33 @@ if (import.meta.vitest) {
|
||||||
|
|
||||||
expect(candidateMap.has("pages/docs/readme")).toBe(true)
|
expect(candidateMap.has("pages/docs/readme")).toBe(true)
|
||||||
expect(candidateMap.has("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",
|
"pages/docs/readme|intro",
|
||||||
)
|
)
|
||||||
expect(trie.children.has("d")).toBe(true)
|
expect(trie.children.has("d")).toBe(true)
|
||||||
expect(trie.children.has("h")).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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
src/utils/__tests__/resolve-ambiguities.test.ts
Normal file
76
src/utils/__tests__/resolve-ambiguities.test.ts
Normal 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
102
src/utils/ai-client.ts
Normal 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
|
||||||
|
}
|
||||||
79
src/utils/resolve-ambiguities.ts
Normal file
79
src/utils/resolve-ambiguities.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { defineConfig } from "vitest/config"
|
import { defineConfig } from "vitest/config"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
includeSource: ["src/**/*.{js,ts}"],
|
includeSource: ["src/**/*.{js,ts}"],
|
||||||
|
alias: {
|
||||||
|
"obsidian": path.resolve(__dirname, "./src/__mocks__/obsidian.ts"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue