logancyang_obsidian-copilot/src/search/findRelevantNotes.ts
Zero Liu 6ca2dc01ea
chore(eslint): enable no-explicit-any; fix ~395 violations (#2452)
* chore(eslint): enable no-explicit-any; fix ~395 violations

Switches @typescript-eslint/no-explicit-any from "off" to "error" and
replaces explicit `any` with proper types or `unknown` + narrowing
across ~100 source files and 15 test files. Eleven eslint-disable
comments remain: one for a BaseLanguageModel prototype patch and ten
for Orama<any> API surfaces where typed alternatives poison Orama's
internal inference to `never`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scripts): cast chainContext to ChainManager in printPromptDebugEntry

The earlier `as any` → `as unknown` rewrite left buildAgentPromptDebugReport's
chainManager argument typed as `unknown`, which CI's `tsc -noEmit` (without
`--skipLibCheck`) flagged. Restore the cast through the real ChainManager type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): fix remaining no-explicit-any and unnecessary-type-assertion errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): drop redundant no-explicit-any rule

Already set to "error" by typescript-eslint/recommendedTypeChecked via
obsidianmd's recommended config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:08:45 -07:00

357 lines
11 KiB
TypeScript

import { logError, logInfo, logWarn } from "@/logger";
import { MiyoClient } from "@/miyo/MiyoClient";
import {
getMiyoCustomUrl,
getMiyoFilePath,
getMiyoFolderName,
getVaultRelativeMiyoPath,
shouldUseMiyo,
} from "@/miyo/miyoUtils";
import { getBacklinkedNotes, getLinkedNotes } from "@/noteUtils";
import { DBOperations } from "@/search/dbOperations";
import type { SemanticIndexDocument } from "@/search/indexBackend/SemanticIndexBackend";
import VectorStoreManager from "@/search/vectorStoreManager";
import { getSettings } from "@/settings/model";
import { InternalTypedDocument, Orama, Result } from "@orama/orama";
import { TFile } from "obsidian";
const MAX_K = 20;
const ORIGINAL_WEIGHT = 0.7;
const LINKS_WEIGHT = 0.3;
/**
* Determine whether Miyo-backed relevant-note scoring should be used.
*
* @returns True when Miyo mode and self-host access validation are active.
*/
function shouldUseMiyoForRelevantNotes(): boolean {
return shouldUseMiyo(getSettings());
}
/**
* Gets the highest score hits for each note and removes the current file path
* from the results.
* @param hits - The hits to get the highest score for.
* @param currentFilePath - The current file path.
* @returns A map of the highest score hits for each note.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- InternalTypedDocument<any> is required as Orama controls this type
function getHighestScoreHits(hits: Result<InternalTypedDocument<any>>[], currentFilePath: string) {
const hitMap = new Map<string, number>();
for (const hit of hits) {
const path = (hit.document as { path: string }).path;
const matchingScore = hitMap.get(path);
if (matchingScore) {
if (hit.score > matchingScore) {
hitMap.set(path, hit.score);
}
} else {
hitMap.set(path, hit.score);
}
}
hitMap.delete(currentFilePath);
return hitMap;
}
/**
* Normalize a score map to the top K entries, ordered by score descending.
*
* @param scoreMap - Map of path to score.
* @returns Capped map containing at most MAX_K entries.
*/
function capToTopK(scoreMap: Map<string, number>): Map<string, number> {
if (scoreMap.size <= MAX_K) {
return scoreMap;
}
const topK = Array.from(scoreMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_K);
return new Map(topK);
}
/**
* Return true when a semantic document has a usable embedding vector.
*
* @param doc - Semantic document candidate.
* @returns True when embedding data exists and is non-empty.
*/
function hasUsableEmbedding(doc: SemanticIndexDocument): boolean {
return Array.isArray(doc.embedding) && doc.embedding.length > 0;
}
/**
* Return true when the source note has non-empty chunk content.
*
* @param docs - Source note semantic chunks.
* @returns True when at least one chunk has content.
*/
function hasSourceChunkContent(docs: SemanticIndexDocument[]): boolean {
return docs.some((doc) => doc.content.trim().length > 0);
}
/**
* Calculate similarity scores using the legacy Orama vector path.
*
* @param db - The Orama database.
* @param filePath - The file path to calculate similarity scores for.
* @param currentNoteEmbeddings - Embedding vectors of the source note.
* @returns A map of note paths to their highest similarity scores.
*/
async function calculateSimilarityScoreFromOrama({
db,
filePath,
currentNoteEmbeddings,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Orama<any> is the correct API type
db: Orama<any>;
filePath: string;
currentNoteEmbeddings: number[][];
}): Promise<Map<string, number>> {
const searchPromises = currentNoteEmbeddings.map((embedding) =>
DBOperations.getDocsByEmbedding(db, embedding, {
limit: MAX_K,
similarity: 0,
})
);
const searchResults = await Promise.all(searchPromises);
const allHits = searchResults.flat();
const aggregatedHits = getHighestScoreHits(allHits, filePath);
return capToTopK(aggregatedHits);
}
/**
* Calculate similarity scores using Miyo's related-note endpoint.
*
* @param filePath - Source note path.
* @returns Map of note paths to max similarity score.
*/
async function calculateSimilarityScoreFromMiyo(filePath: string): Promise<Map<string, number>> {
const settings = getSettings();
const miyoClient = new MiyoClient();
const folderName = getMiyoFolderName(app);
const miyoFilePath = getMiyoFilePath(app, filePath);
try {
const baseUrl = await miyoClient.resolveBaseUrl(getMiyoCustomUrl(settings));
const response = await miyoClient.searchRelated(baseUrl, miyoFilePath, {
folderName,
limit: MAX_K,
});
const similarityScoreMap = new Map<string, number>();
const results = response.results || [];
for (const result of results) {
const relativePath = getVaultRelativeMiyoPath(app, result.path);
if (relativePath === filePath) {
continue;
}
if (typeof result.score !== "number" || Number.isNaN(result.score)) {
continue;
}
const existing = similarityScoreMap.get(relativePath);
if (existing === undefined || result.score > existing) {
similarityScoreMap.set(relativePath, result.score);
}
}
if (settings.debug) {
const sampleResponsePath = results[0]?.path;
const sampleStripped = sampleResponsePath
? getVaultRelativeMiyoPath(app, sampleResponsePath)
: undefined;
logInfo(
`RelevantNotes(Miyo): file_path=${miyoFilePath} folder_name=${folderName} ` +
`received ${results.length} chunks, collected ${similarityScoreMap.size} note scores ` +
`(sample response.path=${sampleResponsePath ?? "n/a"} → stripped=${sampleStripped ?? "n/a"})`
);
}
return capToTopK(similarityScoreMap);
} catch (error) {
logError(
`RelevantNotes(Miyo): searchRelated failed for file_path=${miyoFilePath} folder_name=${folderName}: ${
(error as Error).message
}`
);
return new Map();
}
}
/**
* Calculate similarity scores by selecting the best available backend strategy.
*
* @param filePath - Source note path.
* @returns Map of note paths to max similarity score.
*/
async function calculateSimilarityScore(filePath: string): Promise<Map<string, number>> {
if (shouldUseMiyoForRelevantNotes()) {
return calculateSimilarityScoreFromMiyo(filePath);
}
const currentNoteDocs = await VectorStoreManager.getInstance().getDocumentsByPath(filePath);
if (currentNoteDocs.length === 0) {
return new Map();
}
const currentNoteEmbeddings = currentNoteDocs
.filter((doc) => hasUsableEmbedding(doc))
.map((doc) => doc.embedding);
if (currentNoteEmbeddings.length > 0) {
try {
const db = await VectorStoreManager.getInstance().getDb();
return calculateSimilarityScoreFromOrama({
db,
filePath,
currentNoteEmbeddings,
});
} catch (error) {
logWarn("RelevantNotes(Orama): failed to compute similarity scores", error);
return new Map();
}
}
if (!hasSourceChunkContent(currentNoteDocs)) {
return new Map();
}
return calculateSimilarityScoreFromMiyo(filePath);
}
/**
* Build outgoing/backlink relationship flags for the source note.
*
* @param file - Source note file.
* @returns Map keyed by note path with link metadata.
*/
function getNoteLinks(file: TFile) {
const resultMap = new Map<string, { links: boolean; backlinks: boolean }>();
const linkedNotes = getLinkedNotes(file);
const linkedNotePaths = linkedNotes.map((note) => note.path);
for (const notePath of linkedNotePaths) {
resultMap.set(notePath, { links: true, backlinks: false });
}
const backlinkedNotes = getBacklinkedNotes(file);
const backlinkedNotePaths = backlinkedNotes.map((note) => note.path);
for (const notePath of backlinkedNotePaths) {
if (resultMap.has(notePath)) {
resultMap.set(notePath, { links: true, backlinks: true });
} else {
resultMap.set(notePath, { links: false, backlinks: true });
}
}
return resultMap;
}
/**
* Merge semantic similarity scores with note link heuristics.
*
* @param similarityScoreMap - Semantic score map.
* @param noteLinks - Outgoing/backlink flags map.
* @returns Combined score map used for ranking.
*/
function mergeScoreMaps(
similarityScoreMap: Map<string, number>,
noteLinks: Map<string, { links: boolean; backlinks: boolean }>
) {
const mergedMap = new Map<string, number>();
const totalWeight = ORIGINAL_WEIGHT + LINKS_WEIGHT;
for (const [key, value] of similarityScoreMap) {
mergedMap.set(key, (value * ORIGINAL_WEIGHT) / totalWeight);
}
for (const [key, value] of noteLinks) {
let score = 0;
if (value.links && value.backlinks) {
score = LINKS_WEIGHT;
} else if (value.links) {
// If the note only has outgoing or incoming links, give it a 80% links
// weight.
score = LINKS_WEIGHT * 0.8;
} else if (value.backlinks) {
score = LINKS_WEIGHT * 0.8;
}
mergedMap.set(key, (mergedMap.get(key) ?? 0) + score);
}
return mergedMap;
}
export type RelevantNoteEntry = {
note: {
path: string;
title: string;
};
metadata: {
score: number;
similarityScore: number | undefined;
hasOutgoingLinks: boolean;
hasBacklinks: boolean;
};
};
/**
* Finds the relevant notes for the given file path.
*
* @param filePath - The file path to find relevant notes for.
* @returns The relevant notes hits for the given file path. Empty array if no
* relevant notes are found or the index does not exist.
*/
export async function findRelevantNotes({
filePath,
}: {
filePath: string;
}): Promise<RelevantNoteEntry[]> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) {
return [];
}
const similarityScoreMap = await calculateSimilarityScore(filePath);
const noteLinks = getNoteLinks(file);
const mergedScoreMap = mergeScoreMaps(similarityScoreMap, noteLinks);
const sortedHits = Array.from(mergedScoreMap.entries()).sort((a, b) => {
const aPath = a[0];
const bPath = b[0];
const aCategory = getSimilarityCategory(similarityScoreMap.get(aPath) ?? 0);
const bCategory = getSimilarityCategory(similarityScoreMap.get(bPath) ?? 0);
if (aCategory !== bCategory) {
return bCategory - aCategory;
}
return b[1] - a[1];
});
return sortedHits
.map(([path, score]) => {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile) || file.extension !== "md") {
return null;
}
return {
note: {
path,
title: file.basename,
},
metadata: {
score,
similarityScore: similarityScoreMap.get(path),
hasOutgoingLinks: noteLinks.get(path)?.links ?? false,
hasBacklinks: noteLinks.get(path)?.backlinks ?? false,
},
};
})
.filter((entry) => entry !== null);
}
/**
* Gets the similarity category for the given score.
* @param score - The score to get the similarity category for.
* @returns The similarity category. 1 is low, 2 is medium, 3 is high.
*/
export function getSimilarityCategory(score: number): number {
if (score > 0.7) return 3;
if (score > 0.55) return 2;
return 1;
}