Refactor retry logic and extract scoring weights #1
@@ -1,64 +1,131 @@
|
||||
# Ollama Chat Plugin for Obsidian
|
||||
|
||||
A plugin that integrates Ollama with Obsidian to create a chat interface that can access your vault content.
|
||||
A plugin that integrates [Ollama](https://ollama.ai) with [Obsidian](https://obsidian.md) to create a chat interface that can access your vault content contextually.
|
||||
|
||||
## Features
|
||||
|
||||
- Chat with Ollama models directly in Obsidian
|
||||
- Vault context search - the assistant can reference your notes
|
||||
- Tool integration - create files based on chat responses
|
||||
- Streaming responses
|
||||
- Customizable model and URL settings
|
||||
- **Chat with local AI models** — Interact with Ollama models directly in Obsidian's sidebar
|
||||
- **Vault context search** — The assistant automatically searches your notes using weighted scoring (headings, frontmatter titles/tags, content)
|
||||
- **Tool integration** — AI can create files in your vault via the `create_file` tool
|
||||
- **Streaming responses** — Real-time token streaming for a responsive chat experience
|
||||
- **Configurable settings** — Customize model, URL, search limits, and message history
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the plugin via Obsidian's community plugins
|
||||
2. Make sure you have Ollama installed and running
|
||||
### From Source
|
||||
|
||||
## Setup
|
||||
1. Clone this repository into your Obsidian vault's `.obsidian/plugins/` folder:
|
||||
```bash
|
||||
cd .obsidian/plugins
|
||||
git clone <repo-url> obsidian-ollama
|
||||
cd obsidian-ollama
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. Enable the plugin in Obsidian: Settings → Community plugins → Ollama Plugin
|
||||
|
||||
1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai) to install Ollama
|
||||
2. **Start Ollama service**: `ollama serve`
|
||||
3. **Pull a model**: `ollama pull llama3` (or any other model you prefer)
|
||||
## Prerequisites
|
||||
|
||||
1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai)
|
||||
2. **Start Ollama**: `ollama serve`
|
||||
3. **Pull a model**: `ollama pull llama3` (or any model you prefer)
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Open the plugin settings via Obsidian's settings panel
|
||||
2. Configure the Ollama URL (default: `http://localhost:11434`)
|
||||
3. Configure the model name (default: `llama3`)
|
||||
4. Restart the plugin if needed
|
||||
Open Settings → Ollama Plugin to configure:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Ollama URL | `http://localhost:11434` | Your Ollama instance URL |
|
||||
| Model | `llama3` | Model to use for chat |
|
||||
|
||||
### Internal Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Vault Search Limit | `3` | Max notes returned for context |
|
||||
| Max Message History | `50` | Messages kept in conversation context |
|
||||
|
||||
## Usage
|
||||
|
||||
1. Click the ribbon icon to open the chat view
|
||||
2. Type your message in the input box
|
||||
3. Press Enter or click Send to send your message
|
||||
4. Click the "New Chat" button to start a fresh conversation
|
||||
1. Click the chat icon in the left ribbon to open the chat view
|
||||
2. Type your message and press **Enter** or click **Send**
|
||||
3. Click **New Chat** to start a fresh conversation
|
||||
|
||||
### Vault Context
|
||||
|
||||
When you send a message, the plugin automatically searches your vault for relevant notes and includes them as context. The search uses weighted scoring:
|
||||
|
||||
- **Headings** — 5x weight
|
||||
- **Frontmatter title** — 3x weight
|
||||
- **Frontmatter tags** — 2.5x weight
|
||||
- **First paragraph** — 1.5x weight
|
||||
- **General content** — 1x weight
|
||||
|
||||
### Tools
|
||||
|
||||
The plugin exposes a `create_file` tool that allows the AI to create new markdown files in your vault. Paths are validated for safety (no `.obsidian`/`.git` access, no path traversal).
|
||||
|
||||
## Supported Models
|
||||
|
||||
Any model supported by Ollama should work, including:
|
||||
Any Ollama-supported model works. Popular choices:
|
||||
|
||||
- llama3
|
||||
- llama2
|
||||
- mistral
|
||||
- codellama
|
||||
- etc.
|
||||
| Model | Best For |
|
||||
|-------|----------|
|
||||
| `llama3` | General chat |
|
||||
| `mistral` | Fast responses |
|
||||
| `codellama` | Code assistance |
|
||||
| `gemma` | Lightweight local use |
|
||||
|
||||
## Development
|
||||
|
||||
To build from source:
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npm run build # Compile TypeScript
|
||||
npm run watch # Watch mode for development
|
||||
npm test # Run tests (Jest)
|
||||
npm run lint # Lint with ESLint
|
||||
npm run format # Format with Prettier
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Plugin entry point and settings
|
||||
├── chat-view.ts # Chat UI and message handling
|
||||
├── ollama-client.ts # Ollama API communication
|
||||
├── vault-indexer.ts # Vault search and scoring
|
||||
├── tool-executor.ts # Tool execution (create_file)
|
||||
├── error-handler.ts # Centralized error handling
|
||||
├── utils.ts # Validation and logging utilities
|
||||
├── types.ts # TypeScript type definitions
|
||||
└── constants.ts # Default configuration
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The project includes tests for all major components. Run with:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Connection issues**: Ensure Ollama is running and accessible at the configured URL
|
||||
- **Model not found**: Make sure you've pulled the model (`ollama pull <modelname>`)
|
||||
- **Permission issues**: Check that your Obsidian vault has proper write permissions
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Connection refused | Ensure Ollama is running (`ollama serve`) |
|
||||
| Model not found | Pull the model first (`ollama pull <model>`) |
|
||||
| Permission errors | Check vault write permissions |
|
||||
| Empty responses | Try a different model or check Ollama logs |
|
||||
|
||||
## Security
|
||||
|
||||
- File paths are validated to prevent access to `.obsidian/` and `.git/` directories
|
||||
- Path traversal attempts (`..`) are blocked
|
||||
- Absolute paths and Windows drive letters are rejected
|
||||
- Maximum path length is enforced (200 characters)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
|
||||
const data = (await this.loadData());
|
||||
if (data) {
|
||||
utils_1.Logger.debug('Loading saved settings', 'settings');
|
||||
this.settings = Object.assign({}, this.settings, data);
|
||||
Object.assign(this.settings, data);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
const data = (await this.loadData()) as Partial<PluginSettings> | null;
|
||||
if (data) {
|
||||
Logger.debug('Loading saved settings', 'settings');
|
||||
this.settings = Object.assign({}, this.settings, data);
|
||||
Object.assign(this.settings, data);
|
||||
}
|
||||
} catch (error) {
|
||||
ErrorHandler.handleError(error, 'settings load');
|
||||
|
||||
+47
-60
@@ -19,7 +19,9 @@ class OllamaClient {
|
||||
}
|
||||
}
|
||||
async *streamChat(messages, tools = []) {
|
||||
yield* this.streamChatWithRetry(messages, tools, 0);
|
||||
for await (const message of this.streamChatWithRetry(messages, tools)) {
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
async streamChatAsPromise(messages, tools = []) {
|
||||
const chunks = [];
|
||||
@@ -29,7 +31,6 @@ class OllamaClient {
|
||||
return chunks;
|
||||
}
|
||||
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
|
||||
// Create a local controller for this request instead of using the instance variable
|
||||
const controller = new AbortController();
|
||||
this.currentStreamController = controller;
|
||||
try {
|
||||
@@ -46,38 +47,42 @@ class OllamaClient {
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response) {
|
||||
throw new Error('No response received from Ollama API');
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
|
||||
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
const abortListener = () => {
|
||||
utils_1.Logger.info('Retry aborted by user', 'ollama-client');
|
||||
};
|
||||
const signal = controller.signal;
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortListener);
|
||||
try {
|
||||
await Promise.race([
|
||||
retryTimeout,
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
// Wait for the delay, but also check if the stream was cancelled during backoff
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve(), retryDelay);
|
||||
// If the controller was aborted during the delay, cancel the retry
|
||||
if (controller.signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(controller.signal.reason ??
|
||||
new DOMException('The operation was aborted.', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
finally {
|
||||
signal.removeEventListener('abort', abortListener);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await retryTimeout;
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(controller.signal.reason ??
|
||||
new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
// Only proceed with retry if this controller is still the active one.
|
||||
// If a newer stream replaced currentStreamController during backoff,
|
||||
// abandon the retry to avoid overwriting the newer stream's controller.
|
||||
if (this.currentStreamController !== controller) {
|
||||
return;
|
||||
}
|
||||
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
|
||||
// After successful retry, we need to return (not continue processing this response)
|
||||
return;
|
||||
}
|
||||
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
else {
|
||||
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
@@ -145,13 +150,18 @@ class OllamaClient {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
finally {
|
||||
// Abort the local controller to release underlying fetch resources if not already aborted
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
catch (error) {
|
||||
if (!(error instanceof Error) || error.name !== 'AbortError') {
|
||||
utils_1.Logger.error(`Stream encountered an error: ${String(error)}`, 'ollama-client');
|
||||
throw error;
|
||||
}
|
||||
// Clean up the reference only if this is still the current stream
|
||||
// Re-throw the abort error to allow stream consumers to handle it
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
// Only clear controller if it's still the current one (not replaced by a new stream)
|
||||
if (this.currentStreamController === controller) {
|
||||
controller.abort();
|
||||
this.currentStreamController = null;
|
||||
}
|
||||
}
|
||||
@@ -160,7 +170,6 @@ class OllamaClient {
|
||||
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();
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
@@ -176,34 +185,14 @@ class OllamaClient {
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response) {
|
||||
throw new Error('No response received from Ollama API');
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
|
||||
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
const abortListener = () => {
|
||||
utils_1.Logger.info('Retry aborted by user', 'ollama-client');
|
||||
};
|
||||
const signal = controller.signal;
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortListener);
|
||||
try {
|
||||
await Promise.race([
|
||||
retryTimeout,
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
signal.removeEventListener('abort', abortListener);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await retryTimeout;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
@@ -212,10 +201,7 @@ class OllamaClient {
|
||||
return (this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] });
|
||||
}
|
||||
finally {
|
||||
// Abort the local controller to release underlying fetch resources if not already aborted
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
throwIfOllamaError(parsed) {
|
||||
@@ -228,10 +214,11 @@ class OllamaClient {
|
||||
return null;
|
||||
}
|
||||
const record = value;
|
||||
const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
|
||||
return {
|
||||
role: record.role ?? 'assistant',
|
||||
content: typeof record.content === 'string' ? record.content : '',
|
||||
tool_calls: record.tool_calls ?? [],
|
||||
tool_calls: toolCalls,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+61
-64
@@ -32,7 +32,9 @@ export class OllamaClient {
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[] = []
|
||||
): AsyncGenerator<OllamaMessage, void, unknown> {
|
||||
yield* this.streamChatWithRetry(messages, tools, 0);
|
||||
for await (const message of this.streamChatWithRetry(messages, tools)) {
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
|
||||
async streamChatAsPromise(
|
||||
@@ -51,7 +53,6 @@ export class OllamaClient {
|
||||
tools: OllamaTool[] = [],
|
||||
attempt: number = 0
|
||||
): AsyncGenerator<OllamaMessage, void, unknown> {
|
||||
// Create a local controller for this request instead of using the instance variable
|
||||
const controller = new AbortController();
|
||||
this.currentStreamController = controller;
|
||||
|
||||
@@ -70,6 +71,10 @@ export class OllamaClient {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received from Ollama API');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
@@ -77,36 +82,47 @@ export class OllamaClient {
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
);
|
||||
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
const abortListener = () => {
|
||||
Logger.info('Retry aborted by user', 'ollama-client');
|
||||
};
|
||||
const signal = controller.signal;
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortListener);
|
||||
try {
|
||||
await Promise.race([
|
||||
retryTimeout,
|
||||
new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortListener);
|
||||
|
||||
// Wait for the delay, but also check if the stream was cancelled during backoff
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve(), retryDelay);
|
||||
|
||||
// If the controller was aborted during the delay, cancel the retry
|
||||
if (controller.signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
controller.signal.reason ??
|
||||
new DOMException('The operation was aborted.', 'AbortError')
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Check if signal was aborted before retrying
|
||||
if (signal.aborted) {
|
||||
throw new Error('Stream cancelled by user');
|
||||
}
|
||||
} else {
|
||||
await retryTimeout;
|
||||
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
controller.signal.reason ??
|
||||
new DOMException('The operation was aborted.', 'AbortError')
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
// Only proceed with retry if this controller is still the active one.
|
||||
// If a newer stream replaced currentStreamController during backoff,
|
||||
// abandon the retry to avoid overwriting the newer stream's controller.
|
||||
if (this.currentStreamController !== controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
|
||||
// After successful retry, we need to return (not continue processing this response)
|
||||
return;
|
||||
} else {
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
@@ -188,13 +204,17 @@ export class OllamaClient {
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} finally {
|
||||
// Abort the local controller to release underlying fetch resources if not already aborted
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.name !== 'AbortError') {
|
||||
Logger.error(`Stream encountered an error: ${String(error)}`, 'ollama-client');
|
||||
throw error;
|
||||
}
|
||||
// Clean up the reference only if this is still the current stream
|
||||
// Re-throw the abort error to allow stream consumers to handle it
|
||||
throw error;
|
||||
} finally {
|
||||
// Only clear controller if it's still the current one (not replaced by a new stream)
|
||||
if (this.currentStreamController === controller) {
|
||||
controller.abort();
|
||||
this.currentStreamController = null;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +229,6 @@ export class OllamaClient {
|
||||
tools: OllamaTool[] = [],
|
||||
attempt: number = 0
|
||||
): Promise<OllamaMessage> {
|
||||
// Create a local controller for this request instead of using the instance variable
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
@@ -226,6 +245,10 @@ export class OllamaClient {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received from Ollama API');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
@@ -233,32 +256,7 @@ export class OllamaClient {
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
);
|
||||
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
const abortListener = () => {
|
||||
Logger.info('Retry aborted by user', 'ollama-client');
|
||||
};
|
||||
const signal = controller.signal;
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortListener);
|
||||
try {
|
||||
await Promise.race([
|
||||
retryTimeout,
|
||||
new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} 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;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
@@ -269,10 +267,7 @@ export class OllamaClient {
|
||||
this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }
|
||||
);
|
||||
} finally {
|
||||
// Abort the local controller to release underlying fetch resources if not already aborted
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,10 +283,12 @@ export class OllamaClient {
|
||||
}
|
||||
|
||||
const record = value as Partial<OllamaMessage>;
|
||||
const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
|
||||
|
||||
return {
|
||||
role: record.role ?? 'assistant',
|
||||
content: typeof record.content === 'string' ? record.content : '',
|
||||
tool_calls: record.tool_calls ?? [],
|
||||
tool_calls: toolCalls,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -24,6 +24,14 @@ exports.InMemoryCache = InMemoryCache;
|
||||
class VaultIndexer {
|
||||
constructor(vault, cache) {
|
||||
this.vault = null;
|
||||
// Define weights for scoring
|
||||
this.SCORING_WEIGHTS = {
|
||||
HEADING: 5,
|
||||
FRONTMATTER_TITLE: 3,
|
||||
FRONTMATTER_TAGS: 2.5,
|
||||
FIRST_PARAGRAPH: 1.5,
|
||||
TOKEN: 1,
|
||||
};
|
||||
this.vault = vault;
|
||||
this.cache = cache;
|
||||
}
|
||||
@@ -195,29 +203,29 @@ class VaultIndexer {
|
||||
let matched = false;
|
||||
if (tokenized.frontmatter?.title &&
|
||||
this.exactMatch(tokenized.frontmatter.title, queryToken)) {
|
||||
tokenScore += 3;
|
||||
tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
matched = true;
|
||||
}
|
||||
else if (file &&
|
||||
file.basename &&
|
||||
this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken)) {
|
||||
tokenScore += 3;
|
||||
tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
matched = true;
|
||||
}
|
||||
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) {
|
||||
tokenScore += 2.5;
|
||||
tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TAGS;
|
||||
matched = true;
|
||||
}
|
||||
if (tokenized.headings.some((heading) => heading.toLowerCase().includes(stemmed))) {
|
||||
tokenScore += 5;
|
||||
tokenScore += this.SCORING_WEIGHTS.HEADING;
|
||||
matched = true;
|
||||
}
|
||||
if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) {
|
||||
tokenScore += 1.5;
|
||||
tokenScore += this.SCORING_WEIGHTS.FIRST_PARAGRAPH;
|
||||
matched = true;
|
||||
}
|
||||
if (tokenized.tokens.includes(stemmed)) {
|
||||
tokenScore += 1;
|
||||
tokenScore += this.SCORING_WEIGHTS.TOKEN;
|
||||
matched = true;
|
||||
}
|
||||
if (matched) {
|
||||
@@ -232,6 +240,12 @@ class VaultIndexer {
|
||||
}
|
||||
stemToken(token) {
|
||||
// Improved stemmer that handles edge cases
|
||||
//
|
||||
// Limitations:
|
||||
// - Simple suffix removal (e.g., 's', 'ed', 'ing') may lead to over-stemming or incorrect roots.
|
||||
// - For example, stemming "mice" results in "mic", which is incorrect.
|
||||
// - Consider using a more robust NLP library if the plugin environment permits.
|
||||
//
|
||||
if (token.length <= 3)
|
||||
return token; // Don't stem very short tokens
|
||||
if (token.endsWith('s'))
|
||||
|
||||
+21
-6
@@ -62,6 +62,15 @@ class VaultIndexer {
|
||||
private vault: VaultLike | null = null;
|
||||
private cache?: Cache;
|
||||
|
||||
// Define weights for scoring
|
||||
private readonly SCORING_WEIGHTS = {
|
||||
HEADING: 5,
|
||||
FRONTMATTER_TITLE: 3,
|
||||
FRONTMATTER_TAGS: 2.5,
|
||||
FIRST_PARAGRAPH: 1.5,
|
||||
TOKEN: 1,
|
||||
};
|
||||
|
||||
constructor(vault: VaultLike, cache?: Cache) {
|
||||
this.vault = vault;
|
||||
this.cache = cache;
|
||||
@@ -268,34 +277,34 @@ class VaultIndexer {
|
||||
tokenized.frontmatter?.title &&
|
||||
this.exactMatch(tokenized.frontmatter.title, queryToken)
|
||||
) {
|
||||
tokenScore += 3;
|
||||
tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
matched = true;
|
||||
} else if (
|
||||
file &&
|
||||
file.basename &&
|
||||
this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken)
|
||||
) {
|
||||
tokenScore += 3;
|
||||
tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) {
|
||||
tokenScore += 2.5;
|
||||
tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TAGS;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (tokenized.headings.some((heading: string) => heading.toLowerCase().includes(stemmed))) {
|
||||
tokenScore += 5;
|
||||
tokenScore += this.SCORING_WEIGHTS.HEADING;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) {
|
||||
tokenScore += 1.5;
|
||||
tokenScore += this.SCORING_WEIGHTS.FIRST_PARAGRAPH;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (tokenized.tokens.includes(stemmed)) {
|
||||
tokenScore += 1;
|
||||
tokenScore += this.SCORING_WEIGHTS.TOKEN;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
@@ -313,6 +322,12 @@ class VaultIndexer {
|
||||
|
||||
private stemToken(token: string): string {
|
||||
// Improved stemmer that handles edge cases
|
||||
//
|
||||
// Limitations:
|
||||
// - Simple suffix removal (e.g., 's', 'ed', 'ing') may lead to over-stemming or incorrect roots.
|
||||
// - For example, stemming "mice" results in "mic", which is incorrect.
|
||||
// - Consider using a more robust NLP library if the plugin environment permits.
|
||||
//
|
||||
if (token.length <= 3) return token; // Don't stem very short tokens
|
||||
if (token.endsWith('s')) return token.slice(0, -1);
|
||||
if (token.endsWith('ed') && token.length > 4) return token.slice(0, -2); // Don't stem 3-letter words ending in ed
|
||||
|
||||
Reference in New Issue
Block a user