Add semantic caching and indexing pipeline

This commit adds semantic caching functionality to speed up repeated queries and implements a complete indexing pipeline
for processing vault files. The changes include:

- Added semantic cache service using ChromaDB for storing and retrieving cached responses
- Implemented indexing pipeline with extraction, normalization, and vectorization steps
- Added cache configuration settings to the plugin
- Updated Ollama client to support cache integration
- Added tests for all new indexing components
- Extended vault indexer with indexing pipeline support
This commit is contained in:
2026-05-07 22:23:35 +02:00
parent 47ecd3f803
commit d37b9f23bd
14 changed files with 899 additions and 42 deletions
+5 -5
View File
@@ -34,13 +34,13 @@ class ChatView extends obsidian_1.ItemView {
this.newChatButtonClickWrapper = null;
this.listenersAttached = false;
this.settings = settings;
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model, undefined, settings.cacheConfig);
this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault);
this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app);
}
updateSettings(newSettings) {
this.settings = newSettings;
this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model);
this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model, undefined, newSettings.cacheConfig);
}
getViewType() {
return 'ollama-chat-view';
@@ -48,16 +48,16 @@ class ChatView extends obsidian_1.ItemView {
getDisplayText() {
return 'Ollama Chat';
}
onOpen() {
async onOpen() {
await this.ollamaClient.initializeCache();
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
return Promise.resolve();
}
onSettingsChange(newSettings) {
this.updateSettings(newSettings);
}
onClose() {
async onClose() {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
+6
View File
@@ -8,4 +8,10 @@ exports.DEFAULT_SETTINGS = {
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
cacheConfig: {
enabled: false,
similarityThreshold: 0.85,
collectionName: 'ollama_semantic_cache',
embeddingModel: 'nomic-embed-text',
},
};
+88
View File
@@ -0,0 +1,88 @@
"use strict";
// src/indexing-pipeline/extraction.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContentExtractor = void 0;
/**
* Extracts raw content from a vault file including:
* - Markdown content
* - YAML frontmatter
* - Headings
* - Embedded code blocks
* - First paragraph
*/
class ContentExtractor {
extractFromFile(file, content) {
const frontmatter = {};
const headings = [];
const embeddedCodeBlocks = [];
let firstParagraph;
// Extract frontmatter
const frontmatterMatch = content.match(/^---(.*?)---/s);
if (frontmatterMatch) {
try {
const frontmatterContent = frontmatterMatch[1];
const lines = frontmatterContent.trim().split('\n');
for (const line of lines) {
const [key, ...valueParts] = line.split(':');
if (!key)
continue;
const value = valueParts.join(':').trim();
if (key.trim() === 'title') {
if (value) {
frontmatter.title = value;
}
}
else if (key.trim() === 'tags') {
if (value) {
frontmatter.tags = value;
}
}
else {
// Store other frontmatter fields as-is
frontmatter[key.trim()] = value;
}
}
}
catch {
// If frontmatter parsing fails, continue with empty frontmatter
}
}
// Extract headings
const headingMatches = content.match(/^#{1,6} (.*?)$/gm);
if (headingMatches) {
headings.push(...headingMatches.map((h) => h.replace(/^#{1,6} /, '')));
}
// Extract embedded code blocks
const codeBlockMatches = content.match(/```([\s\S]*?)```/g);
if (codeBlockMatches) {
embeddedCodeBlocks.push(...codeBlockMatches);
}
// Extract first paragraph
const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s);
if (paragraphMatch) {
firstParagraph = paragraphMatch[1].trim();
}
return {
basename: file.basename,
path: file.path,
content,
frontmatter,
headings,
embeddedCodeBlocks,
firstParagraph,
};
}
/**
* Extracts just the raw text content without headers, frontmatter, etc.
*/
extractRawText(content) {
return content
.replace(/^---.*?---/s, '')
.replace(/^#.*?$/gm, '')
.replace(/```.*?```/gs, '')
.replace(/`.*?`/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.trim();
}
}
exports.ContentExtractor = ContentExtractor;
+5 -1
View File
@@ -1,6 +1,10 @@
// src/indexing-pipeline/extraction.ts
import { VaultFile } from '../types';
// VaultFile interface is defined locally since it's not exported from types
interface VaultFile {
basename: string;
path: string;
}
export interface Frontmatter {
title?: string;
+12
View File
@@ -0,0 +1,12 @@
"use strict";
// src/indexing-pipeline/index.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.IndexingPipeline = exports.ContentVectorizer = exports.ContentNormalizer = exports.ContentExtractor = void 0;
var extraction_1 = require("./extraction");
Object.defineProperty(exports, "ContentExtractor", { enumerable: true, get: function () { return extraction_1.ContentExtractor; } });
var normalization_1 = require("./normalization");
Object.defineProperty(exports, "ContentNormalizer", { enumerable: true, get: function () { return normalization_1.ContentNormalizer; } });
var vectorization_1 = require("./vectorization");
Object.defineProperty(exports, "ContentVectorizer", { enumerable: true, get: function () { return vectorization_1.ContentVectorizer; } });
var pipeline_1 = require("./pipeline");
Object.defineProperty(exports, "IndexingPipeline", { enumerable: true, get: function () { return pipeline_1.IndexingPipeline; } });
+159
View File
@@ -0,0 +1,159 @@
"use strict";
// src/indexing-pipeline/normalization.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContentNormalizer = void 0;
/**
* Normalizes and enriches extracted content
*/
class ContentNormalizer {
/**
* Normalizes content by:
* - Standardizing dates to ISO 8601
* - Converting to lowercase for tokenization
* - Extracting tokens
* - Adding metadata
*/
normalize(extractedContent) {
const { basename, path, content, frontmatter, headings, firstParagraph } = extractedContent;
// Standardize title (remove .md extension)
const title = basename.replace(/\.md$/, '');
// Extract tokens (lowercase, remove stop words, etc.)
const tokens = this.tokenize(content);
// Normalize dates (if present in frontmatter)
const normalizedFrontmatter = this.normalizeFrontmatter(frontmatter);
// Calculate word count
const wordCount = content.split(/\s+/).filter(Boolean).length;
return {
path,
title,
content,
tokens,
headings,
frontmatter: normalizedFrontmatter,
firstParagraph,
wordCount,
// Add timestamps if available in frontmatter
createdAt: this.extractDate(frontmatter, 'created') || this.extractDate(frontmatter, 'date'),
updatedAt: this.extractDate(frontmatter, 'updated'),
};
}
/**
* Tokenizes text content by splitting on whitespace and removing stop words
*/
tokenize(text) {
const stopWords = new Set([
'the',
'a',
'an',
'and',
'or',
'but',
'is',
'are',
'was',
'were',
'in',
'on',
'at',
'to',
'of',
'for',
'with',
'as',
'by',
'it',
'its',
'that',
'this',
'these',
'those',
'from',
'up',
'out',
'off',
'over',
'under',
'again',
'further',
'then',
'once',
'here',
'there',
'when',
'where',
'why',
'how',
'all',
'any',
'both',
'each',
'few',
'more',
'most',
'other',
'some',
'such',
'no',
'nor',
'not',
'only',
'own',
'same',
'so',
'than',
'too',
'very',
'just',
'now',
]);
return text
.toLowerCase()
.split(/\W+/)
.filter((token) => token.length > 1 && !stopWords.has(token));
}
/**
* Normalizes frontmatter by standardizing data types and formats
*/
normalizeFrontmatter(frontmatter) {
const normalized = {};
for (const [key, value] of Object.entries(frontmatter)) {
if (key === 'tags' && typeof value === 'string') {
// Convert tag string to array if needed
normalized.tags = value.split(',').map((tag) => tag.trim());
}
else if (key === 'date' || key === 'created' || key === 'updated') {
// Try to parse and standardize date formats
if (typeof value === 'string') {
const date = new Date(value);
if (!isNaN(date.getTime())) {
normalized[key] = date.toISOString();
}
else {
normalized[key] = value; // Keep original if invalid date
}
}
else {
normalized[key] = value;
}
}
else {
normalized[key] = value;
}
}
return normalized;
}
/**
* Extracts a date from frontmatter
*/
extractDate(frontmatter, key) {
const value = frontmatter[key];
if (typeof value === 'string') {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toISOString();
}
}
return undefined;
}
}
exports.ContentNormalizer = ContentNormalizer;
+2 -1
View File
@@ -1,6 +1,7 @@
// src/indexing-pipeline/normalization.ts
import { VaultIndexEntry } from '../types';
// Import ExtractedContent interface from extraction module
import { ExtractedContent } from './extraction';
interface TokenizedContent {
tokens: string[];
+64
View File
@@ -0,0 +1,64 @@
"use strict";
// src/indexing-pipeline/pipeline.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.IndexingPipeline = void 0;
const extraction_1 = require("./extraction");
const normalization_1 = require("./normalization");
const vectorization_1 = require("./vectorization");
class IndexingPipeline {
constructor(config) {
this.extractor = new extraction_1.ContentExtractor();
this.normalizer = new normalization_1.ContentNormalizer();
this.vectorizer = new vectorization_1.ContentVectorizer({
model: config.embeddingModel,
ollamaUrl: config.ollamaUrl,
});
}
/**
* Processes a vault file through the entire pipeline
*/
async processFile(file, content) {
try {
// Extraction step
const extracted = this.extractor.extractFromFile(file, content);
// Normalization/Enrichment step
const normalized = this.normalizer.normalize(extracted);
// Return the normalized content as an index entry
return {
path: normalized.path,
title: normalized.title,
content: this.extractor.extractRawText(content).substring(0, 500),
score: 0, // Score will be calculated during search
};
}
catch (error) {
return null;
}
}
/**
* Processes multiple files in batches
*/
async processFilesInBatches(files, fileContents, batchSize = 10) {
const results = [];
const seenPaths = new Set();
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (file) => {
const content = fileContents[file.path];
if (!content) {
return null;
}
const entry = await this.processFile(file, content);
if (entry && !seenPaths.has(entry.path)) {
seenPaths.add(entry.path);
return entry;
}
return null;
}));
const validResults = batchResults.filter((result) => result !== null);
results.push(...validResults);
}
return results;
}
}
exports.IndexingPipeline = IndexingPipeline;
+55
View File
@@ -0,0 +1,55 @@
"use strict";
// src/indexing-pipeline/vectorization.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContentVectorizer = void 0;
/**
* Vectorizes content chunks using Ollama embeddings
*/
class ContentVectorizer {
constructor(config, fetchFn) {
this.model = config.model;
this.ollamaUrl = config.ollamaUrl;
this.fetchFn = fetchFn ?? fetch;
}
/**
* Generates embeddings for a content chunk
*/
async vectorize(chunk) {
try {
const prompt = this.createPrompt(chunk);
const response = await this.fetchFn(`${this.ollamaUrl}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: prompt,
}),
});
if (!response.ok) {
throw new Error(`Embedding failed with status ${response.status}`);
}
const data = await response.json();
return data.embedding;
}
catch (error) {
// Return empty array on failure to maintain compatibility
console.warn(`Failed to generate embedding: ${String(error)}`);
return [];
}
}
/**
* Creates a prompt from content chunk for embedding
*/
createPrompt(chunk) {
// Combine important elements for embedding
const parts = [
chunk.title,
chunk.firstParagraph,
chunk.content.substring(0, 1000), // Limit content to avoid long prompts
chunk.headings.join(' '),
JSON.stringify(chunk.frontmatter),
].filter(Boolean);
return parts.join('\n\n');
}
}
exports.ContentVectorizer = ContentVectorizer;
+8
View File
@@ -125,6 +125,14 @@ class OllamaSettingTab extends obsidian_1.PluginSettingTab {
new obsidian_1.Notice(modelValidation.error || 'Invalid model name format.');
}
}));
new obsidian_1.Setting(container)
.setName('Enable Semantic Cache')
.setDesc('Cache responses semantically to speed up repeated queries')
.addToggle((toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => {
this.plugin.settings.cacheConfig.enabled = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
}));
}
hide() {
// Clear the container to prevent duplicate elements
+59 -5
View File
@@ -4,13 +4,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaClient = void 0;
const types_1 = require("./types");
const utils_1 = require("./utils");
const semantic_cache_1 = require("./semantic-cache");
class OllamaClient {
constructor(baseURL, model, fetchFn) {
constructor(baseURL, model, fetchFn, cacheConfig) {
this.maxRetries = 3;
this.currentStreamController = null;
this.baseURL = baseURL;
this.model = model;
this.fetchFn = fetchFn ?? fetch;
if (cacheConfig?.enabled) {
this.cacheService = new semantic_cache_1.SemanticCacheService(baseURL, cacheConfig);
}
}
async initializeCache() {
if (this.cacheService) {
await this.cacheService.initialize();
}
}
cancelStream() {
if (this.currentStreamController) {
@@ -19,7 +28,29 @@ class OllamaClient {
}
}
async *streamChat(messages, tools = []) {
yield* this.streamChatWithRetry(messages, tools, 0);
// Bypass cache if tools are involved to prevent state corruption
if (tools.length > 0) {
yield* this.streamChatWithRetry(messages, tools, 0);
return;
}
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
yield { role: 'assistant', content: cached, tool_calls: [] };
return;
}
}
const chunks = [];
for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) {
chunks.push(chunk);
yield chunk;
}
// Populate cache in background after successful stream
if (this.cacheService && lastUserMsg) {
const fullContent = chunks.map((c) => c.content).join('');
void this.cacheService.setCache(lastUserMsg.content, fullContent);
}
}
async streamChatAsPromise(messages, tools = []) {
const chunks = [];
@@ -28,6 +59,24 @@ class OllamaClient {
}
return chunks;
}
async chat(messages, tools = []) {
// Bypass cache if tools are involved
if (tools.length > 0) {
return this.chatWithRetry(messages, tools, 0);
}
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
return { role: 'assistant', content: cached, tool_calls: [] };
}
}
const response = await this.chatWithRetry(messages, tools, 0);
if (this.cacheService && lastUserMsg) {
void this.cacheService.setCache(lastUserMsg.content, response.content);
}
return response;
}
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
// Create a local controller for this request instead of using the instance variable
const controller = new AbortController();
@@ -70,6 +119,10 @@ class OllamaClient {
finally {
signal.removeEventListener('abort', abortListener);
}
// Check if signal was aborted before retrying
if (signal.aborted) {
throw new Error('Stream cancelled by user');
}
}
else {
await retryTimeout;
@@ -156,9 +209,6 @@ class OllamaClient {
}
}
}
async chat(messages, tools = []) {
return this.chatWithRetry(messages, tools, 0);
}
async chatWithRetry(messages, tools = [], attempt = 0) {
// Create a local controller for this request instead of using the instance variable
const controller = new AbortController();
@@ -200,6 +250,10 @@ class OllamaClient {
finally {
signal.removeEventListener('abort', abortListener);
}
// Check if signal was aborted before retrying
if (signal.aborted) {
throw new Error('Stream cancelled by user');
}
}
else {
await retryTimeout;
+96
View File
@@ -0,0 +1,96 @@
"use strict";
// src/semantic-cache.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.SemanticCacheService = void 0;
const chromadb_1 = require("chromadb");
const utils_1 = require("./utils");
class SemanticCacheService {
constructor(ollamaURL, config) {
this.collection = null;
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
this.config = config;
this.client = new chromadb_1.ChromaClient({ path: 'http://localhost:8000' });
}
async initialize() {
if (!this.config.enabled)
return;
try {
this.collection = await this.client.getOrCreateCollection({
name: this.config.collectionName,
metadata: { 'hnsw:space': 'cosine' },
});
utils_1.Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache');
}
catch (error) {
utils_1.Logger.error(`Failed to initialize semantic cache: ${String(error)}`, 'semantic-cache');
}
}
async getEmbedding(text) {
try {
const response = await fetch(`${this.ollamaURL}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.config.embeddingModel,
prompt: text,
}),
});
if (!response.ok) {
throw new Error(`Embedding failed with status ${response.status}`);
}
const data = await response.json();
return data.embedding;
}
catch (error) {
utils_1.Logger.warn(`Failed to generate embedding: ${String(error)}`, 'semantic-cache');
return [];
}
}
async getCache(prompt) {
if (!this.collection || !this.config.enabled || !prompt.trim()) {
return null;
}
try {
const embedding = await this.getEmbedding(prompt);
if (!embedding.length)
return null;
const results = await this.collection.query({
queryEmbeddings: [embedding],
nResults: 1,
include: ['metadatas', 'distances'],
});
// Cosine distance = 1 - cosine_similarity
// We want distance < (1 - threshold)
if (results.distances &&
results.distances[0] &&
results.distances[0][0] < 1 - this.config.similarityThreshold) {
utils_1.Logger.debug('Semantic cache hit', 'semantic-cache');
return results.metadatas?.[0]?.[0]?.fullResponse ?? null;
}
}
catch (error) {
utils_1.Logger.warn(`Cache lookup failed: ${String(error)}`, 'semantic-cache');
}
return null;
}
async setCache(prompt, response) {
if (!this.collection || !this.config.enabled || !prompt.trim() || !response.trim()) {
return;
}
try {
const embedding = await this.getEmbedding(prompt);
if (!embedding.length)
return;
await this.collection.add({
ids: [crypto.randomUUID()],
embeddings: [embedding],
metadatas: [{ fullResponse: response }],
});
utils_1.Logger.debug('Cached new response', 'semantic-cache');
}
catch (error) {
utils_1.Logger.warn(`Cache write failed: ${String(error)}`, 'semantic-cache');
}
}
}
exports.SemanticCacheService = SemanticCacheService;
+32 -30
View File
@@ -1,9 +1,11 @@
"use strict";
// src/vault-indexer.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.InMemoryCache = exports.VaultIndexer = void 0;
exports.IndexingPipeline = exports.InMemoryCache = exports.VaultIndexer = void 0;
exports.createVaultIndexerWithCache = createVaultIndexerWithCache;
const utils_1 = require("./utils");
const pipeline_1 = require("./indexing-pipeline/pipeline");
Object.defineProperty(exports, "IndexingPipeline", { enumerable: true, get: function () { return pipeline_1.IndexingPipeline; } });
class InMemoryCache {
constructor() {
this.store = new Map();
@@ -22,10 +24,16 @@ class InMemoryCache {
}
exports.InMemoryCache = InMemoryCache;
class VaultIndexer {
constructor(vault, cache) {
constructor(vault, cache, pipeline) {
this.vault = null;
this.vault = vault;
this.cache = cache;
this.pipeline =
pipeline ||
new pipeline_1.IndexingPipeline({
ollamaUrl: 'http://localhost:11434', // Default URL
embeddingModel: 'nomic-embed-text',
});
}
async searchVault(query, limit = 5) {
if (!query || !query.trim()) {
@@ -73,35 +81,34 @@ class VaultIndexer {
const batchSize = 10;
const results = [];
const seenPaths = new Set();
// Read all files first to get their content
const fileContents = {};
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (file) => {
await Promise.all(batch.map(async (file) => {
try {
const content = await vault.read(file);
const tokenized = this.tokenizeContent(content);
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
if (scoreResult.score > 0) {
const entry = {
path: file.path,
title: file.basename.replace(/\.md$/, ''),
content: content.substring(0, 500),
score: scoreResult.score,
};
if (!seenPaths.has(entry.path)) {
seenPaths.add(entry.path);
return entry;
}
return null;
}
return null;
fileContents[file.path] = content;
}
catch (error) {
utils_1.Logger.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer');
return null;
utils_1.Logger.warn(`Failed to read ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer');
}
}));
const validResults = batchResults.filter((result) => result !== null);
results.push(...validResults);
}
// Process files through the pipeline
const pipelineResults = await this.pipeline.processFilesInBatches(files, fileContents, batchSize);
// Apply scoring to pipeline results
for (const entry of pipelineResults) {
const content = fileContents[entry.path];
if (content) {
const tokenized = this.tokenizeContent(content);
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens);
entry.score = scoreResult.score;
if (!seenPaths.has(entry.path)) {
seenPaths.add(entry.path);
results.push(entry);
}
}
}
return results;
}
@@ -139,6 +146,7 @@ class VaultIndexer {
.filter((token) => token.length > 1 && !stopWords.has(token));
}
tokenizeContent(content) {
// This is a simplified version - the pipeline will handle full extraction
const tokens = [];
const headings = [];
const frontmatter = {};
@@ -186,7 +194,7 @@ class VaultIndexer {
tokens.push(...this.tokenize(allText));
return { tokens, headings, frontmatter, firstParagraph };
}
calculateWeightedScore(tokenized, queryTokens, file) {
calculateWeightedScore(tokenized, queryTokens) {
let totalScore = 0;
const matchedTokens = new Set();
for (const queryToken of queryTokens) {
@@ -198,12 +206,6 @@ class VaultIndexer {
tokenScore += 3;
matched = true;
}
else if (file &&
file.basename &&
this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken)) {
tokenScore += 3;
matched = true;
}
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) {
tokenScore += 2.5;
matched = true;
+308
View File
@@ -0,0 +1,308 @@
import { ContentExtractor } from '../src/indexing-pipeline/extraction';
import { ContentNormalizer } from '../src/indexing-pipeline/normalization';
import { ContentVectorizer } from '../src/indexing-pipeline/vectorization';
import { IndexingPipeline } from '../src/indexing-pipeline/pipeline';
// Mock VaultFile interface for testing
interface MockVaultFile {
basename: string;
path: string;
}
describe('Indexing Pipeline Components', () => {
describe('ContentExtractor', () => {
let extractor: ContentExtractor;
beforeEach(() => {
extractor = new ContentExtractor();
});
it('should extract frontmatter correctly', () => {
const content = `---
title: Test Title
tags: algorithm, programming
date: 2023-01-01
---
# Heading
Content here`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
expect(extracted.frontmatter.title).toBe('Test Title');
expect(extracted.frontmatter.tags).toBe('algorithm, programming');
expect(extracted.frontmatter.date).toBe('2023-01-01');
expect(extracted.headings).toContain('Heading');
});
it('should extract headings correctly', () => {
const content = `# Heading 1
## Heading 2
### Heading 3
Content`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
expect(extracted.headings).toEqual(['Heading 1', 'Heading 2', 'Heading 3']);
});
it('should extract embedded code blocks', () => {
const content = `# Code Example
\`\`\`javascript
console.log('hello world');
\`\`\`
Some content`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
expect(extracted.embeddedCodeBlocks).toHaveLength(1);
expect(extracted.embeddedCodeBlocks[0]).toContain('console.log');
});
it('should extract first paragraph', () => {
const content = `First paragraph here.
Second paragraph here.
# Heading`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
expect(extracted.firstParagraph).toBe('First paragraph here.');
});
it('should extract raw text correctly', () => {
const content = `---
title: Test
---
# Heading
Content with **bold** and [link](url).
\`\`\`javascript
code
\`\`\``;
const rawText = extractor.extractRawText(content);
expect(rawText).not.toContain('---');
expect(rawText).not.toContain('# Heading');
expect(rawText).not.toContain('```javascript');
expect(rawText).toContain('Content with bold and link');
});
});
describe('ContentNormalizer', () => {
let normalizer: ContentNormalizer;
let extractor: ContentExtractor;
beforeEach(() => {
normalizer = new ContentNormalizer();
extractor = new ContentExtractor();
});
it('should normalize frontmatter dates to ISO format', () => {
const content = `---
title: Test
date: 2023-01-01
created: 2023-06-15
updated: invalid-date
tags: algorithm
---
Content`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
const normalized = normalizer.normalize(extracted);
expect(normalized.frontmatter.date).toBe('2023-01-01T00:00:00.000Z');
expect(normalized.frontmatter.created).toBe('2023-06-15T00:00:00.000Z');
expect(normalized.frontmatter.updated).toBe('invalid-date'); // Should preserve invalid dates
});
it('should convert tags to array format', () => {
const content = `---
title: Test
tags: algorithm, programming, javascript
---
Content`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
const normalized = normalizer.normalize(extracted);
expect(normalized.frontmatter.tags).toEqual(['algorithm', 'programming', 'javascript']);
});
it('should calculate word count correctly', () => {
const content = `# Title
This is a test document with several words to count.
It has multiple sentences and words to make it longer.`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
const normalized = normalizer.normalize(extracted);
expect(normalized.wordCount).toBeGreaterThan(0);
});
it('should extract tokens correctly', () => {
const content = `# Test Document
This is a test document with important keywords.`;
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
const normalized = normalizer.normalize(extracted);
expect(normalized.tokens).toContain('test');
expect(normalized.tokens).toContain('document');
expect(normalized.tokens).toContain('important');
expect(normalized.tokens).toContain('keywords');
});
it('should extract title correctly', () => {
const content = `# Test Document
Content`;
const file: MockVaultFile = { basename: 'test.md', path: 'test.md' };
const extracted = extractor.extractFromFile(file, content);
const normalized = normalizer.normalize(extracted);
expect(normalized.title).toBe('test');
});
});
describe('ContentVectorizer', () => {
let vectorizer: ContentVectorizer;
beforeEach(() => {
vectorizer = new ContentVectorizer({
model: 'nomic-embed-text',
ollamaUrl: 'http://localhost:11434',
});
});
it('should create a proper prompt from content chunk', () => {
const mockChunk = {
id: 'test',
path: 'test.md',
title: 'Test',
content: 'Test content',
tokens: ['test', 'content'],
headings: ['Heading'],
frontmatter: { tags: ['test'] },
firstParagraph: 'First paragraph',
wordCount: 2,
chunkIndex: 0,
chunkSize: 100
};
const prompt = (vectorizer as any).createPrompt(mockChunk);
expect(prompt).toContain('Test');
expect(prompt).toContain('First paragraph');
expect(prompt).toContain('Heading');
expect(prompt).toContain('tags');
});
// Note: Actual embedding tests would require mocking fetch or integration testing
it('should handle vectorization errors gracefully', async () => {
// This test would require mocking fetch to simulate error responses
// For now, we're just ensuring the method exists and doesn't crash
const mockChunk = {
id: 'test',
path: 'test.md',
title: 'Test',
content: 'Test content',
tokens: ['test', 'content'],
headings: ['Heading'],
frontmatter: { tags: ['test'] },
firstParagraph: 'First paragraph',
wordCount: 2,
chunkIndex: 0,
chunkSize: 100
};
// Mock fetch to simulate an error
const originalFetch = global.fetch;
(global.fetch as any) = jest.fn().mockRejectedValue(new Error('Network error'));
try {
const result = await vectorizer.vectorize(mockChunk);
expect(result).toEqual([]);
} finally {
global.fetch = originalFetch;
}
});
});
describe('IndexingPipeline', () => {
let pipeline: IndexingPipeline;
beforeEach(() => {
pipeline = new IndexingPipeline({
ollamaUrl: 'http://localhost:11434',
embeddingModel: 'nomic-embed-text',
});
});
it('should process files through the pipeline', async () => {
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const content = `---
title: Test Document
tags: test, example
---
# Introduction
This is a test document for pipeline processing.`;
const result = await pipeline.processFile(file, content);
expect(result).not.toBeNull();
expect(result?.title).toBe('test');
expect(result?.path).toBe('test.md');
expect(result?.content).toContain('This is a test document for pipeline processing');
});
it('should handle processing errors gracefully', async () => {
const file: MockVaultFile = { basename: 'test', path: 'test.md' };
const result = await pipeline.processFile(file, '');
// Should not crash, but might return null or incomplete result
expect(result).toBeNull(); // Empty content should return null
});
it('should process files in batches', async () => {
const files: MockVaultFile[] = [
{ basename: 'file1', path: 'file1.md' },
{ basename: 'file2', path: 'file2.md' }
];
const fileContents = {
'file1.md': '# File 1\n\nContent 1',
'file2.md': '# File 2\n\nContent 2'
};
const results = await pipeline.processFilesInBatches(files, fileContents, 1);
expect(results).toHaveLength(2);
expect(results[0].title).toBe('file1');
expect(results[1].title).toBe('file2');
});
});
});