From 245bb2e3e1076267254293a96d86b9ad27711fd1 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Sun, 19 Apr 2026 18:47:47 -0500 Subject: [PATCH] Revert "Migrate classifier to chat completions" This reverts commit d48557fe5b28d27db73075b449d9a121519c7bf1. --- AGENTS.md | 6 +- README.md | 29 ++--- _locales/en-US/messages.json | 4 +- modules/AiClassifier.js | 209 +++++++++++------------------------ options/options.html | 5 +- 5 files changed, 85 insertions(+), 168 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9e3261e..11876f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje - `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3). - `details.html` and `details.js`: View AI reasoning and clear cache for a message. - `resources/`: Images and other static files. -- `prompt_templates/`: Provider-specific templated message formats for non-OpenAI flows (qwen, mistral, harmony, plus legacy openai template material kept in-repo). +- `prompt_templates/`: Prompt template files for the AI service (openai, qwen, mistral, harmony). - `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.sh`: Bash script to package the extension. - `resources/svg2img.ps1`: PowerShell script to regenerate themed PNG icons from SVGs. @@ -35,10 +35,10 @@ There are currently no automated tests for this project. If you add tests in the ## Endpoint Notes -Sortana targets `POST /v1/chat/completions`. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/chat/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. Endpoint normalization strips a trailing `/v1`, `/v1/chat/completions`, `/v1/completions`, or `/v1/models`. +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. The options page can query `/v1/models` from the same base URL to populate the Model dropdown; selecting **None** omits the `model` field from the request payload. Advanced options allow an optional API key plus `OpenAI-Organization` and `OpenAI-Project` headers; these headers are only sent when values are provided. -Requests use a Chat Completions `messages` array and ask for strict JSON schema output via `response_format`. Responses are parsed from `choices[0].message`, with `match` as a boolean and `reason` as a short string. Unsupported OpenAI sampling fields are filtered out, and the saved `max_tokens` setting is translated to `max_completion_tokens`. +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 diff --git a/README.md b/README.md index a9c08ae..fe67874 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,22 @@ Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. It allows you to classify email messages by sending their contents to a configurable -HTTP endpoint. Sortana uses `POST /v1/chat/completions`; the options page stores a -base URL and appends `/v1/chat/completions` when sending classification requests. -The same base URL is used with `/v1/models` when refreshing the model list. -Classification requests ask the model for structured JSON output with a required -`match` boolean and `reason` string. +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 +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 - **Configurable endpoint** – set the classification service base URL on the options page. - **Model selection** – load available models from the endpoint and choose one (or omit the model field). - **Optional OpenAI auth headers** – provide an API key plus optional organization/project headers when needed. -- **Request formats** – use native OpenAI chat messages or choose Qwen, Mistral, Harmony (gpt-oss), or a custom templated message format. +- **Prompt templates** – choose between OpenAI/ChatML, Qwen, Mistral, Harmony (gpt-oss), or provide your own custom template. - **Custom system prompts** – tailor the instructions sent to the model for more precise results. - **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. -- **Advanced parameters** – tune generation settings like temperature and top‑p from the options page. Unsupported OpenAI sampling fields are filtered out automatically. +- **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. - **Debug tab** – view the last request payload and a diff between the unaltered message text and the final prompt. @@ -82,11 +83,10 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs. ## Usage 1. Open the add-on's options and set the base URL of your classification service - (Sortana will append `/v1/chat/completions`). Endpoints ending in `/v1`, - `/v1/chat/completions`, `/v1/completions`, or `/v1/models` are normalized back - to the same base URL. Use the Model dropdown to load `/v1/models` and select a - model or choose **None** to omit the `model` field. Advanced settings include - optional API key, organization, and project headers for OpenAI-hosted endpoints. + (Sortana will append `/v1/completions`). Use the Model dropdown to load + `/v1/models` and select a model or choose **None** to omit the `model` field. + Advanced settings include optional API key, organization, and project headers + for OpenAI-hosted endpoints. 2. Use the **Classification Rules** section to add a criterion and optional actions such as tagging, moving, copying, forwarding, replying, deleting or archiving a message when it matches. Drag rules to @@ -101,11 +101,6 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs. configured rules. 4. If the toolbar icon shows a red X, it will clear after a few seconds. Open the Errors tab in Options to review the latest failures. -OpenAI Chat requests are sent with a `messages` array plus a strict -`response_format` JSON schema. Sortana maps the saved `max_tokens` setting to -`max_completion_tokens` for Chat Completions and only forwards OpenAI-supported -sampling fields. - ### Example Filters Here are some useful and fun example criteria you can use in your filters. Filters should be able to be answered as either `true` or `false`. diff --git a/_locales/en-US/messages.json b/_locales/en-US/messages.json index 8311ef2..017e1a3 100644 --- a/_locales/en-US/messages.json +++ b/_locales/en-US/messages.json @@ -4,12 +4,12 @@ "doesntMatch": { "message": "doesn't match" }, "options.title": { "message": "AI Filter Options" }, "options.endpoint": { "message": "Endpoint" }, - "options.template": { "message": "Request format" }, + "options.template": { "message": "Prompt template" }, "options.customTemplate": { "message": "Custom template" }, "options.systemInstructions": { "message": "System instructions" }, "options.reset": { "message": "Reset to default" }, "options.placeholders": { "message": "Placeholders: {{system}}, {{email}}, {{query}}" }, - "template.openai": { "message": "OpenAI Chat" }, + "template.openai": { "message": "OpenAI / ChatML" }, "template.qwen": { "message": "Qwen" }, "template.mistral": { "message": "Mistral" }, "template.harmony": { "message": "Harmony (gpt-oss)" }, diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index 7736b6f..dbbde92 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -4,7 +4,7 @@ import { DEFAULT_AI_PARAMS } from "./defaultParams.js"; const storage = (globalThis.messenger ?? globalThis.browser).storage; -const CHAT_COMPLETIONS_PATH = "/v1/chat/completions"; +const COMPLETIONS_PATH = "/v1/completions"; const MODELS_PATH = "/v1/models"; const SYSTEM_PREFIX = `You are an email-classification assistant. @@ -14,26 +14,11 @@ Read the email below and the classification criterion provided by the user. const DEFAULT_CUSTOM_SYSTEM_PROMPT = "Determine whether the email satisfies the user's criterion."; const SYSTEM_SUFFIX = ` -Return JSON that matches the requested schema exactly. -Set "match" to true when the email satisfies the criterion, otherwise false. -Set "reason" to a short explanation grounded in the email contents.`; +Return ONLY a JSON object on a single line of the form: +{"match": true, "reason": ""} - if the email satisfies the criterion +{"match": false, "reason": ""} - otherwise -const RESPONSE_FORMAT = { - type: "json_schema", - json_schema: { - name: "email_classification", - strict: true, - schema: { - type: "object", - properties: { - match: { type: "boolean" }, - reason: { type: "string" }, - }, - required: ["match", "reason"], - additionalProperties: false, - }, - }, -}; +Do not add any other keys, text, or formatting.`; let gEndpointBase = "http://127.0.0.1:5000"; let gEndpoint = buildEndpointUrl(gEndpointBase); @@ -59,7 +44,7 @@ function normalizeEndpointBase(endpoint) { if (!base) { return ""; } - base = base.replace(/\/v1(?:\/(?:chat\/completions|completions|models))?\/?$/i, ""); + base = base.replace(/\/v1\/(completions|models)\/?$/i, ""); return base; } @@ -70,7 +55,7 @@ function buildEndpointUrl(endpointBase) { } const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; const needsSlash = withScheme.endsWith("/"); - const path = CHAT_COMPLETIONS_PATH.replace(/^\//, ""); + const path = COMPLETIONS_PATH.replace(/^\//, ""); return `${withScheme}${needsSlash ? "" : "/"}${path}`; } @@ -216,9 +201,7 @@ async function setConfig(config = {}) { if (typeof config.debugLogging === "boolean") { setDebug(config.debugLogging); } - if (gTemplateName === "openai") { - gTemplateText = ""; - } else if (gTemplateName === "custom") { + if (gTemplateName === "custom") { gTemplateText = gCustomTemplate; } else { gTemplateText = await loadTemplate(gTemplateName); @@ -260,35 +243,6 @@ function buildPrompt(body, criterion) { return template.replace(/{{\s*(\w+)\s*}}/g, (m, key) => data[key] || ""); } -function buildUserMessage(body, criterion) { - return `Email contents: -${body} - -Classification criterion: ${criterion}`; -} - -function buildMessages(body, criterion) { - if (gTemplateName === "openai") { - return [ - { - role: "system", - content: buildSystemPrompt(), - }, - { - role: "user", - content: buildUserMessage(body, criterion), - }, - ]; - } - - return [ - { - role: "user", - content: buildPrompt(body, criterion), - }, - ]; -} - function getCachedResult(cacheKey) { if (!gCacheLoaded) { return null; @@ -309,41 +263,14 @@ function getReason(cacheKey) { return cacheKey && entry ? entry.reason || null : null; } -function buildOpenAiParams() { - const params = {}; - - if (Number.isFinite(gAiParams.max_tokens) && gAiParams.max_tokens > 0) { - params.max_completion_tokens = Math.trunc(gAiParams.max_tokens); - } - if (Number.isFinite(gAiParams.temperature)) { - params.temperature = gAiParams.temperature; - } - if (Number.isFinite(gAiParams.top_p)) { - params.top_p = gAiParams.top_p; - } - if (Number.isFinite(gAiParams.presence_penalty)) { - params.presence_penalty = gAiParams.presence_penalty; - } - if (Number.isFinite(gAiParams.frequency_penalty)) { - params.frequency_penalty = gAiParams.frequency_penalty; - } - if (Number.isInteger(gAiParams.seed) && gAiParams.seed >= 0) { - params.seed = gAiParams.seed; - } - - return params; -} - -function buildPayloadObject(text, criterion) { - const payloadObj = { - messages: buildMessages(text, criterion), - response_format: RESPONSE_FORMAT, - ...buildOpenAiParams(), - }; +function buildPayload(text, criterion) { + let payloadObj = Object.assign({ + prompt: buildPrompt(text, criterion) + }, gAiParams); if (gModel) { payloadObj.model = gModel; } - return payloadObj; + return JSON.stringify(payloadObj); } function reportParseError(message, detail) { @@ -363,81 +290,78 @@ function reportParseError(message, detail) { } } -function extractMessageContent(content) { - if (typeof content === "string") { - return { text: content, refusal: "" }; - } - if (!Array.isArray(content)) { - return { text: "", refusal: "" }; - } +function extractLastJsonObject(text) { + let last = null; + let start = -1; + let depth = 0; + let inString = false; + let escape = false; - const textParts = []; - const refusalParts = []; - for (const part of content) { - if (!part || typeof part !== "object") { + 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 (part.type === "text" && typeof part.text === "string") { - textParts.push(part.text); + if (ch === "\"") { + inString = true; + continue; } - if (part.type === "refusal" && typeof part.refusal === "string") { - refusalParts.push(part.refusal); + 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 { - text: textParts.join("\n").trim(), - refusal: refusalParts.join("\n").trim(), - }; + return last; } function parseMatch(result) { - const message = result?.choices?.[0]?.message; - if (!message || typeof message !== "object") { - reportParseError("AI response missing assistant message.", JSON.stringify(result).slice(0, 800)); - return { matched: false, reason: "" }; - } - - if (typeof message.refusal === "string" && message.refusal.trim()) { - reportParseError("Model refused classification request.", message.refusal.slice(0, 800)); - return { matched: false, reason: message.refusal.trim() }; - } - - if (message.parsed && typeof message.parsed === "object") { - const parsed = message.parsed; - if (typeof parsed.match === "boolean" && typeof parsed.reason === "string") { - return { matched: parsed.match, reason: parsed.reason }; - } - } - - const extracted = extractMessageContent(message.content); - if (extracted.refusal) { - reportParseError("Model refused classification request.", extracted.refusal.slice(0, 800)); - return { matched: false, reason: extracted.refusal }; - } - if (!extracted.text) { - reportParseError("AI response missing assistant message content.", JSON.stringify(message).slice(0, 800)); + const rawText = result.choices?.[0]?.text || ""; + const candidate = extractLastJsonObject(rawText); + if (!candidate) { + reportParseError("No JSON object found in AI response.", rawText.slice(0, 800)); return { matched: false, reason: "" }; } let obj; try { - obj = JSON.parse(extracted.text); + obj = JSON.parse(candidate); } catch (e) { - reportParseError("Failed to parse JSON from AI response.", extracted.text.slice(0, 800)); + reportParseError("Failed to parse JSON from AI response.", candidate.slice(0, 800)); return { matched: false, reason: "" }; } - if (typeof obj?.match !== "boolean") { - reportParseError("AI response missing valid match boolean.", extracted.text.slice(0, 800)); - return { matched: false, reason: "" }; - } - if (typeof obj?.reason !== "string") { - reportParseError("AI response missing valid reason string.", extracted.text.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)); } - return { matched: obj.match, reason: obj.reason }; + const reasonValue = obj.reason ?? obj.reasoning ?? obj.explaination; + const reason = typeof reasonValue === "string" ? reasonValue : ""; + + return { matched, reason }; } function cacheEntry(cacheKey, matched, reason) { @@ -503,10 +427,9 @@ async function classifyText(text, criterion, cacheKey = null) { return cached; } - const payloadObj = buildPayloadObject(text, criterion); - const payload = JSON.stringify(payloadObj); + const payload = buildPayload(text, criterion); try { - await storage.local.set({ lastPayload: payloadObj }); + await storage.local.set({ lastPayload: JSON.parse(payload) }); } catch (e) { aiLog('failed to save last payload', { level: 'warn' }, e); } diff --git a/options/options.html b/options/options.html index 934fe2d..cfc370d 100644 --- a/options/options.html +++ b/options/options.html @@ -93,13 +93,12 @@
- +
-

OpenAI Chat uses native chat messages. Other formats send one templated user message over Chat Completions.

- +