mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: enhance vault search to return content snippets with context
- Update SearchVaultFiles function to return contextual snippets instead of just file lists - Implement snippet extraction with configurable context length (300 chars) - Add snippet merging logic to consolidate overlapping matches - Limit results to 20 randomly sampled matches when exceeding threshold - Update return types across service layer to use SearchMatch/SearchSnippet - Change regex flags to 'ig' for global case-insensitive matching
This commit is contained in:
parent
f2a7a41705
commit
d58e9ef1ff
5 changed files with 176 additions and 18 deletions
|
|
@ -4,11 +4,11 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition";
|
|||
export const SearchVaultFiles: IAIFunctionDefinition = {
|
||||
name: AIFunction.SearchVaultFiles,
|
||||
description: `Searches through all files in the user's Obsidian vault for the given search term.
|
||||
Uses regex pattern matching to search both file names and file content.
|
||||
Returns list of matching vault files with metadata (names, paths) and contextual snippets showing matched content with surrounding text.
|
||||
Call this whenever you need to know what files exist in the vault to answer questions,
|
||||
verify file presence, or to perform further agentic functions.
|
||||
Use proactively when vault contents would inform your response.`,
|
||||
Uses regex pattern matching to search file content.
|
||||
Returns matching vault files with metadata (names, paths) and contextual snippets showing matched content with surrounding text to enable relevance assessment.
|
||||
Call this whenever you need to know what files exist in the vault to answer questions,
|
||||
verify file presence, or to perform further agentic functions.
|
||||
Use proactively when vault contents would inform your response.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
|
|||
18
Helpers/SearchTypes.ts
Normal file
18
Helpers/SearchTypes.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { TFile } from "obsidian";
|
||||
|
||||
/**
|
||||
* Represents a single snippet of matched content from a file
|
||||
*/
|
||||
export interface SearchSnippet {
|
||||
text: string;
|
||||
matchIndex: number;
|
||||
matchLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents all matches found in a single file
|
||||
*/
|
||||
export interface SearchMatch {
|
||||
file: TFile;
|
||||
snippets: SearchSnippet[];
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import type { FileSystemService } from "./FileSystemService";
|
||||
import { TFile } from "obsidian";
|
||||
import { AIFunction } from "Enums/AIFunction";
|
||||
import { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse";
|
||||
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
|
||||
import type { SearchMatch } from "../Helpers/SearchTypes";
|
||||
|
||||
export class AIFunctionService {
|
||||
|
||||
|
|
@ -33,10 +33,14 @@ export class AIFunctionService {
|
|||
}
|
||||
|
||||
private async searchVaultFiles(searchTerm: string): Promise<object> {
|
||||
const files: TFile[] = await this.fileSystemService.searchVaultFiles(searchTerm);
|
||||
return files.map((file) => ({
|
||||
name: file.basename,
|
||||
path: file.path
|
||||
const matches: SearchMatch[] = await this.fileSystemService.searchVaultFiles(searchTerm);
|
||||
return matches.map((match) => ({
|
||||
name: match.file.basename,
|
||||
path: match.file.path,
|
||||
snippets: match.snippets.map((snippet) => ({
|
||||
text: snippet.text,
|
||||
matchPosition: snippet.matchIndex
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Services } from "./Services";
|
|||
import { isValidJson } from "Helpers/Helpers";
|
||||
import { Path } from "Enums/Path";
|
||||
import type { VaultService } from "./VaultService";
|
||||
import type { SearchMatch } from "../Helpers/SearchTypes";
|
||||
|
||||
export class FileSystemService {
|
||||
|
||||
|
|
@ -96,7 +97,7 @@ export class FileSystemService {
|
|||
}
|
||||
}
|
||||
|
||||
public async searchVaultFiles(searchTerm: string): Promise<TFile[]> {
|
||||
public async searchVaultFiles(searchTerm: string): Promise<SearchMatch[]> {
|
||||
return await this.vaultService.searchVaultFiles(searchTerm);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Services } from "./Services";
|
|||
import type AIAgentPlugin from "main";
|
||||
import { Path } from "Enums/Path";
|
||||
import { escapeRegex } from "Helpers/Helpers";
|
||||
import type { SearchMatch, SearchSnippet } from "../Helpers/SearchTypes";
|
||||
|
||||
/* This service protects the users vault through their exclusions. The plugin root is excluded by default */
|
||||
export class VaultService {
|
||||
|
|
@ -91,25 +92,159 @@ export class VaultService {
|
|||
return files.filter(file => !this.isExclusion(file.path, allowAccessToPluginRoot));
|
||||
}
|
||||
|
||||
public async searchVaultFiles(searchTerm: string): Promise<TFile[]> {
|
||||
public async searchVaultFiles(searchTerm: string): Promise<SearchMatch[]> {
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(searchTerm, "i");
|
||||
regex = new RegExp(searchTerm, "ig"); // Added 'g' flag for global matching
|
||||
} catch {
|
||||
regex = new RegExp(escapeRegex(searchTerm), "i");
|
||||
regex = new RegExp(escapeRegex(searchTerm), "ig");
|
||||
}
|
||||
|
||||
const files: TFile[] = this.vault.getFiles().filter(file => !this.isExclusion(file.path));
|
||||
|
||||
const matches: TFile[] = [];
|
||||
// Collect all matches from all files
|
||||
const allMatches: SearchMatch[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
if (regex.test(content)) {
|
||||
matches.push(file);
|
||||
const snippets = this.extractSnippets(content, regex);
|
||||
|
||||
if (snippets.length > 0) {
|
||||
allMatches.push({ file, snippets });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
// Flatten matches for random sampling
|
||||
const flatMatches: { file: TFile; snippet: SearchSnippet }[] = [];
|
||||
for (const match of allMatches) {
|
||||
for (const snippet of match.snippets) {
|
||||
flatMatches.push({ file: match.file, snippet });
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 20 matches, randomly sample 20
|
||||
let selectedMatches: { file: TFile; snippet: SearchSnippet }[];
|
||||
if (flatMatches.length > 20) {
|
||||
selectedMatches = this.randomSample(flatMatches, 20);
|
||||
} else {
|
||||
selectedMatches = flatMatches;
|
||||
}
|
||||
|
||||
// Regroup by file
|
||||
const resultMap = new Map<TFile, SearchSnippet[]>();
|
||||
for (const match of selectedMatches) {
|
||||
const existing = resultMap.get(match.file);
|
||||
if (existing) {
|
||||
existing.push(match.snippet);
|
||||
} else {
|
||||
resultMap.set(match.file, [match.snippet]);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to array of SearchMatch objects
|
||||
const results: SearchMatch[] = [];
|
||||
for (const [file, snippets] of resultMap.entries()) {
|
||||
results.push({ file, snippets });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts snippets from content based on regex matches.
|
||||
* Merges overlapping snippets to avoid duplication.
|
||||
*/
|
||||
private extractSnippets(content: string, regex: RegExp): SearchSnippet[] {
|
||||
const snippets: SearchSnippet[] = [];
|
||||
const maxContextLength = 300; // Characters before and after the match
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Find all matches
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const matchIndex = match.index;
|
||||
const matchLength = match[0].length;
|
||||
|
||||
// Calculate snippet boundaries
|
||||
const snippetStart = Math.max(0, matchIndex - maxContextLength);
|
||||
const snippetEnd = Math.min(content.length, matchIndex + matchLength + maxContextLength);
|
||||
|
||||
snippets.push({
|
||||
text: content.substring(snippetStart, snippetEnd),
|
||||
matchIndex,
|
||||
matchLength
|
||||
});
|
||||
}
|
||||
|
||||
// Reset regex lastIndex
|
||||
regex.lastIndex = 0;
|
||||
|
||||
// Merge overlapping snippets
|
||||
return this.mergeOverlappingSnippets(snippets, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges snippets that overlap in the original content
|
||||
*/
|
||||
private mergeOverlappingSnippets(snippets: SearchSnippet[], content: string): SearchSnippet[] {
|
||||
if (snippets.length === 0) return snippets;
|
||||
|
||||
// Sort snippets by match index
|
||||
snippets.sort((a, b) => a.matchIndex - b.matchIndex);
|
||||
|
||||
const merged: SearchSnippet[] = [];
|
||||
let current = snippets[0];
|
||||
const maxContextLength = 300;
|
||||
|
||||
for (let i = 1; i < snippets.length; i++) {
|
||||
const next = snippets[i];
|
||||
|
||||
// Calculate the actual boundaries of current and next snippets in the original content
|
||||
const currentStart = Math.max(0, current.matchIndex - maxContextLength);
|
||||
const currentEnd = Math.min(content.length, current.matchIndex + current.matchLength + maxContextLength);
|
||||
const nextStart = Math.max(0, next.matchIndex - maxContextLength);
|
||||
const nextEnd = Math.min(content.length, next.matchIndex + next.matchLength + maxContextLength);
|
||||
|
||||
// Check if snippets overlap
|
||||
if (nextStart <= currentEnd) {
|
||||
// Merge: extend current to include next
|
||||
const mergedStart = Math.min(currentStart, nextStart);
|
||||
const mergedEnd = Math.max(currentEnd, nextEnd);
|
||||
|
||||
current = {
|
||||
text: content.substring(mergedStart, mergedEnd),
|
||||
matchIndex: current.matchIndex, // Keep the first match index
|
||||
matchLength: next.matchIndex + next.matchLength - current.matchIndex // Total span
|
||||
};
|
||||
} else {
|
||||
// No overlap, save current and move to next
|
||||
merged.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last snippet
|
||||
merged.push(current);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Randomly samples n items from an array
|
||||
*/
|
||||
private randomSample<T>(array: T[], n: number): T[] {
|
||||
const result: T[] = [];
|
||||
const taken = new Set<number>();
|
||||
|
||||
while (result.length < n && result.length < array.length) {
|
||||
const index = Math.floor(Math.random() * array.length);
|
||||
if (!taken.has(index)) {
|
||||
taken.add(index);
|
||||
result.push(array[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private isExclusion(filePath: string, allowAccessToPluginRoot: boolean = false): boolean {
|
||||
|
|
|
|||
Loading…
Reference in a new issue