From 50f5036118281af22dd3773a83a51acbf4010c07 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 14 Sep 2026 15:24:48 +0200 Subject: [PATCH] feat: the assistant reopens the chat you were in, and DeepSeek answers without thinking when told to A refresh landed on a new empty chat with the conversation a click away in the list. The open chat's id is remembered per account and reopened on load; New chat forgets it; a chat deleted elsewhere is quietly gone. DeepSeek models think by default: measured on ds-deepseek-v4.1-flash, a three-sentence clinical answer spent 301 reasoning tokens and 2.9 s before writing and gave the same answer in 0.9 s with thinking off. The assistant's reasoning effort now comes from CLINICAL_ASSISTANT_REASONING_EFFORT ('low' as before; 'none' switches thinking off where a model allows it, sent as DeepSeek's own thinking field through LiteLLM). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP --- .env.example | 1 + public/js/clinicalAssistant.js | 32 ++++++++++++++++++++++++ src/utils/clinicalAnswer.js | 13 +++++++++- src/utils/generationOptions.js | 9 +++++++ test/assistant-open-chat-restore.test.js | 27 ++++++++++++++++++++ test/clinical-generation-options.test.js | 17 +++++++++++++ 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 test/assistant-open-chat-restore.test.js diff --git a/.env.example b/.env.example index 541ae47a..f5aae3b1 100644 --- a/.env.example +++ b/.env.example @@ -263,6 +263,7 @@ DB_PASSWORD=pedscribe_secret_change_me # CLINICAL_ASSISTANT_COLLECTIONS_TTL_MS=600000 # how often the list of search collections is re-read (off the query path); with one collection nothing changes # CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE=30 # paid assistant questions per user per minute, counted in Redis (no Redis: no limit) # CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S=60 # same library search within this window is answered from Redis; 0 disables +# CLINICAL_ASSISTANT_REASONING_EFFORT=low # none switches thinking off on models that allow it (DeepSeek): same answers, ~3x faster; low is the historical profile # CLINICAL_ASSISTANT_MCP_WARMUP= # open a session at boot # CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS= diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index a4bf0758..bff24633 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -125,6 +125,35 @@ import { loadModelSelection(); loadStatus(); loadExamples(); + restoreOpenChat(); + } + + // ── The chat that was open ───────────────────────────────────────────── + // A refresh used to land on a new, empty chat, and the conversation the + // person was in the middle of was a click away in the list — if they knew + // to look. The open chat's id is remembered per account and reopened on + // load; "New chat" forgets it, so a deliberate fresh start stays fresh. + var OPEN_CHAT_KEY = 'ped_assistant_open_chat'; + function rememberOpenChat(id) { + try { + var key = modelStorageKey(OPEN_CHAT_KEY); + if (id) localStorage.setItem(key, String(id)); + else localStorage.removeItem(key); + } catch (e) {} + } + function restoreOpenChat() { + var id = null; + try { id = localStorage.getItem(modelStorageKey(OPEN_CHAT_KEY)); } catch (e) {} + if (!id || !document.getElementById('assistant-messages')) return; + // Quietly: a chat that was deleted from another device is simply gone, + // and the page opens on a new chat as it always did. + return fetchSavedAssistantChat(id) + .then(function (data) { + if (!data.success || !data.chat) throw new Error('gone'); + currentChatId = data.chat.id; + restoreSavedChat(data.chat.payload || {}); + }) + .catch(function () { rememberOpenChat(null); }); } // Delegated document listeners must be registered once. initIfNeeded already @@ -2514,6 +2543,7 @@ import { .then(function(data) { if (!data.success) throw new Error(data.error || 'Autosave failed'); if (data.id) currentChatId = data.id; + rememberOpenChat(currentChatId); autosaveErrorShown = false; var done = document.getElementById('assistant-autosave-state'); if (done) { done.textContent = 'Saved'; done.className = 'assistant-autosave-state saved'; } @@ -2556,6 +2586,7 @@ import { if (activeAssistantRequest) cancelAssistantSearch(); cancelAutosave(); currentChatId = null; + rememberOpenChat(null); autosaveErrorShown = false; messages = []; attachments = []; @@ -2760,6 +2791,7 @@ import { .then(function (data) { if (!data.success) throw new Error(data.error || 'Load failed'); currentChatId = data.chat ? data.chat.id : null; + rememberOpenChat(currentChatId); restoreSavedChat(data.chat && data.chat.payload || {}); }) .catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }) diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js index 560487d2..9d74bca8 100644 --- a/src/utils/clinicalAnswer.js +++ b/src/utils/clinicalAnswer.js @@ -26,9 +26,19 @@ function buildUserPrompt(question, context, history, searchQuery) { return 'Question:\n' + question + searchNote + '\n\nFull conversation context (prior AI output is not evidence; use fresh retrieved sources for factual claims):\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.'; } +// How hard the assistant's model may think before answering. 'low' is the +// profile that has always been sent to the models that accept it; 'none' +// switches thinking off where a model allows that (DeepSeek), which for a +// retrieval-grounded answer proved as accurate and three times faster. +var REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high']; +function assistantReasoningEffort(env) { + var value = String((env || process.env).CLINICAL_ASSISTANT_REASONING_EFFORT || '').trim().toLowerCase(); + return REASONING_EFFORTS.indexOf(value) === -1 ? 'low' : value; +} + function assistantGenerationOptions(overrides) { return Object.assign({ - reasoningEffort: 'low', + reasoningEffort: assistantReasoningEffort(), reasoningFormat: 'hidden' }, overrides || {}); } @@ -118,6 +128,7 @@ function shouldRegenerateTruncatedAnswer(answer, finishReason) { } module.exports = { + assistantReasoningEffort: assistantReasoningEffort, buildSystemPrompt: buildSystemPrompt, stripCitationMarkers: stripCitationMarkers, buildUserPrompt: buildUserPrompt, diff --git a/src/utils/generationOptions.js b/src/utils/generationOptions.js index 5aa0ef3e..ac1a7bff 100644 --- a/src/utils/generationOptions.js +++ b/src/utils/generationOptions.js @@ -9,6 +9,15 @@ function resolveGenerationOptions(options) { } function addReasoningOptions(request, generation) { + // DeepSeek models think by default: measured on ds-deepseek-v4.1-flash, a + // three-sentence clinical answer spent 301 reasoning tokens and 2.9 s before + // writing, and gave the same answer in 0.9 s with thinking switched off. The + // switch is DeepSeek's own field, which LiteLLM passes through; an effort of + // 'none' turns it off and anything else leaves the provider default alone. + if (/deepseek/i.test(String(request.model || ''))) { + if (generation.reasoningEffort === 'none') request.thinking = { type: 'disabled' }; + return request; + } // These OpenAI-compatible fields are rejected by ordinary chat models such as GPT-4.1. // Keep the Clinical Assistant profile, but only send it to the Groq Qwen model that supports both fields. if (request.model !== 'groq-qwen3.8-27b') return request; diff --git a/test/assistant-open-chat-restore.test.js b/test/assistant-open-chat-restore.test.js new file mode 100644 index 00000000..d751cf1f --- /dev/null +++ b/test/assistant-open-chat-restore.test.js @@ -0,0 +1,27 @@ +// A refresh reopens the chat the person was in, per account; "New chat" +// forgets it; a chat deleted elsewhere is simply gone. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8'); + +test('the open chat is remembered when it is loaded or saved, and forgotten on a new chat', () => { + assert.match(src, /var OPEN_CHAT_KEY = 'ped_assistant_open_chat'/); + assert.match(src, /modelStorageKey\(OPEN_CHAT_KEY\)/, 'scoped to the account like the model choice'); + const load = src.slice(src.indexOf('function loadSavedChat'), src.indexOf('function restoreSavedChat')); + assert.match(load, /rememberOpenChat\(currentChatId\)/); + const save = src.slice(src.indexOf('function performAutosave'), src.indexOf('function performAutosave') + 900); + assert.match(save, /if \(data\.id\) currentChatId = data\.id;\s*\n\s*rememberOpenChat\(currentChatId\)/); + const clear = src.slice(src.indexOf('function performClearConversation'), src.indexOf('function cancelAssistantSearch')); + assert.match(clear, /currentChatId = null;\s*\n\s*rememberOpenChat\(null\)/); +}); + +test('the page reopens it on load, quietly, and forgets a chat that no longer exists', () => { + const init = src.slice(src.indexOf('function initIfNeeded'), src.indexOf('function onceOnDocument')); + assert.match(init, /loadExamples\(\);\s*\n\s*restoreOpenChat\(\);/); + const restore = src.slice(src.indexOf('function restoreOpenChat'), src.indexOf('function restoreOpenChat') + 900); + assert.match(restore, /fetchSavedAssistantChat\(id\)/); + assert.match(restore, /\.catch\(function \(\) \{ rememberOpenChat\(null\); \}\)/); + assert.doesNotMatch(restore, /showToast/, 'a missing chat is not an error to announce'); +}); diff --git a/test/clinical-generation-options.test.js b/test/clinical-generation-options.test.js index 800ca1d7..a1abf55e 100644 --- a/test/clinical-generation-options.test.js +++ b/test/clinical-generation-options.test.js @@ -25,3 +25,20 @@ test('reasoning fields are sent only to the confirmed Groq Qwen model', () => { model: 'groq-qwen3.8-27b', reasoning_effort: 'low', reasoning_format: 'hidden' }); }); + +test('DeepSeek thinks unless told not to: only an effort of none sends the switch, as DeepSeek spells it', () => { + assert.deepEqual(addReasoningOptions({ model: 'ds-deepseek-v4.1-flash' }, { reasoningEffort: 'none', reasoningFormat: 'hidden' }), + { model: 'ds-deepseek-v4.1-flash', thinking: { type: 'disabled' } }); + assert.deepEqual(addReasoningOptions({ model: 'openrouter-deepseek-v4.1-flash' }, { reasoningEffort: 'low', reasoningFormat: 'hidden' }), + { model: 'openrouter-deepseek-v4.1-flash' }, 'the historical low profile leaves the provider default alone'); + assert.deepEqual(addReasoningOptions({ model: 'ds-deepseek-r1' }, { reasoningEffort: 'none' }), + { model: 'ds-deepseek-r1', thinking: { type: 'disabled' } }); +}); + +test('the assistant profile takes its effort from the environment, and only a known value', () => { + const { assistantReasoningEffort } = require('../src/utils/clinicalAnswer'); + assert.equal(assistantReasoningEffort({}), 'low'); + assert.equal(assistantReasoningEffort({ CLINICAL_ASSISTANT_REASONING_EFFORT: 'none' }), 'none'); + assert.equal(assistantReasoningEffort({ CLINICAL_ASSISTANT_REASONING_EFFORT: 'NONE ' }), 'none'); + assert.equal(assistantReasoningEffort({ CLINICAL_ASSISTANT_REASONING_EFFORT: 'maximum' }), 'low'); +});