feat: the assistant reopens the chat you were in, and DeepSeek answers without thinking when told to
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
Daniel 2026-09-14 15:24:48 +02:00
parent 381483dcf3
commit 50f5036118
6 changed files with 98 additions and 1 deletions

View file

@ -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_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_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_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= # open a session at boot
# CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS= # CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS=

View file

@ -125,6 +125,35 @@ import {
loadModelSelection(); loadModelSelection();
loadStatus(); loadStatus();
loadExamples(); 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 // Delegated document listeners must be registered once. initIfNeeded already
@ -2514,6 +2543,7 @@ import {
.then(function(data) { .then(function(data) {
if (!data.success) throw new Error(data.error || 'Autosave failed'); if (!data.success) throw new Error(data.error || 'Autosave failed');
if (data.id) currentChatId = data.id; if (data.id) currentChatId = data.id;
rememberOpenChat(currentChatId);
autosaveErrorShown = false; autosaveErrorShown = false;
var done = document.getElementById('assistant-autosave-state'); var done = document.getElementById('assistant-autosave-state');
if (done) { done.textContent = 'Saved'; done.className = 'assistant-autosave-state saved'; } if (done) { done.textContent = 'Saved'; done.className = 'assistant-autosave-state saved'; }
@ -2556,6 +2586,7 @@ import {
if (activeAssistantRequest) cancelAssistantSearch(); if (activeAssistantRequest) cancelAssistantSearch();
cancelAutosave(); cancelAutosave();
currentChatId = null; currentChatId = null;
rememberOpenChat(null);
autosaveErrorShown = false; autosaveErrorShown = false;
messages = []; messages = [];
attachments = []; attachments = [];
@ -2760,6 +2791,7 @@ import {
.then(function (data) { .then(function (data) {
if (!data.success) throw new Error(data.error || 'Load failed'); if (!data.success) throw new Error(data.error || 'Load failed');
currentChatId = data.chat ? data.chat.id : null; currentChatId = data.chat ? data.chat.id : null;
rememberOpenChat(currentChatId);
restoreSavedChat(data.chat && data.chat.payload || {}); restoreSavedChat(data.chat && data.chat.payload || {});
}) })
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }) .catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); })

View file

@ -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.'; 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) { function assistantGenerationOptions(overrides) {
return Object.assign({ return Object.assign({
reasoningEffort: 'low', reasoningEffort: assistantReasoningEffort(),
reasoningFormat: 'hidden' reasoningFormat: 'hidden'
}, overrides || {}); }, overrides || {});
} }
@ -118,6 +128,7 @@ function shouldRegenerateTruncatedAnswer(answer, finishReason) {
} }
module.exports = { module.exports = {
assistantReasoningEffort: assistantReasoningEffort,
buildSystemPrompt: buildSystemPrompt, buildSystemPrompt: buildSystemPrompt,
stripCitationMarkers: stripCitationMarkers, stripCitationMarkers: stripCitationMarkers,
buildUserPrompt: buildUserPrompt, buildUserPrompt: buildUserPrompt,

View file

@ -9,6 +9,15 @@ function resolveGenerationOptions(options) {
} }
function addReasoningOptions(request, generation) { 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. // 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. // 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; if (request.model !== 'groq-qwen3.8-27b') return request;

View file

@ -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');
});

View file

@ -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' 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');
});