From dee3cf126e916d0993508db5831b93000f8b3d61 Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Wed, 16 Sep 2026 17:03:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(chat):=20persona=20models=20speak=20only=20?= =?UTF-8?q?as=20themselves=20=E2=80=94=20no=20CODEC=20prompt,=20no=20scaff?= =?UTF-8?q?old?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fréd / M Corpus / Mwen (config extra_models[].system_prompt*) are personas the user talks TO. The server built their persona prompt correctly, then glued the frontend's generic "You are CODEC Deep Chat — a J.A.R.V.I.S.-class AI…" system message onto it and (Think on) appended the FINAL-ANSWER reasoning scaffold. On a 4B that produced two conflicting identities: it answered in the generic voice, confabulated an attachment it never read, and emitted a stray tag the UI stripper (### FINAL ANSWER only) never removed. For a persona model: REPLACE the client system message with the persona prompt instead of concatenating, and skip _REASON_SCAFFOLD. Also strip tag variants in the UI as defense-in-depth. Operator persona (everyday model) unchanged — it still gets the CODEC prompt + scaffold + skills. Co-Authored-By: Claude Opus 4.8 --- codec_chat.html | 4 ++-- routes/chat.py | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codec_chat.html b/codec_chat.html index 746ff6c..cc53e37 100644 --- a/codec_chat.html +++ b/codec_chat.html @@ -888,7 +888,7 @@

CODEC

// (also stripping an UNCLOSED tail — a cut/degenerate stream // must never dump raw reasoning into the bubble) > honest glitch notice // with the reasoning kept in the reveal panel. - var answer=(pf.answer&&pf.answer.trim())||raw.replace(/[\s\S]*?<\/thinking>/i,'').replace(/[\s\S]*$/i,'').replace(/###\s*FINAL ANSWER:?/i,'').trim(); + var answer=(pf.answer&&pf.answer.trim())||raw.replace(/[\s\S]*?<\/thinking>/i,'').replace(/[\s\S]*$/i,'').replace(/###\s*FINAL ANSWER:?/i,'').replace(/<\/?final\s*answer>/gi,'').trim(); if(!answer&&pf.think&&pf.think.trim()){answer='*My reasoning ran on without reaching a final answer — a local-model glitch, not a real reply. The train of thought is below; please ask again.*'} if(answer){ div.remove();var md=addMessage('assistant',answer); @@ -1703,7 +1703,7 @@

CODEC

var pf2=parseScaffold(raw); // Same fallback order as the primary handler: never dump an unclosed // tail into the bubble; keep reasoning in the reveal panel. - var answer2=(pf2.answer&&pf2.answer.trim())||raw.replace(/[\s\S]*?<\/thinking>/i,'').replace(/[\s\S]*$/i,'').replace(/###\s*FINAL ANSWER:?/i,'').trim(); + var answer2=(pf2.answer&&pf2.answer.trim())||raw.replace(/[\s\S]*?<\/thinking>/i,'').replace(/[\s\S]*$/i,'').replace(/###\s*FINAL ANSWER:?/i,'').replace(/<\/?final\s*answer>/gi,'').trim(); if(!answer2&&pf2.think&&pf2.think.trim()){answer2='*My reasoning ran on without reaching a final answer — a local-model glitch, not a real reply. The train of thought is below; please ask again.*'} if(answer2){div.remove();var md2=addMessage('assistant',answer2);if(thinkingEnabled&&pf2.think&&pf2.think.trim()&&md2){var fp2=makeToT(md2,'Train of thought',false);totSet(fp2,pf2.think.trim())}chatHist.push({role:'assistant',content:answer2});saveMessages([{role:'assistant',content:answer2}])} else{bubble.innerHTML='No response'} diff --git a/routes/chat.py b/routes/chat.py index 9544429..91db249 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -1110,16 +1110,37 @@ async def _skill_stream(): f"Treat it as the current working directory for this conversation." ) + # A persona model (Fréd, M Corpus, Mwen — config extra_models[].system_prompt*) + # OWNS the turn: it must speak only as itself. The frontend still ships its + # generic "You are CODEC Deep Chat — a J.A.R.V.I.S.-class AI…" system + # message; gluing that onto the persona gave the 4B two conflicting + # identities (it answered in the generic voice and confabulated an + # attachment it never read). So for a persona, REPLACE the client system + # message with the persona prompt instead of concatenating. + _persona_active = False + try: + import codec_models as _cmp + _persona_active = bool(_cmp.model_extras().get("system_prompt")) + except Exception as _e: + log.debug("persona check failed: %s", _e) + # Prepend system message (or replace existing one) if messages and messages[0].get("role") == "system": - messages[0]["content"] = sys_prompt + "\n\n" + messages[0]["content"] + messages[0]["content"] = ( + sys_prompt if _persona_active + else sys_prompt + "\n\n" + messages[0]["content"] + ) else: messages.insert(0, {"role": "system", "content": sys_prompt}) # Think mode: append the reasoning scaffold LAST so it wins over the base # prompt's "answer directly"/emoji rules. Frontend sends reason_scaffold # = Think-toggle state; it parses the resulting /### FINAL ANSWER. - if body.get("reason_scaffold") and messages and messages[0].get("role") == "system": + # Skipped for persona models: a small fine-tune renders the format as its + # own variant the UI stripper misses, and it fights the + # persona's voice. + if (body.get("reason_scaffold") and not _persona_active + and messages and messages[0].get("role") == "system"): messages[0]["content"] += _REASON_SCAFFOLD stream_mode = body.get("stream", False)