Refactor retry logic and extract scoring weights
This commit is contained in:
+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