From 9a5404cb629df3c5808cdc812044ef91196008c0 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Wed, 7 Oct 2026 23:57:49 -0500 Subject: [PATCH] Add SystemOne classification support and release version 4.0.0 --- AGENTS.md | 59 ++- README.md | 104 +++++- background.js | 82 ++-- details.html | 27 ++ details.js | 34 +- manifest.json | 2 +- modules/AiClassifier.js | 559 +++++++--------------------- modules/backends/chatCompletions.js | 81 ++++ modules/backends/systemOne.js | 17 + modules/classificationConfig.js | 32 ++ options/dataTransfer.js | 8 +- options/options.html | 21 +- options/options.js | 131 ++++--- tests/cacheRestore.test.mjs | 84 +++++ tests/classification.test.mjs | 167 +++++++++ tests/ruleExecution.test.mjs | 80 ++++ 16 files changed, 916 insertions(+), 572 deletions(-) create mode 100644 modules/backends/chatCompletions.js create mode 100644 modules/backends/systemOne.js create mode 100644 modules/classificationConfig.js create mode 100644 tests/cacheRestore.test.mjs create mode 100644 tests/classification.test.mjs create mode 100644 tests/ruleExecution.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 1dcb02d..673e422 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje - `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`, `defaultParams.js` and `themeUtils.js`. - `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. +- `details.html` and `details.js`: View classification evidence and clear cache for a message. - `resources/`: Images and other static files. - `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.sh`: Bash script to package the extension. @@ -30,14 +30,42 @@ This file provides guidelines for codex agents contributing to the Sortana proje ## Testing -There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script. +Run the dependency-free suite with Node.js 22 or newer: `node --test tests/*.test.mjs`. +The tests mock extension storage, HTTP responses, and Thunderbird rule execution. +Manual verification in Thunderbird is still required for UI and real-server integration. +Do **not** run the `ps1` build script or the SVG processing script. ## Endpoint Notes -Sortana targets the OpenAI-compatible `/v1/chat/completions` API. 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. -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 `messages` with system and user roles. Responses are expected to include text in `choices[0].message.content` containing a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in that content and ignores any surrounding commentary. +Sortana supports `apiType: "chat-completions"` (default) and `"systemone"`. +Plain adapter modules under `modules/backends/` own request construction and response +validation; `AiClassifier` owns shared transport, configuration snapshots, threshold +policy, and caching. The stored endpoint is a base URL. Normalization accepts `/v1` +and recognized full API paths, preserves proxy prefixes, and defaults to HTTPS +when no scheme is supplied. Model discovery uses `/v1/models`; **None** omits `model`. +Bearer authentication is optional for both APIs. Organization/project headers are +sent only for Chat Completions. + +Chat Completions sends system/user messages and extracts the last JSON object from +`choices[0].message.content`, requiring a `match` or `matched` boolean. SystemOne +sends prepared email text as `state` and a single `questions.match` of type `noul`. +It requires `answers.match.type === "noul"` and a finite numeric `noul` in `[0, 1]`. +No chat generation parameters or system prompt are sent to SystemOne. + +The global `decisionThreshold` defaults to 0.5; optional rule-level +`decisionThreshold` overrides it. UI percentages are stored as numbers in `[0, 1]`. +Matching uses `probability >= threshold` before display rounding. Details displays +probabilities in a progress bar with the percentage inside; threshold and model +appear in its tooltip. Results use the current threshold, never fabricated reasoning or action +history. No secondary model calls are made. + +`classify()` returns structured evidence; `classifyText()` remains a boolean wrapper. +Failures throw and are never cached. HTTP errors include the request URL and +JSON server error message when available; SystemOne 404 errors suggest checking +the running server build and decision-model support. The rule runner stops that email, reports the +error, and continues the next queued email. Snapshot settings and rules per email. +`inputCharacterLimit` replaces coupling input truncation to output `max_tokens`; +it applies with aggressive reduction and migrates from the old effective limit. ## Documentation @@ -71,10 +99,21 @@ text extracted from all text parts. ### Cache Strategy -`aiCache` persists classification results. Each key is the SHA‑256 hex of -`"|"` and maps to an object with `matched` and `reason` -properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first -time the add-on loads after an update. +`aiCache` persists version 2 inference evidence. Keys are +`v2::`. +Include backend, endpoint, model, effective request parameters and preprocessing; +exclude secrets, actions and thresholds. Recompute SystemOne matches from cached +probability and current thresholds without automatically executing actions. +Entries retain a hashed message identifier for clearing across configurations. +Version 4.0.0 intentionally breaks legacy cache compatibility: legacy caches +(including `aiReasonCache`) are invalidated because their inference +configuration is unknown. Import/export validates versioned evidence; storage +changes refresh the running cache. Maintenance uses `sortana:getCacheSize` to +count validated entries in the background classifier, rather than raw storage keys. +Full restoration requires matching settings and rules as well as cache evidence; +`tests/cacheRestore.test.mjs` exercises export/reinstall/import through the actual +storage and Details handlers, including changed numeric Thunderbird message IDs. Clearing/importing during inference prevents +that in-flight result from repopulating the replaced cache. ### Icon Set Usage diff --git a/README.md b/README.md index a0625d8..b7fb0f5 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,11 @@ 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 the OpenAI-compatible `/v1/chat/completions` API; the options page stores a base -URL and appends `/v1/chat/completions` when sending requests. Requests contain a system -message and a user message. The endpoint should respond -with text in `choices[0].message.content` containing JSON indicating whether the -message meets a specified criterion, including a short reasoning summary. -Responses are parsed by extracting the last JSON object in that content and -expecting a `match` (or `matched`) boolean plus a `reason` string. +HTTP endpoint. Choose **Chat Completions** for an OpenAI-compatible +`/v1/chat/completions` service or **SystemOne** for decision models served through +`/v1/systemone`. Chat Completions returns a match boolean and textual explanation; +SystemOne returns a match probability, compared against a configurable threshold. +The options page stores a base URL and shows the resolved request URL. ## Features @@ -19,7 +17,7 @@ expecting a `match` (or `matched`) boolean plus a `reason` string. - **Optional OpenAI auth headers** – provide an API key plus optional organization/project headers when needed. - **Custom system prompts** – tailor the instructions sent as the system message 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, top‑p and more from the options page. +- **Advanced parameters (Chat Completions)** – tune generation settings like temperature, top‑p and more from the options page. Provider-specific advanced fields are forwarded as configured and may be rejected by strict OpenAI endpoints. - **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. @@ -33,7 +31,7 @@ expecting a `match` (or `matched`) boolean plus a `reason` string. - **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red briefly before returning to normal. - **Error notification** – failed classification displays a notification in Thunderbird. - **Session error log** – the Errors tab (visible only when errors occur) shows errors recorded since the last add-on start. -- **View reasoning** – inspect why rules matched via the Details popup. +- **View results** – inspect chat explanations or SystemOne probabilities and thresholds in the Details popup. - **Cache management** – clear cached results from the context menu or options page. - **Queue & timing stats** – monitor processing time on the Maintenance tab. - **Packaging scripts** – `build-xpi.ps1` (PowerShell) or `build-xpi.sh` (bash) build an XPI ready for installation. @@ -42,19 +40,91 @@ expecting a `match` (or `matched`) boolean plus a `reason` string. ### Cache Storage -Classification results are stored under the `aiCache` key in extension storage. -Each entry maps a SHA‑256 hash of `"|"` to an object -containing `matched` and `reason` fields. Older installations with a separate -`aiReasonCache` will be migrated automatically on startup. +Classification results are stored under `aiCache` using version 2 entries. Keys +combine a hashed Message-ID with a fingerprint of the criterion, backend, +endpoint, selected model, effective request settings, and preprocessing settings. +Credentials and thresholds are excluded. SystemOne probabilities are retained at +full precision; changing a threshold reuses the evidence without another request. +Changing inference settings requires a fresh classification. If the model behind +an unchanged server/model alias changes, clear the cache manually. + +**Version 4.0.0 upgrade note:** legacy cache entries and `aiReasonCache` are discarded because +they do not identify the inference configuration. Settings and rules are retained. +Imported legacy caches are also discarded. To restore cached Details after reinstalling, +export and import **Settings, Rules, and Cache** together: lookups require the +original criterion and matching inference settings. Import refreshes the running +classifier immediately; Maintenance counts its validated, loaded entries. A cache-only +import will not recreate rules or settings. Clearing a message's cache removes its +entries across rules and configurations. + +### SystemOne with llama-server + +Use a llama.cpp build supporting `/v1/systemone` and a compatible decision GGUF; +older builds and ordinary chat models cannot serve this API. A 404 with +`File Not Found` from `/v1/systemone` indicates the route is unavailable: check +the running server build and any reverse-proxy routing. Loading a decision GGUF +alone does not add this route to an older server. For example: + +```sh +llama-server -hf ggml-org/Laya-GGUF +``` + +1. Select **SystemOne** in Settings and set the endpoint to `http://127.0.0.1:8080` + (or your server's address). Refresh models and select one, or choose **None** + for a single loaded model. Router deployments require the appropriate model ID. +2. Set the default match probability threshold (initially **50%**). Each rule has + an optional percentage override; leave it blank to inherit the default. +3. Apply rules normally. One `noul` question is sent per evaluated rule. A rule + matches when the unrounded probability is **greater than or equal to** its + effective threshold. For example, 80% matches an 80% threshold. +4. Open Details to see a probability bar with the percentage inside. Hover over + the bar to see the current threshold and model. This is the + result under current settings, not an action history. Changing thresholds does + not automatically rerun actions. Decision models provide no textual explanation; + no second model is called to invent one. Use Chat Completions for explanations. + +Probabilities are model estimates, not guaranteed accuracy. Evaluate thresholds +against representative labeled emails, including long messages and attachments. +SystemOne uses the existing text extraction and attachment summaries; it does not +send attachment binaries or images. Chat prompts and generation settings remain +saved but are inactive for SystemOne. Optional Bearer authentication works with +both APIs; organization/project headers apply only to Chat Completions. + +**Input character limit** applies when aggressive reduction is enabled, independently +of maximum output tokens. Existing installations inherit their previous effective +character limit; the default is 3686. Character counts do not guarantee that input +fits a model's token context or llama-server batch limits. Leave room for question +instructions and configure the server for the chosen model. + +A request or response-parsing failure stops the remaining rules for that email, +records an error, and allows the queue to continue with the next email. Failed +results are never cached. Actions already completed are not rolled back. + +Upstream: [llama-server SystemOne documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md#typesafe-compatible-api-endpoints). + +### Verification + +Run the dependency-free tests with Node.js 22 or newer: + +```sh +node --test tests/*.test.mjs +``` + +Tests cover adapters, thresholds, URLs, settings validation, cache migration and +reuse, request failures, per-email snapshots, and queue behavior with mocked +Thunderbird services. Manual acceptance testing in Thunderbird should verify both +API types, model discovery, rule overrides and ordering, import/export, cache +clearing, Details, and stop-after-match against a running server. ## Architecture Overview Sortana is implemented entirely with documented MailExtension/WebExtension APIs. No custom experiment code or Thunderbird-internal fallback paths are required: - `background.js` loads saved settings, manages the classification queue and listens for new messages. -- `modules/AiClassifier.js` implements the classification logic and cache handling. +- `modules/AiClassifier.js` shares transport, threshold policy, and cache handling; + `modules/backends/` contains the Chat Completions and SystemOne adapters. - `options/` contains the HTML and JavaScript for configuring the endpoint and rules. -- `details.html` / `details.js` present cached reasoning for a message. +- `details.html` / `details.js` present cached classification evidence for a message. - `_locales/` holds localized strings used throughout the UI. ### Key Files @@ -65,7 +135,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs. | `background.js` | Startup tasks and classification queue management. | | `modules/AiClassifier.js` | Core classification logic and cache handling. | | `options/options.html` and `options.js` | Endpoint and rule configuration UI. | -| `details.html` and `details.js` | View stored reasoning for a message. | +| `details.html` and `details.js` | View stored classification evidence for a message. | | `logger.js` | Colorized logging with optional debug mode. | ## Building @@ -84,7 +154,7 @@ 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`). Use the Model dropdown to load + (Sortana appends the path for the selected API type). 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. diff --git a/background.js b/background.js index 5cfc112..1b9f554 100644 --- a/background.js +++ b/background.js @@ -28,7 +28,6 @@ let stripUrlParams = false; let altTextImages = false; let collapseWhitespace = false; let tokenReduction = false; -let maxTokens = 4096; let TurndownService = null; let userTheme = 'auto'; let currentTheme = 'light'; @@ -53,6 +52,7 @@ function normalizeRules(rules) { if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo }); const rule = { criterion: r.criterion, actions }; + if (r.decisionThreshold !== undefined) rule.decisionThreshold = r.decisionThreshold; if (r.stopProcessing) rule.stopProcessing = true; if (r.unreadOnly) rule.unreadOnly = true; if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays; @@ -318,10 +318,12 @@ async function processMessage(id) { updateActionIcon(); try { const full = await messenger.messages.getFull(id); + const snapshot = AiClassifier.getConfig(); + const rules = structuredClone(aiRules); const originalText = buildEmailText(full, false); let text = buildEmailText(full); - if (tokenReduction && maxTokens > 0) { - const limit = Math.floor(maxTokens * 0.9); + if (tokenReduction) { + const limit = snapshot.inputCharacterLimit; if (text.length > limit) { text = text.slice(0, limit); } @@ -345,7 +347,7 @@ async function processMessage(id) { identityId = null; } - for (const rule of aiRules) { + for (const rule of rules) { if (rule.enabled === false) { continue; } @@ -372,8 +374,8 @@ async function processMessage(id) { } } } - const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion); - const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); + const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion, snapshot); + const { matched } = await AiClassifier.classify(text, rule.criterion, { cacheKey, snapshot, threshold: rule.decisionThreshold ?? snapshot.decisionThreshold }); if (matched) { for (const act of (rule.actions || [])) { if (act.type === 'tag' && act.tagKey) { @@ -456,18 +458,8 @@ async function clearCacheForMessages(idsInput) { aiRules = normalizeRules(stored); } - const keys = []; - for (const msg of ids) { - const id = msg?.id ?? msg; - for (const rule of aiRules) { - const key = await AiClassifier.buildCacheKey(id, rule.criterion); - keys.push(key); - } - } - if (keys.length) { - await AiClassifier.removeCacheEntries(keys); - showTransientIcon(ICONS.circle); - } + for (const msg of ids) await AiClassifier.clearCacheForMessage(msg?.id ?? msg); + showTransientIcon(ICONS.circle); } (async () => { @@ -484,8 +476,13 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); + const store = await storage.local.get(["apiType", "decisionThreshold", "inputCharacterLimit", "endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); logger.setDebug(store.debugLogging); + const { migratedInputLimit } = await import(browser.runtime.getURL('modules/classificationConfig.js')); + if (store.inputCharacterLimit === undefined) { + store.inputCharacterLimit = migratedInputLimit(store); + await storage.local.set({ inputCharacterLimit: store.inputCharacterLimit }); + } await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; @@ -495,9 +492,6 @@ async function clearCacheForMessages(idsInput) { altTextImages = store.altTextImages === true; collapseWhitespace = store.collapseWhitespace === true; tokenReduction = store.tokenReduction === true; - if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') { - maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens; - } showDebugTab = store.showDebugTab === true; const savedStats = await storage.local.get('classifyStats'); if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { @@ -514,27 +508,9 @@ async function clearCacheForMessages(idsInput) { aiRules = normalizeRules(newRules); logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); } - if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { - const config = {}; - if (changes.endpoint) config.endpoint = changes.endpoint.newValue; - if (changes.model) config.model = changes.model.newValue; - if (changes.apiKey) config.apiKey = changes.apiKey.newValue; - if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue; - if (changes.openaiProject) config.openaiProject = changes.openaiProject.newValue; - if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue; - if (changes.aiParams) { - config.aiParams = changes.aiParams.newValue; - if (changes.aiParams.newValue && typeof changes.aiParams.newValue.max_tokens !== 'undefined') { - maxTokens = parseInt(changes.aiParams.newValue.max_tokens) || maxTokens; - } - } - if (changes.debugLogging) { - config.debugLogging = changes.debugLogging.newValue === true; - logger.setDebug(config.debugLogging); - } - await AiClassifier.setConfig(config); - logger.aiLog("AiClassifier config updated from storage change", { debug: true }, config); - } + const configChanges = Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue])); + AiClassifier.setConfig(configChanges); + if (changes.aiCache) AiClassifier.replaceCache(changes.aiCache.newValue); if (changes.htmlToMarkdown) { htmlToMarkdown = changes.htmlToMarkdown.newValue === true; logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown); @@ -666,10 +642,10 @@ async function clearCacheForMessages(idsInput) { logger.aiLog("sortana:test – criterion", { debug: true }, criterion); try { - logger.aiLog("Calling AiClassifier.classifyText()", { debug: true }); - const result = await AiClassifier.classifyText(text, criterion); + logger.aiLog("Calling AiClassifier.classify()", { debug: true }); + const result = await AiClassifier.classify(text, criterion, { threshold: msg.decisionThreshold ?? AiClassifier.getConfig().decisionThreshold }); logger.aiLog("classify() returned", { debug: true }, result); - return { match: result }; + return { ...result, match: result.matched }; } catch (err) { logger.aiLog("Error in classify()", { level: 'error' }, err); @@ -716,13 +692,11 @@ async function clearCacheForMessages(idsInput) { aiRules = normalizeRules(stored); } const results = []; - for (const rule of aiRules) { - const key = await AiClassifier.buildCacheKey(id, rule.criterion); - const matched = AiClassifier.getCachedResult(key); - const reason = AiClassifier.getReason(key); - if (matched !== null || reason) { - results.push({ criterion: rule.criterion, matched, reason }); - } + const snapshot = AiClassifier.getConfig(); + for (const rule of structuredClone(aiRules)) { + const key = await AiClassifier.buildCacheKey(id, rule.criterion, snapshot); + const detail = AiClassifier.getCachedDetails(key, rule.decisionThreshold ?? snapshot.decisionThreshold); + if (detail) results.push({ criterion: rule.criterion, ...detail }); } return { subject, results }; } catch (e) { @@ -757,6 +731,8 @@ async function clearCacheForMessages(idsInput) { timingStats.last = typeof last === 'number' ? last : -1; await storage.local.set({ classifyStats: timingStats }); return { ok: true }; + } else if (msg?.type === "sortana:getCacheSize") { + return { count: await AiClassifier.getCacheSize() }; } else if (msg?.type === "sortana:getQueueCount") { return { count: queuedCount + (processing ? 1 : 0) }; } else if (msg?.type === "sortana:getErrorLog") { diff --git a/details.html b/details.html index d15a3c9..e84bf2e 100644 --- a/details.html +++ b/details.html @@ -4,6 +4,33 @@ AI Details +
diff --git a/details.js b/details.js index f306e01..a4534d3 100644 --- a/details.js +++ b/details.js @@ -51,15 +51,39 @@ async function loadMessage(id) { article.className = `message ${color} mb-4`; const header = document.createElement('div'); header.className = 'message-header'; - header.innerHTML = `

${r.criterion}

`; + const criterion = document.createElement('p'); + criterion.textContent = r.criterion; + header.appendChild(criterion); const body = document.createElement('div'); body.className = 'message-body'; const status = document.createElement('p'); status.textContent = r.matched ? 'Matched' : 'Did not match'; - const pre = document.createElement('pre'); - pre.textContent = r.reason || ''; - body.appendChild(status); - body.appendChild(pre); + if (r.backend === 'systemone') { + status.textContent += ' under current threshold'; + status.classList.add('mb-2'); + body.appendChild(status); + const percentage = r.probability * 100; + const bar = document.createElement('div'); + bar.className = `probability-bar ${color}`; + bar.setAttribute('role', 'progressbar'); + bar.setAttribute('aria-label', 'Match probability'); + bar.setAttribute('aria-valuemin', '0'); + bar.setAttribute('aria-valuemax', '100'); + bar.setAttribute('aria-valuenow', String(percentage)); + bar.title = `Threshold: ${r.threshold * 100}%${r.model ? ` · Model: ${r.model}` : ''}`; + const fill = document.createElement('div'); + fill.className = 'probability-fill'; + fill.style.width = `${percentage}%`; + const value = document.createElement('span'); + value.className = 'probability-value'; + value.textContent = `${percentage.toFixed(2)}%`; + bar.append(fill, value); + body.appendChild(bar); + } else { + const pre = document.createElement('pre'); + pre.textContent = r.reason || ''; + body.append(status, pre); + } article.appendChild(header); article.appendChild(body); container.appendChild(article); diff --git a/manifest.json b/manifest.json index 139e60e..e9a3088 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Sortana", - "version": "3.0.0", + "version": "4.0.0", "default_locale": "en-US", "applications": { "gecko": { diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index 583eec0..51951e6 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -1,438 +1,151 @@ "use strict"; -import { aiLog, setDebug } from "../logger.js"; -import { DEFAULT_AI_PARAMS } from "./defaultParams.js"; - +import { aiLog, setDebug } from '../logger.js'; +import { CONFIG_DEFAULTS, validateSettings, validateThreshold } from './classificationConfig.js'; +import * as chat from './backends/chatCompletions.js'; +import * as systemone from './backends/systemOne.js'; const storage = (globalThis.messenger ?? globalThis.browser).storage; +const adapters = { 'chat-completions': chat, systemone }; +let config = structuredClone(CONFIG_DEFAULTS); +let cache = new Map(); +let loaded = false; +let loading; +let generation = 0; -const CHAT_COMPLETIONS_PATH = "/v1/chat/completions"; -const MODELS_PATH = "/v1/models"; - -const SYSTEM_PREFIX = `You are an email-classification assistant. -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 ONLY a JSON object on a single line of the form: -{"match": true, "reason": ""} - if the email satisfies the criterion -{"match": false, "reason": ""} - otherwise - -Do not add any other keys, text, or formatting.`; - -let gEndpointBase = "http://127.0.0.1:5000"; -let gEndpoint = buildEndpointUrl(gEndpointBase); -let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; - -let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); -let gModel = ""; -let gApiKey = ""; -let gOpenaiOrganization = ""; -let gOpenaiProject = ""; - -let gCache = new Map(); -let gCacheLoaded = false; - -function normalizeEndpointBase(endpoint) { - if (typeof endpoint !== "string") { - return ""; - } - let base = endpoint.trim(); - if (!base) { - return ""; - } - base = base.replace(/\/v1\/(chat\/completions|completions|models)\/?$/i, ""); - return base; +export function normalizeEndpointBase(endpoint) { + if (typeof endpoint !== 'string' || !endpoint.trim()) return ''; + return endpoint.trim().replace(/\/+$/, '').replace(/\/v1(?:\/(?:chat\/completions|completions|models|systemone))?$/i, ''); } - -function buildEndpointUrl(endpointBase) { - const base = normalizeEndpointBase(endpointBase); - if (!base) { - return ""; - } - const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; - const needsSlash = withScheme.endsWith("/"); - const path = CHAT_COMPLETIONS_PATH.replace(/^\//, ""); - return `${withScheme}${needsSlash ? "" : "/"}${path}`; +function urlFor(endpoint, path) { + const base = normalizeEndpointBase(endpoint); + return base ? `${/^https?:\/\//i.test(base) ? base : `https://${base}`}${path}` : ''; } - -function buildModelsUrl(endpointBase) { - const base = normalizeEndpointBase(endpointBase); - if (!base) { - return ""; - } - const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; - const needsSlash = withScheme.endsWith("/"); - const path = MODELS_PATH.replace(/^\//, ""); - return `${withScheme}${needsSlash ? "" : "/"}${path}`; +export function buildEndpointUrl(endpoint, apiType = 'chat-completions') { + if (!adapters[apiType]) throw new Error('Unknown API type.'); + return urlFor(endpoint, adapters[apiType].path); } - -async function sha256Hex(str) { - try { - const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)); - return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, "0")).join(""); - } catch (e) { - aiLog("sha256Hex failed", { level: "error" }, e); - return ""; +export function buildModelsUrl(endpoint) { return urlFor(endpoint, '/v1/models'); } +export function getConfig() { return structuredClone(config); } +export function setConfig(update = {}) { + validateSettings(update); + for (const key of Object.keys(CONFIG_DEFAULTS)) { + if (update[key] !== undefined) config[key] = structuredClone(update[key]); } + config.aiParams = { ...CONFIG_DEFAULTS.aiParams, ...config.aiParams }; + for (const key of ['model', 'apiKey', 'openaiOrganization', 'openaiProject']) config[key] = String(config[key]).trim(); + setDebug(config.debugLogging); } - -async function resolveHeaderId(id) { - if (typeof id === "number" && typeof messenger?.messages?.get === "function") { +async function hash(value) { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); +} +async function messageHash(id) { + const api = globalThis.messenger ?? globalThis.browser; + if (typeof id === 'number' && api.messages?.get) { + const header = await api.messages.get(id); + id = header.headerMessageId || id; + } + return hash(String(id)); +} +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])])); + return value; +} +export async function buildCacheKey(id, criterion, snapshot = getConfig()) { + const fingerprint = { + backend: snapshot.apiType, endpoint: buildEndpointUrl(snapshot.endpoint, snapshot.apiType), + request: adapters[snapshot.apiType].buildRequest('', criterion, snapshot), + organization: snapshot.apiType === 'chat-completions' ? snapshot.openaiOrganization : '', + project: snapshot.apiType === 'chat-completions' ? snapshot.openaiProject : '', + preprocessing: Object.fromEntries(['htmlToMarkdown', 'stripUrlParams', 'altTextImages', 'collapseWhitespace', 'tokenReduction', 'inputCharacterLimit'].map(k => [k, snapshot[k]])) + }; + return `v2:${await messageHash(id)}:${await hash(JSON.stringify(stable(fingerprint)))}`; +} +export function validCacheEntry(key, entry) { + return /^v2:[a-f0-9]{64}:[a-f0-9]{64}$/.test(key) && entry?.version === 2 && + entry.messageHash === key.split(':')[1] && typeof entry.model === 'string' && + (entry.backend === 'systemone' + ? typeof entry.probability === 'number' && Number.isFinite(entry.probability) && entry.probability >= 0 && entry.probability <= 1 && entry.reason === null + : entry.backend === 'chat-completions' && typeof entry.matched === 'boolean' && entry.probability === null && (entry.reason === null || typeof entry.reason === 'string')); +} +export function replaceCache(data = {}) { + cache = new Map(Object.entries(data || {}).filter(([key, entry]) => validCacheEntry(key, entry))); + loaded = true; + generation++; +} +async function saveCache() { await storage.local.set({ aiCache: Object.fromEntries(cache) }); } +export async function init() { + if (loaded) return; + if (!loading) loading = (async () => { + const { aiCache = {}, aiReasonCache } = await storage.local.get(['aiCache', 'aiReasonCache']); + replaceCache(aiCache); + if (Object.keys(aiCache || {}).length !== cache.size) await saveCache(); + if (aiReasonCache !== undefined) await storage.local.remove('aiReasonCache'); + })().finally(() => { loading = null; }); + await loading; +} +function withPolicy(entry, threshold) { + const isDecision = entry.backend === 'systemone'; + if (isDecision) validateThreshold(threshold); + return { matched: isDecision ? entry.probability >= threshold : entry.matched, + reason: entry.reason, probability: entry.probability, threshold: isDecision ? threshold : null, + backend: entry.backend, model: entry.model }; +} +export function getCachedDetails(key, threshold = config.decisionThreshold) { + const entry = cache.get(key); + return entry ? withPolicy(entry, threshold) : null; +} +export function getCachedResult(key, threshold) { return getCachedDetails(key, threshold)?.matched ?? null; } +export function getReason(key) { return cache.get(key)?.reason ?? null; } +export async function classify(text, criterion, { cacheKey = null, snapshot = getConfig(), threshold = snapshot.decisionThreshold } = {}) { + await init(); + if (snapshot.apiType === 'systemone') validateThreshold(threshold); + const cached = getCachedDetails(cacheKey, threshold); + if (cached) return cached; + const epoch = generation; + const adapter = adapters[snapshot.apiType]; + const payload = adapter.buildRequest(text, criterion, snapshot); + try { await storage.local.set({ lastPayload: payload }); } + catch (error) { aiLog('Failed to save last payload', { level: 'warn' }, error); } + const headers = { 'Content-Type': 'application/json' }; + if (snapshot.apiKey) headers.Authorization = `Bearer ${snapshot.apiKey}`; + if (snapshot.apiType === 'chat-completions') { + if (snapshot.openaiOrganization) headers['OpenAI-Organization'] = snapshot.openaiOrganization; + if (snapshot.openaiProject) headers['OpenAI-Project'] = snapshot.openaiProject; + } + const endpoint = buildEndpointUrl(snapshot.endpoint, snapshot.apiType); + const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) }); + if (!response.ok) { + let detail = ''; try { - const hdr = await messenger.messages.get(id); - if (hdr?.headerMessageId) { - return hdr.headerMessageId; - } - } catch (e) { - aiLog(`Failed to resolve headerMessageId for ${id}`, { level: 'warn' }, e); - } + const body = await response.json(); + const message = body?.error?.message; + if (typeof message === 'string') detail = ` Server response: ${message.slice(0, 300)}.`; + } catch { /* Non-JSON error responses still report the URL and status. */ } + const hint = response.status === 404 && snapshot.apiType === 'systemone' + ? ' Verify that the running server build supports /v1/systemone and the selected decision model.' : ''; + throw new Error(`Classification request failed: HTTP ${response.status} at ${endpoint}.${detail}${hint}`); } - return String(id); -} - -async function buildCacheKey(id, criterion) { - const resolvedId = await resolveHeaderId(id); - return sha256Hex(`${resolvedId}|${criterion}`); -} - -async function loadCache() { - if (gCacheLoaded) { - return; - } - aiLog(`[AiClassifier] Loading cache`, {debug: true}); - try { - const { aiCache, aiReasonCache } = await storage.local.get(["aiCache", "aiReasonCache"]); - if (aiCache) { - for (let [k, v] of Object.entries(aiCache)) { - if (v && typeof v === "object") { - gCache.set(k, { matched: v.matched ?? null, reason: v.reason || "" }); - } else { - gCache.set(k, { matched: v, reason: "" }); - } - } - aiLog(`[AiClassifier] Loaded ${gCache.size} cache entries`, {debug: true}); - } else { - aiLog(`[AiClassifier] Cache is empty`, {debug: true}); - } - if (aiReasonCache) { - aiLog(`[AiClassifier] Migrating ${Object.keys(aiReasonCache).length} reason entries`, {debug: true}); - for (let [k, reason] of Object.entries(aiReasonCache)) { - let entry = gCache.get(k) || { matched: null, reason: "" }; - entry.reason = reason; - gCache.set(k, entry); - } - await storage.local.remove("aiReasonCache"); - await storage.local.set({ aiCache: Object.fromEntries(gCache) }); - } - } catch (e) { - aiLog(`Failed to load cache`, {level: 'error'}, e); - } - gCacheLoaded = true; -} - - -async function saveCache(updatedKey, updatedValue) { - if (typeof updatedKey !== "undefined") { - aiLog(`[AiClassifier] ⮡ Persisting entry '${updatedKey}' → ${updatedValue}`, {debug: true}); - } - try { - await storage.local.set({ aiCache: Object.fromEntries(gCache) }); - } catch (e) { - aiLog(`Failed to save cache`, {level: 'error'}, e); - } -} - - -async function setConfig(config = {}) { - if (typeof config.endpoint === "string") { - const base = normalizeEndpointBase(config.endpoint); - if (base) { - gEndpointBase = base; - } - gEndpoint = buildEndpointUrl(gEndpointBase); - } - if (typeof config.customSystemPrompt === "string") { - gCustomSystemPrompt = config.customSystemPrompt; - } - if (config.aiParams && typeof config.aiParams === "object") { - for (let [k, v] of Object.entries(config.aiParams)) { - if (k in gAiParams && typeof v !== "undefined") { - gAiParams[k] = v; - } - } - } - if (typeof config.model === "string") { - gModel = config.model.trim(); - } - if (typeof config.apiKey === "string") { - gApiKey = config.apiKey.trim(); - } - if (typeof config.openaiOrganization === "string") { - gOpenaiOrganization = config.openaiOrganization.trim(); - } - if (typeof config.openaiProject === "string") { - gOpenaiProject = config.openaiProject.trim(); - } - if (typeof config.debugLogging === "boolean") { - setDebug(config.debugLogging); - } - if (!gEndpoint) { - gEndpoint = buildEndpointUrl(gEndpointBase); - } - aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true}); - aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); -} - -function buildAuthHeaders() { - const headers = {}; - if (gApiKey) { - headers.Authorization = `Bearer ${gApiKey}`; - } - if (gOpenaiOrganization) { - headers["OpenAI-Organization"] = gOpenaiOrganization; - } - if (gOpenaiProject) { - headers["OpenAI-Project"] = gOpenaiProject; - } - return headers; -} - -function buildSystemPrompt() { - return SYSTEM_PREFIX + (gCustomSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX; -} - -function buildPrompt(body, criterion) { - aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true}); - return `**Email Contents**\n\`\`\`\n${body}\n\`\`\`\nClassification Criterion: ${criterion}`; -} - -function getCachedResult(cacheKey) { - if (!gCacheLoaded) { - return null; - } - if (cacheKey && gCache.has(cacheKey)) { - aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true}); - const entry = gCache.get(cacheKey); - return entry?.matched ?? null; - } - return null; -} - -function getReason(cacheKey) { - if (!gCacheLoaded) { - return null; - } - const entry = gCache.get(cacheKey); - return cacheKey && entry ? entry.reason || null : null; -} - -function buildPayload(text, criterion) { - let payloadObj = Object.assign({ - messages: [ - { role: "system", content: buildSystemPrompt() }, - { role: "user", content: buildPrompt(text, criterion) } - ] - }, gAiParams); - if (gModel) { - payloadObj.model = gModel; - } - 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) { - const rawText = result.choices?.[0]?.message?.content; - if (typeof rawText !== "string") { - reportParseError("Chat response missing text content.", JSON.stringify(result).slice(0, 800)); - return { matched: false, reason: "" }; - } - 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(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) { - if (!cacheKey) { - return; - } - aiLog(`[AiClassifier] Caching entry '${cacheKey}'`, {debug: true}); - const entry = gCache.get(cacheKey) || { matched: null, reason: "" }; - if (typeof matched === "boolean") { - entry.matched = matched; - } - if (typeof reason === "string") { - entry.reason = reason; - } - gCache.set(cacheKey, entry); - saveCache(cacheKey, entry); -} - -async function removeCacheEntries(keys = []) { - if (!Array.isArray(keys)) { - keys = [keys]; - } - if (!gCacheLoaded) { - await loadCache(); - } - let removed = false; - for (let key of keys) { - if (gCache.delete(key)) { - removed = true; - aiLog(`[AiClassifier] Removed cache entry '${key}'`, {debug: true}); - } - } - if (removed) { + const raw = await response.json(); + const entry = { ...adapter.parseResponse(raw), backend: snapshot.apiType, + model: typeof raw.model === 'string' ? raw.model : snapshot.model, version: 2, + messageHash: cacheKey?.split(':')[1] }; + if (cacheKey && epoch === generation) { + cache.set(cacheKey, entry); await saveCache(); } + return withPolicy(entry, threshold); } - -async function clearCache() { - if (!gCacheLoaded) { - await loadCache(); - } - if (gCache.size > 0) { - gCache.clear(); - await saveCache(); - aiLog(`[AiClassifier] Cleared cache`, {debug: true}); - } +export async function classifyText(text, criterion, cacheKey = null) { return (await classify(text, criterion, { cacheKey })).matched; } +export async function removeCacheEntries(keys = []) { + await init(); generation++; + for (const key of Array.isArray(keys) ? keys : [keys]) cache.delete(key); + await saveCache(); } - -async function getCacheSize() { - if (!gCacheLoaded) { - await loadCache(); - } - return gCache.size; +export async function clearCacheForMessage(id) { + await init(); + const identifier = await messageHash(id); + await removeCacheEntries([...cache].filter(([, entry]) => entry.messageHash === identifier).map(([key]) => key)); } - - -async function classifyText(text, criterion, cacheKey = null) { - if (!gCacheLoaded) { - await loadCache(); - } - const cached = getCachedResult(cacheKey); - if (cached !== null) { - return cached; - } - - const payload = buildPayload(text, criterion); - try { - await storage.local.set({ lastPayload: JSON.parse(payload) }); - } catch (e) { - aiLog('failed to save last payload', { level: 'warn' }, e); - } - - aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true}); - aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload); - - try { - const response = await fetch(gEndpoint, { - method: "POST", - headers: { "Content-Type": "application/json", ...buildAuthHeaders() }, - body: payload, - }); - - if (!response.ok) { - aiLog(`HTTP status ${response.status}`, {level: 'warn'}); - return false; - } - - const result = await response.json(); - aiLog(`[AiClassifier] Received response:`, {debug: true}, result); - const parsed = parseMatch(result); - cacheEntry(cacheKey, parsed.matched, parsed.reason); - return parsed.matched; - } catch (e) { - aiLog(`HTTP request failed`, {level: 'error'}, e); - return false; - } -} - -async function init() { - await loadCache(); -} - -export { buildEndpointUrl, buildModelsUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; +export async function clearCache() { await init(); generation++; cache.clear(); await saveCache(); } +export async function getCacheSize() { await init(); return cache.size; } diff --git a/modules/backends/chatCompletions.js b/modules/backends/chatCompletions.js new file mode 100644 index 0000000..38d9771 --- /dev/null +++ b/modules/backends/chatCompletions.js @@ -0,0 +1,81 @@ +const SYSTEM_PREFIX = `You are an email-classification assistant. +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 ONLY a JSON object on a single line of the form: +{"match": true, "reason": ""} - if the email satisfies the criterion +{"match": false, "reason": ""} - otherwise + +Do not add any other keys, text, or formatting.`; + +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; +} + +export const path = '/v1/chat/completions'; +export function buildRequest(text, criterion, config) { + const payload = { + messages: [ + { role: 'system', content: SYSTEM_PREFIX + (config.customSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX }, + { role: 'user', content: `**Email Contents**\n\`\`\`\n${text}\n\`\`\`\nClassification Criterion: ${criterion}` } + ], ...config.aiParams + }; + if (config.model) payload.model = config.model; + return payload; +} +export function parseResponse(result) { + const content = result?.choices?.[0]?.message?.content; + if (typeof content !== 'string') throw new Error('Chat response missing text content.'); + const candidate = extractLastJsonObject(content); + if (!candidate) throw new Error('No JSON object found in AI response.'); + const obj = JSON.parse(candidate); + const matched = Object.hasOwn(obj, 'match') ? obj.match : obj.matched; + if (typeof matched !== 'boolean') throw new Error('AI response missing valid match boolean.'); + const reason = obj.reason ?? obj.reasoning ?? obj.explaination; + return { matched, reason: typeof reason === 'string' ? reason : null, probability: null }; +} diff --git a/modules/backends/systemOne.js b/modules/backends/systemOne.js new file mode 100644 index 0000000..0de2472 --- /dev/null +++ b/modules/backends/systemOne.js @@ -0,0 +1,17 @@ +export const path = '/v1/systemone'; +export function buildRequest(text, criterion, config) { + const payload = { + state: text, + questions: { match: { type: 'noul', instructions: `Does this email satisfy the following criterion: ${criterion}` } } + }; + if (config.model) payload.model = config.model; + return payload; +} +export function parseResponse(result) { + const answer = result?.answers?.match; + if (answer?.type !== 'noul' || typeof answer.noul !== 'number' || + !Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) { + throw new Error('SystemOne response must contain answers.match with type noul and a probability from 0 to 1.'); + } + return { matched: null, reason: null, probability: answer.noul }; +} diff --git a/modules/classificationConfig.js b/modules/classificationConfig.js new file mode 100644 index 0000000..3abe7cc --- /dev/null +++ b/modules/classificationConfig.js @@ -0,0 +1,32 @@ +import { DEFAULT_AI_PARAMS } from './defaultParams.js'; +export const CONFIG_DEFAULTS = { + apiType: 'chat-completions', endpoint: 'http://127.0.0.1:5000', model: '', + apiKey: '', openaiOrganization: '', openaiProject: '', customSystemPrompt: '', + decisionThreshold: 0.5, inputCharacterLimit: 3686, aiParams: DEFAULT_AI_PARAMS, + htmlToMarkdown: false, stripUrlParams: false, altTextImages: false, + collapseWhitespace: false, tokenReduction: false, debugLogging: false +}; +export function validateThreshold(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { + throw new Error('Match threshold must be between 0% and 100%.'); + } + return value; +} +export function validateSettings(data) { + if (data.apiType !== undefined && !['chat-completions', 'systemone'].includes(data.apiType)) throw new Error('Unknown API type.'); + if (data.decisionThreshold !== undefined) validateThreshold(data.decisionThreshold); + if (data.inputCharacterLimit !== undefined && (!Number.isSafeInteger(data.inputCharacterLimit) || data.inputCharacterLimit < 1)) { + throw new Error('Input character limit must be a positive integer.'); + } + if (data.aiRules !== undefined) { + if (!Array.isArray(data.aiRules)) throw new Error('Rules must be an array.'); + for (const rule of data.aiRules) { + if (rule.decisionThreshold !== undefined) validateThreshold(rule.decisionThreshold); + } + } +} +export function migratedInputLimit(data) { + if (data.inputCharacterLimit !== undefined) return data.inputCharacterLimit; + const value = Math.floor(Number(data.aiParams?.max_tokens ?? 4096) * 0.9); + return Number.isSafeInteger(value) && value > 0 ? value : 3686; +} diff --git a/options/dataTransfer.js b/options/dataTransfer.js index 2c0b26e..ef2b237 100644 --- a/options/dataTransfer.js +++ b/options/dataTransfer.js @@ -1,7 +1,10 @@ "use strict"; +import { validateSettings, migratedInputLimit } from '../modules/classificationConfig.js'; +import { validCacheEntry } from '../modules/AiClassifier.js'; const storage = (globalThis.messenger ?? browser).storage; const KEY_GROUPS = { settings: [ + 'apiType', 'decisionThreshold', 'inputCharacterLimit', 'tokenReduction', 'endpoint', 'model', 'apiKey', @@ -36,12 +39,15 @@ export async function exportData(categories) { URL.revokeObjectURL(url); } -export async function importData(file, categories) { +export async function importData(file, categories = Object.keys(KEY_GROUPS)) { const text = await file.text(); const parsed = JSON.parse(text); const data = {}; for (const key of collectKeys(categories)) { if (key in parsed) data[key] = parsed[key]; } + if (categories.includes('settings') && data.inputCharacterLimit === undefined) data.inputCharacterLimit = migratedInputLimit(data); + validateSettings(data); + if (data.aiCache !== undefined) data.aiCache = Object.fromEntries(Object.entries(data.aiCache || {}).filter(([key, entry]) => validCacheEntry(key, entry))); await storage.local.set(data); } diff --git a/options/options.html b/options/options.html index cd3d581..8091df5 100644 --- a/options/options.html +++ b/options/options.html @@ -64,11 +64,25 @@ + +

Settings

+
+ +
+
+
+ + +

Match when probability is at least this value. Rules may override it. Evaluate thresholds on your own email examples.

+
@@ -186,7 +200,12 @@
- + + +

Applied with aggressive reduction. Characters are not tokens; leave room for instructions within your model's context limit.

+
+
+
diff --git a/options/options.js b/options/options.js index 2a8d3c4..d1f71a2 100644 --- a/options/options.js +++ b/options/options.js @@ -5,7 +5,9 @@ document.addEventListener('DOMContentLoaded', async () => { const dataTransfer = await import(browser.runtime.getURL('options/dataTransfer.js')); const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js')); const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js')); + const { validateSettings, migratedInputLimit } = await import(browser.runtime.getURL('modules/classificationConfig.js')); const defaults = await storage.local.get([ + 'apiType', 'decisionThreshold', 'inputCharacterLimit', 'endpoint', 'customSystemPrompt', 'model', @@ -86,6 +88,23 @@ document.addEventListener('DOMContentLoaded', async () => { markDirty(); await applyTheme(themeSelect.value); }); + const apiTypeInput = document.getElementById('api-type'); + apiTypeInput.value = defaults.apiType || 'chat-completions'; + const thresholdInput = document.getElementById('decision-threshold'); + thresholdInput.value = (defaults.decisionThreshold ?? 0.5) * 100; + const inputLimit = document.getElementById('input-character-limit'); + inputLimit.value = migratedInputLimit(defaults); + const settingsError = document.getElementById('settings-error'); + function updateApiControls() { + const decision = apiTypeInput.value === 'systemone'; + document.querySelectorAll('.systemone-only').forEach(el => el.classList.toggle('is-hidden', !decision)); + for (const id of ['system-instructions', 'openai-organization', 'openai-project', ...Object.keys(DEFAULT_AI_PARAMS)]) { + document.getElementById(id)?.closest('.field')?.classList.toggle('is-hidden', decision); + } + document.getElementById('reset-system').classList.toggle('is-hidden', decision); + updateEndpointPreview(); + } + apiTypeInput.addEventListener('change', updateApiControls); const endpointInput = document.getElementById('endpoint'); const endpointPreview = document.getElementById('endpoint-preview'); const fallbackEndpoint = 'http://127.0.0.1:5000'; @@ -94,13 +113,13 @@ document.addEventListener('DOMContentLoaded', async () => { endpointInput.value = endpointBase; function updateEndpointPreview() { - const resolved = AiClassifier.buildEndpointUrl(endpointInput.value); + const resolved = AiClassifier.buildEndpointUrl(endpointInput.value, apiTypeInput.value); endpointPreview.textContent = resolved ? `Resolved endpoint: ${resolved}` : 'Resolved endpoint: (invalid)'; } endpointInput.addEventListener('input', updateEndpointPreview); - updateEndpointPreview(); + updateApiControls(); const modelSelect = document.getElementById('model-select'); const refreshModelsBtn = document.getElementById('refresh-models'); @@ -148,11 +167,11 @@ document.addEventListener('DOMContentLoaded', async () => { headers.Authorization = `Bearer ${apiKey}`; } const organization = openaiOrgInput?.value.trim(); - if (organization) { + if (organization && apiTypeInput.value === 'chat-completions') { headers["OpenAI-Organization"] = organization; } const project = openaiProjectInput?.value.trim(); - if (project) { + if (project && apiTypeInput.value === 'chat-completions') { headers["OpenAI-Project"] = project; } return headers; @@ -467,7 +486,7 @@ document.addEventListener('DOMContentLoaded', async () => { const perDayEl = document.getElementById('per-day'); let timingLogged = false; ruleCountEl.textContent = (defaults.aiRules || []).length; - cacheCountEl.textContent = defaults.aiCache ? Object.keys(defaults.aiCache).length : 0; + cacheCountEl.textContent = '…'; function createActionRow(action = {type: 'tag'}) { const row = document.createElement('div'); @@ -861,14 +880,25 @@ document.addEventListener('DOMContentLoaded', async () => { body.appendChild(folderBox); article.appendChild(header); + const thresholdField = document.createElement('label'); + thresholdField.className = 'field systemone-only'; + thresholdField.classList.toggle('is-hidden', apiTypeInput.value !== 'systemone'); + thresholdField.textContent = 'Match probability threshold (%) — blank uses default'; + const override = document.createElement('input'); + override.className = 'input decision-threshold'; + override.type = 'number'; + override.min = '0'; override.max = '100'; override.step = 'any'; + override.value = rule.decisionThreshold === undefined ? '' : rule.decisionThreshold * 100; + thresholdField.appendChild(override); + body.appendChild(thresholdField); article.appendChild(body); rulesContainer.appendChild(article); } } - addRuleBtn.addEventListener('click', () => { - const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => { + function readRules() { + return [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => { const criterion = ruleEl.querySelector('.criterion').value; const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => { const type = row.querySelector('select').value; @@ -890,8 +920,11 @@ document.addEventListener('DOMContentLoaded', async () => { if (type === 'flag') { return { type, flagged: row.querySelector('.flag-select').value === 'true' }; } - if (type === 'delete' || type === 'archive') { - return { type }; + if (type === 'forward') { + return { type, address: row.querySelector('.forward-input').value.trim() }; + } + if (type === 'reply') { + return { type, replyType: row.querySelector('.reply-select').value }; } return { type }; }); @@ -903,6 +936,8 @@ document.addEventListener('DOMContentLoaded', async () => { const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value); const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value); const rule = { criterion, actions, enabled }; + const override = ruleEl.querySelector('.decision-threshold').value; + if (override.trim() !== '') rule.decisionThreshold = Number(override) / 100; if (unreadOnly) rule.unreadOnly = true; if (stopProcessing) rule.stopProcessing = true; if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays; @@ -911,6 +946,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (folders.length) rule.folders = folders; return rule; }); + } + addRuleBtn.addEventListener('click', () => { + const data = readRules(); data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] }); renderRules(data); }); @@ -927,6 +965,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo }); const rule = { criterion: r.criterion, actions }; + if (r.decisionThreshold !== undefined) rule.decisionThreshold = r.decisionThreshold; if (r.stopProcessing) rule.stopProcessing = true; if (r.unreadOnly) rule.unreadOnly = true; if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays; @@ -996,8 +1035,8 @@ document.addEventListener('DOMContentLoaded', async () => { } try { - const { aiCache } = await storage.local.get('aiCache'); - cacheCountEl.textContent = aiCache ? Object.keys(aiCache).length : 0; + const { count } = await browser.runtime.sendMessage({ type: 'sortana:getCacheSize' }); + cacheCountEl.textContent = count; } catch { cacheCountEl.textContent = '?'; } @@ -1044,14 +1083,22 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('import-data').addEventListener('click', () => importInput.click()); importInput.addEventListener('change', async () => { if (importInput.files.length) { - await dataTransfer.importData(importInput.files[0], selectedCategories()); - location.reload(); + try { + await dataTransfer.importData(importInput.files[0], selectedCategories()); + location.reload(); + } catch (error) { + window.alert(`Import failed: ${error.message}`); + } } }); initialized = true; document.getElementById('save').addEventListener('click', async () => { + settingsError.textContent = ''; + const apiType = apiTypeInput.value; + const decisionThreshold = thresholdInput.value.trim() === '' ? NaN : Number(thresholdInput.value) / 100; + const inputCharacterLimit = Number(inputLimit.value); const endpoint = endpointInput.value.trim(); const model = modelSelect?.value || ''; const apiKey = apiKeyInput?.value.trim() || ''; @@ -1068,63 +1115,25 @@ document.addEventListener('DOMContentLoaded', async () => { } const debugLogging = debugToggle.checked; const htmlToMarkdown = htmlToggle.checked; - const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => { - const criterion = ruleEl.querySelector('.criterion').value; - const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => { - const type = row.querySelector('select').value; - if (type === 'tag') { - return { type, tagKey: row.querySelector('.tag-select').value }; - } - if (type === 'move') { - return { type, folder: row.querySelector('.folder-select').value }; - } - if (type === 'copy') { - return { type, copyTarget: row.querySelector('.folder-select').value }; - } - if (type === 'junk') { - return { type, junk: row.querySelector('.junk-select').value === 'true' }; - } - if (type === 'read') { - return { type, read: row.querySelector('.read-select').value === 'true' }; - } - if (type === 'flag') { - return { type, flagged: row.querySelector('.flag-select').value === 'true' }; - } - if (type === 'forward') { - return { type, address: row.querySelector('.forward-input').value.trim() }; - } - if (type === 'reply') { - return { type, replyType: row.querySelector('.reply-select').value }; - } - return { type }; - }); - const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; - const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; - const enabled = ruleEl.dataset.enabled !== 'false'; - const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value); - const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value); - const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value); - const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value); - const rule = { criterion, actions, enabled }; - if (unreadOnly) rule.unreadOnly = true; - if (stopProcessing) rule.stopProcessing = true; - if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays; - if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays; - if (accounts.length) rule.accounts = accounts; - if (folders.length) rule.folders = folders; - return rule; - }).filter(r => r.criterion); + const invalidThreshold = [...document.querySelectorAll('.decision-threshold'), thresholdInput, inputLimit].find(el => !el.checkValidity()); + if (invalidThreshold) { + settingsError.textContent = 'Enter thresholds from 0% to 100% and a positive whole-number character limit.'; + return; + } + const rules = readRules().filter(r => r.criterion); const stripUrlParams = stripUrlToggle.checked; const altTextImages = altTextToggle.checked; const collapseWhitespace = collapseWhitespaceToggle.checked; const tokenReduction = tokenReductionToggle.checked; const showDebugTab = debugTabToggle.checked; const theme = themeSelect.value; - await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); + try { validateSettings({ apiType, decisionThreshold, inputCharacterLimit, aiRules: rules }); } + catch (error) { settingsError.textContent = error.message; return; } + await storage.local.set({ apiType, decisionThreshold, inputCharacterLimit, endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); await storage.local.remove(['templateName', 'customTemplate']); await applyTheme(theme); try { - await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); + await AiClassifier.setConfig({ apiType, decisionThreshold, inputCharacterLimit, endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); logger.setDebug(debugLogging); } catch (e) { logger.aiLog('[options] failed to apply config', {level: 'error'}, e); diff --git a/tests/cacheRestore.test.mjs b/tests/cacheRestore.test.mjs new file mode 100644 index 0000000..f5ca74d --- /dev/null +++ b/tests/cacheRestore.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { readFile } from 'node:fs/promises'; +import { CONFIG_DEFAULTS } from '../modules/classificationConfig.js'; + +const persisted = {}; +const listeners = []; +const storage = { + local: { + async get(keys) { + return Object.fromEntries((Array.isArray(keys) ? keys : [keys]) + .filter(key => key in persisted).map(key => [key, structuredClone(persisted[key])])); + }, + async set(values) { + const changes = {}; + for (const [key, value] of Object.entries(values)) { + changes[key] = { oldValue: persisted[key], newValue: structuredClone(value) }; + persisted[key] = structuredClone(value); + } + for (const listener of listeners) await listener(changes, 'local'); + }, + async remove(keys) { + for (const key of Array.isArray(keys) ? keys : [keys]) delete persisted[key]; + } + }, + onChanged: { addListener(listener) { listeners.push(listener); } } +}; +const messenger = { storage, messages: { async get(id) { + // Numeric Thunderbird IDs may change after reinstall; RFC Message-ID does not. + return { subject: 'Invoice', headerMessageId: 'persistent-message@example.com' }; +} } }; +globalThis.messenger = messenger; +const classifier = await import('../modules/AiClassifier.js'); +const transfer = await import('../options/dataTransfer.js'); +const source = await readFile(new URL('../background.js', import.meta.url), 'utf8'); +let onMessage; +const context = vm.createContext({ + storage, messenger, structuredClone, AiClassifier: classifier, aiRules: [], + logger: { aiLog() {} }, logGetTiming: false, + htmlToMarkdown: false, stripUrlParams: false, altTextImages: false, + collapseWhitespace: false, tokenReduction: false, showDebugTab: false, + browser: { runtime: { onMessage: { addListener(listener) { onMessage = listener; } } } } +}); +// Exercise the actual background storage and Details handlers, not a manual cache reload. +const normalizeStart = source.indexOf('function normalizeRules('); +vm.runInContext(source.slice(normalizeStart, source.indexOf('function iconPaths(', normalizeStart)), context); +const changesStart = source.indexOf('storage.onChanged.addListener('); +vm.runInContext(source.slice(changesStart, source.indexOf('\n\n if (browser.theme', changesStart)), context); +const messageStart = source.indexOf('browser.runtime.onMessage.addListener('); +vm.runInContext(source.slice(messageStart, source.indexOf('\n\n // Automatically classify', messageStart)), context); + +test('export/reinstall/import restores Details immediately with changed Thunderbird IDs', async () => { + const settings = { ...CONFIG_DEFAULTS, apiType: 'systemone', endpoint: 'http://decision-server:8080', tokenReduction: true, inputCharacterLimit: 5000 }; + const rules = [{ criterion: 'invoice', decisionThreshold: 0.8, actions: [] }]; + await storage.local.set({ ...settings, aiRules: rules }); + globalThis.fetch = async () => ({ ok: true, json: async () => ({ answers: { match: { type: 'noul', noul: 0.9 } } }) }); + const key = await classifier.buildCacheKey(1, 'invoice'); + await classifier.classify('Invoice attached', 'invoice', { cacheKey: key, threshold: 0.8 }); + let exported; + const originalDocument = globalThis.document; + const createURL = URL.createObjectURL; + const revokeURL = URL.revokeObjectURL; + URL.createObjectURL = blob => { exported = blob; return 'blob:export'; }; + URL.revokeObjectURL = () => {}; + globalThis.document = { body: { appendChild() {} }, createElement() { return { click() {}, remove() {} }; } }; + try { await transfer.exportData(['settings', 'rules', 'cache']); } + finally { globalThis.document = originalDocument; URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; } + await classifier.clearCache(); + for (const key of Object.keys(persisted)) delete persisted[key]; + classifier.setConfig(CONFIG_DEFAULTS); + context.aiRules = []; + assert.equal((await onMessage({ type: 'sortana:getDetails', id: 99 })).results.length, 0); + await transfer.importData(exported, ['settings', 'rules', 'cache']); + assert.equal((await onMessage({ type: 'sortana:getCacheSize' })).count, 1); + const details = await onMessage({ type: 'sortana:getDetails', id: 99 }); + assert.equal(details.results.length, 1); + assert.equal(details.results[0].criterion, 'invoice'); + assert.equal(details.results[0].probability, 0.9); + assert.equal(details.results[0].threshold, 0.8); + assert.equal(details.results[0].matched, true); + globalThis.fetch = () => { throw new Error('Restored cache must avoid inference'); }; + assert.equal((await classifier.classify('Invoice attached', 'invoice', { cacheKey: await classifier.buildCacheKey(99, 'invoice') })).matched, true); +}); diff --git a/tests/classification.test.mjs b/tests/classification.test.mjs new file mode 100644 index 0000000..daf6ea6 --- /dev/null +++ b/tests/classification.test.mjs @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as chat from '../modules/backends/chatCompletions.js'; +import * as decision from '../modules/backends/systemOne.js'; +import { CONFIG_DEFAULTS, migratedInputLimit, validateSettings } from '../modules/classificationConfig.js'; + +const data = { aiCache: { legacy: { matched: true, reason: 'old' } }, aiReasonCache: { legacy: 'old' } }; +globalThis.messenger = { storage: { local: { + async get(keys) { return Object.fromEntries((Array.isArray(keys) ? keys : [keys]).map(key => [key, data[key]])); }, + async set(values) { Object.assign(data, structuredClone(values)); }, + async remove(keys) { for (const key of Array.isArray(keys) ? keys : [keys]) delete data[key]; } +} }, messages: { async get(id) { return { headerMessageId: `message-${id}` }; } } }; +const classifier = await import('../modules/AiClassifier.js'); +const transfer = await import('../options/dataTransfer.js'); +const answer = probability => ({ model: 'served-model', answers: { match: { type: 'noul', noul: probability } } }); +let calls = []; +function serve(body, status = 200) { + globalThis.fetch = async (url, request) => { calls.push({ url, ...request }); return { ok: status === 200, status, json: async () => body }; }; +} + +test('adapters preserve chat prompts and isolate SystemOne payloads', () => { + const config = { ...CONFIG_DEFAULTS, model: 'chosen', customSystemPrompt: 'Custom guidance' }; + const payload = chat.buildRequest('email', 'invoice', config); + assert.match(payload.messages[0].content, /Custom guidance/); + assert.match(payload.messages[1].content, /Classification Criterion: invoice/); + assert.equal(payload.max_tokens, 4096); + assert.deepEqual(decision.buildRequest('email', 'invoice', config), { + state: 'email', model: 'chosen', questions: { match: { type: 'noul', instructions: 'Does this email satisfy the following criterion: invoice' } } + }); + assert.equal('model' in decision.buildRequest('email', 'invoice', CONFIG_DEFAULTS), false); + assert.equal('model' in chat.buildRequest('email', 'invoice', CONFIG_DEFAULTS), false); +}); + +test('parsers validate evidence including zero, one, and malformed answers', () => { + for (const probability of [0, 0.5, 1]) assert.equal(decision.parseResponse(answer(probability)).probability, probability); + for (const probability of [null, '0.5', -0.1, 1.1, Infinity, NaN]) assert.throws(() => decision.parseResponse(answer(probability))); + for (const raw of [null, {}, { answers: { other: { type: 'noul', noul: 0.5 } } }, { answers: { match: { type: 'choice', noul: 0.5 } } }]) assert.throws(() => decision.parseResponse(raw)); + assert.deepEqual(chat.parseResponse({ choices: [{ message: { content: 'text {"match":false} then {"matched":true,"reason":"brace } inside"}' } }] }), { matched: true, reason: 'brace } inside', probability: null }); + for (const content of ['', '{broken}', '{"match":"true"}']) assert.throws(() => chat.parseResponse({ choices: [{ message: { content } }] })); + assert.throws(() => chat.parseResponse(null)); +}); + +test('URLs preserve proxy prefixes and normalize API suffixes', () => { + for (const suffix of ['', '/', '/v1', '/v1/', '/v1/models', '/v1/chat/completions/', '/v1/systemone']) { + assert.equal(classifier.buildEndpointUrl(`http://localhost:8080/proxy${suffix}`, 'systemone'), 'http://localhost:8080/proxy/v1/systemone'); + assert.equal(classifier.buildModelsUrl(`http://localhost:8080/proxy${suffix}`), 'http://localhost:8080/proxy/v1/models'); + } + assert.equal(classifier.buildEndpointUrl('example.com'), 'https://example.com/v1/chat/completions'); + assert.equal(classifier.buildEndpointUrl(''), ''); +}); + +test('settings validation and independent character-limit migration', () => { + assert.equal(migratedInputLimit({}), 3686); + assert.equal(migratedInputLimit({ aiParams: { max_tokens: 1000 } }), 900); + assert.equal(migratedInputLimit({ inputCharacterLimit: 500, aiParams: { max_tokens: 1000 } }), 500); + validateSettings({ decisionThreshold: 0, aiRules: [{ decisionThreshold: 1 }] }); + for (const value of [-1, 2, null, '0.5']) assert.throws(() => validateSettings({ aiRules: [{ decisionThreshold: value }] })); + for (const value of [0, 1.2, NaN]) assert.throws(() => validateSettings({ inputCharacterLimit: value })); +}); + +test('cache migration, inference reuse, fingerprints, clearing and import', async () => { + await classifier.init(); + assert.equal(await classifier.getCacheSize(), 0); + assert.equal(data.aiReasonCache, undefined); + assert.deepEqual(data.aiCache, {}); + classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone', endpoint: 'http://localhost:8080', apiKey: 'secret', openaiOrganization: 'org', openaiProject: 'project' }); + const snapshot = classifier.getConfig(); + const key = await classifier.buildCacheKey(1, 'invoice', snapshot); + serve(answer(0.75)); calls = []; + const first = await classifier.classify('email', 'invoice', { snapshot, cacheKey: key, threshold: 0.75 }); + assert.equal(first.matched, true); + assert.equal(first.model, 'served-model'); + assert.deepEqual(calls[0].headers, { 'Content-Type': 'application/json', Authorization: 'Bearer secret' }); + const second = await classifier.classify('email', 'invoice', { snapshot, cacheKey: key, threshold: 0.8 }); + assert.equal(second.matched, false); + assert.equal(second.probability, 0.75); + assert.equal(calls.length, 1); + classifier.replaceCache(data.aiCache); + assert.equal(classifier.getCachedDetails(key, 0.75).matched, true); + assert.equal(await classifier.buildCacheKey(1, 'invoice', { ...snapshot, decisionThreshold: 0.9, apiKey: 'new secret' }), key); + for (const change of [{ apiType: 'chat-completions' }, { endpoint: 'http://other' }, { model: 'other' }, { tokenReduction: true }, { inputCharacterLimit: 500 }]) { + assert.notEqual(await classifier.buildCacheKey(1, 'invoice', { ...snapshot, ...change }), key); + } + const chatSnapshot = { ...snapshot, apiType: 'chat-completions' }; + const chatKey = await classifier.buildCacheKey(1, 'invoice', chatSnapshot); + assert.notEqual(await classifier.buildCacheKey(1, 'invoice', { ...chatSnapshot, customSystemPrompt: 'other' }), chatKey); + assert.notEqual(await classifier.buildCacheKey(1, 'invoice', { ...chatSnapshot, aiParams: { ...snapshot.aiParams, temperature: 1 } }), chatKey); + serve({ choices: [{ message: { content: '{"match":false,"reason":"not invoice"}' } }] }); + await classifier.classify('email', 'invoice', { cacheKey: chatKey, snapshot: chatSnapshot }); + assert.equal(calls.at(-1).headers['OpenAI-Organization'], 'org'); + assert.equal(await classifier.getCacheSize(), 2); + const exported = structuredClone(data.aiCache); + await classifier.clearCacheForMessage(1); + assert.equal(await classifier.getCacheSize(), 0); + await transfer.importData({ text: async () => JSON.stringify({ aiCache: { ...exported, legacy: true } }) }, ['cache']); + classifier.replaceCache(data.aiCache); // background storage listener consumes imported replacement + assert.equal(await classifier.getCacheSize(), 2); + await assert.rejects(transfer.importData({ text: async () => JSON.stringify({ decisionThreshold: 2 }) }, ['settings'])); + await assert.rejects(transfer.importData({ text: async () => JSON.stringify({ aiRules: [{ decisionThreshold: -1 }] }) }, ['rules'])); + await classifier.clearCache(); +}); + +test('failures remain uncached and snapshots survive configuration changes', async () => { + classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone' }); + const snapshot = classifier.getConfig(); + const key = await classifier.buildCacheKey(2, 'invoice', snapshot); + for (const raw of [null, answer('0.9'), {}]) { + serve(raw); + await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot })); + assert.equal(classifier.getCachedDetails(key), null); + } + serve({}, 503); + await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }), /503/); + globalThis.fetch = async () => { throw new Error('network'); }; + await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }), /network/); + globalThis.fetch = async () => ({ ok: true, json: async () => { throw new SyntaxError('bad JSON'); } }); + await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }), /bad JSON/); + classifier.setConfig({ apiType: 'chat-completions', endpoint: 'http://changed' }); + serve(answer(1)); calls = []; + const result = await classifier.classify('email', 'invoice', { cacheKey: key, snapshot }); + assert.equal(result.backend, 'systemone'); + assert.equal(calls[0].url, 'http://127.0.0.1:5000/v1/systemone'); + assert.equal('Authorization' in calls[0].headers, false); + await classifier.clearCache(); +}); + +test('clearing or replacing cache during inference prevents stale repopulation', async () => { + classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone' }); + const snapshot = classifier.getConfig(); + const key = await classifier.buildCacheKey(3, 'invoice', snapshot); + for (const replace of [() => classifier.clearCache(), () => classifier.replaceCache({})]) { + let release, started; + const ready = new Promise(resolve => { started = resolve; }); + globalThis.fetch = async () => { + started(); + await new Promise(resolve => { release = resolve; }); + return { ok: true, json: async () => answer(0.9) }; + }; + const pending = classifier.classify('email', 'invoice', { cacheKey: key, snapshot }); + await ready; + await replace(); + release(); + assert.equal((await pending).matched, true); + assert.equal(classifier.getCachedDetails(key), null); + } +}); + +test('settings and rule imports retain thresholds and reject invalid caches', async () => { + const settings = { apiType: 'systemone', decisionThreshold: 0.75, inputCharacterLimit: 5000, tokenReduction: true }; + const rules = [{ criterion: 'invoice', decisionThreshold: 0, actions: [{ type: 'flag', flagged: true }] }, { criterion: 'urgent', actions: [] }]; + await transfer.importData({ text: async () => JSON.stringify({ ...settings, aiRules: rules }) }, ['settings', 'rules']); + for (const [key, value] of Object.entries(settings)) assert.equal(data[key], value); + assert.deepEqual(data.aiRules, rules); + const key = await classifier.buildCacheKey(4, 'invoice'); + assert.equal(classifier.validCacheEntry(key, { version: 2, backend: 'systemone', probability: '0.8', reason: null, messageHash: key.split(':')[1], model: '' }), false); +}); + +test('SystemOne 404 reports endpoint, server message, and compatibility guidance', async () => { + classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone', endpoint: 'http://seinei.wageshouse:5003' }); + serve({ error: { message: 'File Not Found' } }, 404); + await assert.rejects(classifier.classify('synthetic email', 'invoice'), error => { + assert.match(error.message, /HTTP 404 at http:\/\/seinei\.wageshouse:5003\/v1\/systemone/); + assert.match(error.message, /Server response: File Not Found/); + assert.match(error.message, /running server build supports/); + return true; + }); +}); diff --git a/tests/ruleExecution.test.mjs b/tests/ruleExecution.test.mjs new file mode 100644 index 0000000..153a050 --- /dev/null +++ b/tests/ruleExecution.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { readFile } from 'node:fs/promises'; + +// Run the actual background rule/queue functions with Thunderbird services mocked. +const source = await readFile(new URL('../background.js', import.meta.url), 'utf8'); +const functions = source.slice(source.indexOf('async function processMessage('), source.indexOf('async function clearCacheForMessages(')); +function runner({ fail = false, changeSettings = false } = {}) { + const actions = [], evaluated = [], errors = []; + const rules = [ + { criterion: 'first', decisionThreshold: 0.8, actions: [{ type: 'flag', flagged: true }], stopProcessing: true }, + { criterion: 'second', actions: [{ type: 'read', read: true }] } + ]; + let currentConfig = { apiType: 'systemone', inputCharacterLimit: 5, decisionThreshold: 0.5 }; + const context = vm.createContext({ + structuredClone, Date, Error, + aiRules: rules, queue: Promise.resolve(), queuedCount: 0, processing: false, + currentStart: 0, tokenReduction: true, showDebugTab: false, timingStats: {}, + updateActionIcon() {}, updateTimingStats() {}, showTransientIcon() {}, + buildEmailText() { return 'email contents'; }, ICONS: { circle: 'circle' }, + logger: { aiLog() {} }, recordError(...args) { errors.push(args); }, + ERROR_NOTIFICATION_ID: 'error', + storage: { local: { async set() {} } }, + messenger: { + messages: { + async getFull() { return {}; }, + async get() { + if (changeSettings) { + rules[0].decisionThreshold = 0.1; + currentConfig = { apiType: 'chat-completions', decisionThreshold: 0.1 }; + } + return { tags: [], folder: { accountId: 'account' } }; + }, + async update(id, action) { actions.push({ id, ...action }); } + }, identities: { async list() { return []; } } + }, + browser: { runtime: { getURL: x => x }, notifications: { async create() {} } }, + AiClassifier: { + getConfig() { return structuredClone(currentConfig); }, + async buildCacheKey(id, criterion) { return `${id}:${criterion}`; }, + async classify(text, criterion, options) { + evaluated.push({ text, criterion, ...options }); + if (fail && options.cacheKey.startsWith('1:')) throw new Error('inference failed'); + return { matched: true }; + } + } + }); + vm.runInContext(functions, context); + return { context, actions, evaluated, errors }; +} + +test('failure stops remaining rules for one email, next queued email still acts', async () => { + const { context, actions, evaluated, errors } = runner({ fail: true }); + await vm.runInContext('applyAiRules([1, 2])', context); + assert.deepEqual(evaluated.map(x => x.cacheKey), ['1:first', '2:first']); + assert.deepEqual(actions, [{ id: 2, flagged: true }]); + assert.equal(errors.length, 1); + assert.equal(context.processing, false); + assert.equal(context.queuedCount, 0); +}); + +test('settings and rules remain fixed during an email; reduction uses characters', async () => { + const { context, evaluated, actions } = runner({ changeSettings: true }); + await vm.runInContext('applyAiRules([1])', context); + assert.equal(evaluated[0].text, 'email'); + assert.equal(evaluated[0].threshold, 0.8); + assert.equal(evaluated[0].snapshot.apiType, 'systemone'); + assert.equal(evaluated.length, 1); // stop after first match + assert.equal(actions.length, 1); +}); + +test('inherited thresholds and eligibility preserve ordered rule processing', async () => { + const { context, evaluated, actions } = runner(); + context.aiRules[0].enabled = false; + await vm.runInContext('applyAiRules([1])', context); + assert.equal(evaluated[0].criterion, 'second'); + assert.equal(evaluated[0].threshold, 0.5); + assert.deepEqual(actions, [{ id: 1, read: true }]); +});