feat(brain): make OUTPUT_LANGUAGE lock robust on small models

Harden the reply-language lock so qwen2.5:3b reliably stays in the locked
language instead of leaking the query language back in:

- reply_language_directive(): single resolver with clear precedence —
  explicit OUTPUT_LANGUAGE lock wins over the Piper/Chatterbox English-only
  fallback (this deployment's actual TTS is Korean MeloTTS, so the legacy
  English lock was both wrong and contradicting the Korean lock).
- Stronger, override-explicit directive wording, inserted near the FRONT of
  the system prompt so a small model gives it primacy over the persona.
- build_system_prompt(output_language=...): rewrite the persona's "in the
  user's language" clause to the locked language so the persona stops
  fighting the lock.
- docs/llm_contexts.md: document the resolver, precedence, and placement.

Live-verified on the running brain (qwen2.5:3b): Korean voice-style input
and a cold English query both return fully Korean replies with no CJK/Hanja
leak. Tests cover unset/set/agnostic/whitespace + precedence + persona rewrite.
This commit is contained in:
javis-bot
2026-06-12 21:18:47 +09:00
parent 006a32276a
commit 8a2a109d5e
4 changed files with 117 additions and 24 deletions

View File

@@ -9,7 +9,7 @@ import os
from typing import Optional, TYPE_CHECKING
from ..utils.redact import redact
from ..system_prompt import build_system_prompt, output_language_directive
from ..system_prompt import build_system_prompt, reply_language_directive
from ..tools.registry import run_tool_with_retries, generate_tools_description, generate_tools_json_schema, BUILTIN_TOOLS
from ..tools.builtin.stop import STOP_SIGNAL
from ..debug import debug_log
@@ -1425,7 +1425,7 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
action_plan = strip_memory_directives(action_plan)
_assistant_name = str(getattr(cfg, "wake_word", "jarvis") or "jarvis").strip().capitalize()
_persona_prompt = build_system_prompt(_assistant_name)
_persona_prompt = build_system_prompt(_assistant_name, os.environ.get("OUTPUT_LANGUAGE"))
def _build_initial_system_message() -> str:
guidance = [_persona_prompt.strip()]
@@ -1433,22 +1433,19 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
# Add model-size-appropriate prompt components
guidance.extend(prompts.to_list())
# Both current TTS engines (Piper, Chatterbox) only support English.
# Responding in another language would produce garbled audio.
# Remove this constraint when a multilingual TTS engine is added.
tts_engine = getattr(cfg, 'tts_engine', 'piper')
if tts_engine in ('piper', 'chatterbox'):
guidance.append(
"Always respond in English regardless of the language the user speaks in."
)
# Deployment-level output-language lock (OUTPUT_LANGUAGE). When set,
# force every reply into that single language and forbid stray
# characters from other scripts. Empty (default) keeps the
# multilingual behaviour of replying in the user's own language.
_lang_directive = output_language_directive(os.environ.get("OUTPUT_LANGUAGE"))
# Reply-language policy: an explicit OUTPUT_LANGUAGE lock wins, else
# Piper/Chatterbox TTS forces English (English-only voices), else the
# assistant replies in the user's own language. See
# reply_language_directive() for the precedence rationale.
# Placed at the FRONT (after the persona header) so a small model gives
# it primacy over the persona's "use the user's language" lines — a tail
# instruction loses to those when the query itself is in another language.
_lang_directive = reply_language_directive(
os.environ.get("OUTPUT_LANGUAGE"),
getattr(cfg, "tts_engine", "piper"),
)
if _lang_directive:
guidance.append(_lang_directive)
guidance.insert(1, _lang_directive)
if warm_profile_block:
# Pre-query, query-agnostic user context. Lives OUTSIDE the