Update AI response parsing for JSON reasoning

This commit is contained in:
Jordan Wages 2026-01-07 03:32:29 -06:00
commit 42d013fccd
Notes: Jordan Wages 2026-01-07 13:52:38 -06:00
Generalizes parsing of response messages to account for multiple model response formats. Related to #1.
7 changed files with 114 additions and 18 deletions

View file

@ -34,6 +34,7 @@ There are currently no automated tests for this project. If you add tests in the
## Endpoint Notes ## Endpoint Notes
Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided.
Responses are expected to include a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in the response text and ignores any surrounding commentary.
## Documentation ## Documentation

View file

@ -6,7 +6,10 @@ Sortana is an experimental Thunderbird add-on that integrates an AI-powered filt
It allows you to classify email messages by sending their contents to a configurable It allows you to classify email messages by sending their contents to a configurable
HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base
URL and appends `/v1/completions` when sending requests. The endpoint should respond URL and appends `/v1/completions` when sending requests. The endpoint should respond
with JSON indicating whether the message meets a specified criterion. with JSON indicating whether the message meets a specified criterion, including a
short reasoning summary.
Responses are parsed by extracting the last JSON object in the response text and
expecting a `match` (or `matched`) boolean plus a `reason` string.
## Features ## Features

View file

@ -118,8 +118,17 @@ async function clearError() {
} }
function recordError(context, err) { function recordError(context, err) {
const message = err instanceof Error ? err.message : String(err || 'Unknown error'); let message = 'Unknown error';
const detail = err instanceof Error ? err.stack : ''; let detail = '';
if (err instanceof Error) {
message = err.message;
detail = err.stack || '';
} else if (err && typeof err === 'object') {
message = typeof err.message === 'string' ? err.message : String(err || 'Unknown error');
detail = typeof err.detail === 'string' ? err.detail : '';
} else {
message = String(err || 'Unknown error');
}
errorLog.unshift({ errorLog.unshift({
time: Date.now(), time: Date.now(),
context, context,
@ -741,6 +750,9 @@ async function clearCacheForMessages(idsInput) {
return { count: queuedCount + (processing ? 1 : 0) }; return { count: queuedCount + (processing ? 1 : 0) };
} else if (msg?.type === "sortana:getErrorLog") { } else if (msg?.type === "sortana:getErrorLog") {
return { errors: errorLog.slice() }; return { errors: errorLog.slice() };
} else if (msg?.type === "sortana:recordError") {
recordError(msg.context || "Sortana Error", { message: msg.message, detail: msg.detail });
return { ok: true };
} else if (msg?.type === "sortana:getTiming") { } else if (msg?.type === "sortana:getTiming") {
const t = timingStats; const t = timingStats;
const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0;

View file

@ -25,8 +25,8 @@ const DEFAULT_CUSTOM_SYSTEM_PROMPT = "Determine whether the email satisfies the
const SYSTEM_SUFFIX = ` const SYSTEM_SUFFIX = `
Return ONLY a JSON object on a single line of the form: Return ONLY a JSON object on a single line of the form:
{"match": true} - if the email satisfies the criterion {"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false} - otherwise {"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.`; Do not add any other keys, text, or formatting.`;
@ -266,15 +266,95 @@ function buildPayload(text, criterion) {
return JSON.stringify(payloadObj); return JSON.stringify(payloadObj);
} }
function reportParseError(message, detail) {
try {
const runtime = (globalThis.browser ?? globalThis.messenger)?.runtime;
if (!runtime?.sendMessage) {
return;
}
runtime.sendMessage({
type: "sortana:recordError",
context: "AI response parsing",
message,
detail
}).catch(() => {});
} catch (e) {
aiLog("Failed to report parse error", { level: "warn" }, e);
}
}
function extractLastJsonObject(text) {
let last = null;
let start = -1;
let depth = 0;
let inString = false;
let escape = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
if (depth === 0) {
start = i;
}
depth += 1;
continue;
}
if (ch === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start !== -1) {
last = text.slice(start, i + 1);
start = -1;
}
}
}
return last;
}
function parseMatch(result) { function parseMatch(result) {
const rawText = result.choices?.[0]?.text || ""; const rawText = result.choices?.[0]?.text || "";
const thinkText = rawText.match(/<think>[\s\S]*?<\/think>/gi)?.join('') || ''; const candidate = extractLastJsonObject(rawText);
aiLog('[AiClassifier] ⮡ Reasoning:', {debug: true}, thinkText); if (!candidate) {
const cleanedText = rawText.replace(/<think>[\s\S]*?<\/think>/gi, "").trim(); reportParseError("No JSON object found in AI response.", rawText.slice(0, 800));
aiLog('[AiClassifier] ⮡ Cleaned Response Text:', {debug: true}, cleanedText); return { matched: false, reason: "" };
const obj = JSON.parse(cleanedText); }
const matched = obj.matched === true || obj.match === true;
return { matched, reason: thinkText }; let obj;
try {
obj = JSON.parse(candidate);
} catch (e) {
reportParseError("Failed to parse JSON from AI response.", candidate.slice(0, 800));
return { matched: false, reason: "" };
}
const matchValue = Object.prototype.hasOwnProperty.call(obj, "match") ? obj.match : obj.matched;
const matched = matchValue === true;
if (matchValue !== true && matchValue !== false) {
reportParseError("AI response missing valid match boolean.", candidate.slice(0, 800));
}
const reasonValue = obj.reason ?? obj.reasoning ?? obj.explaination;
const reason = typeof reasonValue === "string" ? reasonValue : "";
return { matched, reason };
} }
function cacheEntry(cacheKey, matched, reason) { function cacheEntry(cacheKey, matched, reason) {

View file

@ -5,8 +5,8 @@ Email:
Criterion: {{query}} Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form: Remember, return ONLY a JSON object on a single line of the form:
{"match": true} - if the email satisfies the criterion {"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false} - otherwise {"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting. Do not add any other keys, text, or formatting.
[/INST] [/INST]

View file

@ -7,8 +7,8 @@
``` ```
Classification Criterion: {{query}} Classification Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form: Remember, return ONLY a JSON object on a single line of the form:
{"match": true} - if the email satisfies the criterion {"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false} - otherwise {"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.<|im_end|> Do not add any other keys, text, or formatting.<|im_end|>
<|im_start|>assistant <|im_start|>assistant

View file

@ -7,8 +7,8 @@ Email:
Criterion: {{query}} Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form: Remember, return ONLY a JSON object on a single line of the form:
{"match": true} - if the email satisfies the criterion {"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false} - otherwise {"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting. Do not add any other keys, text, or formatting.
<|im_end|> <|im_end|>