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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
parent
381483dcf3
commit
50f5036118
6 changed files with 98 additions and 1 deletions
|
|
@ -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=
|
||||
|
||||
|
|
|
|||
|
|
@ -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'); })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
27
test/assistant-open-chat-restore.test.js
Normal file
27
test/assistant-open-chat-restore.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue