Refactor retry logic and extract scoring weights

This commit is contained in:
2026-05-07 19:02:36 +02:00
parent 667553ee9d
commit 3ab326ffc2
6 changed files with 151 additions and 138 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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