From 006a32276af6507d757423db24bfb63633f66887 Mon Sep 17 00:00:00 2001 From: javis-bot Date: Fri, 12 Jun 2026 21:08:44 +0900 Subject: [PATCH] feat(brain): add OUTPUT_LANGUAGE reply-language lock Add an optional OUTPUT_LANGUAGE env var that forces every reply into a single language. When set, output_language_directive() injects a "respond only in " instruction (also forbidding other scripts) into the chat loop's system prompt, next to the existing TTS English-only lock. Empty (default) keeps the multilingual "reply in the user's language" behaviour, so upstream is unaffected. For the Korean-only deployment this also suppresses the occasional trailing CJK/Hanja fragment qwen2.5:3b leaks on free-form chit-chat. - system_prompt.py: language-agnostic output_language_directive() helper - engine.py: read OUTPUT_LANGUAGE, append directive in _build_initial_system_message - docker-compose.yml + .env.example: document/pass the new var - docs/llm_contexts.md: note the new gating on the main reply context - tests: cover unset/set/agnostic/whitespace cases --- .env.example | 6 ++++++ docker-compose.yml | 2 ++ docs/llm_contexts.md | 2 +- src/jarvis/reply/engine.py | 11 ++++++++++- src/jarvis/system_prompt.py | 25 +++++++++++++++++++++++++ tests/test_system_prompt.py | 34 +++++++++++++++++++++++++++++++++- 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 1a31b05..92c331a 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,12 @@ OLLAMA_CHAT_MODEL=qwen2.5:3b OLLAMA_EMBED_MODEL=nomic-embed-text WHISPER_MODEL=small +# Lock every reply to one language, e.g. OUTPUT_LANGUAGE=Korean. Leave BLANK to +# keep the default behaviour of replying in whatever language the user wrote in. +# A fixed value also suppresses stray characters from other scripts (e.g. the +# occasional trailing CJK fragment small models leak on free-form chat). +OUTPUT_LANGUAGE= + # --------------------------------------------------------------------------- # Docker desktop (VNC) — used only by the container image # --------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 9722317..91bc8c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,8 @@ services: WHISPER_MODEL: ${WHISPER_MODEL:-small} WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda} WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-float16} + # Optional single-language lock for replies (empty = user's own language). + OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-} BRIDGE_URL: http://127.0.0.1:8765 depends_on: - ollama diff --git a/docs/llm_contexts.md b/docs/llm_contexts.md index 8bf036c..33e9e3a 100644 --- a/docs/llm_contexts.md +++ b/docs/llm_contexts.md @@ -12,7 +12,7 @@ Every distinct LLM call in Jarvis, what feeds it, what consumes it, and how it i - **Inputs**: - Redacted user query - Recent dialogue (last 5 minutes), including in-loop tool-call + tool-role messages from prior replies within the active conversation (tool carryover, `DialogueMemory.record_tool_turn` / `get_recent_turns_with_tools` in [src/jarvis/memory/conversation.py](src/jarvis/memory/conversation.py); per-prompt cap via `cfg.tool_carryover_max_turns` / `tool_carryover_per_entry_chars`; storage cap `_tool_turns_max_storage = 16`; cleared on `stop` signal AND on new-conversation entry; UNTRUSTED WEB EXTRACT fence markers preserved on truncation; both `content` and `tool_calls[*].function.arguments` scrubbed on write) - - Unified system prompt from [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) + ASR note + tool-protocol guidance + - Unified system prompt from [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) + ASR note + tool-protocol guidance. When the `OUTPUT_LANGUAGE` env var is set, `output_language_directive()` appends a "respond only in ``" instruction (also forbids other scripts, suppressing trailing CJK leakage on small models); empty (default) keeps the multilingual "reply in the user's language" behaviour. Gated in `_build_initial_system_message()` at [engine.py](src/jarvis/reply/engine.py) alongside the TTS English-only lock. - **Warm profile block** (query-agnostic User + Directives excerpt from the knowledge graph, composed by `build_warm_profile()` / `format_warm_profile_block()` in [src/jarvis/memory/graph_ops.py](src/jarvis/memory/graph_ops.py) at Step 3.5 of `reply()`; no LLM call, pure SQLite read; injected unconditionally so personalisation is the default; result cached in `DialogueMemory._hot_cache` under `DialogueMemory.WARM_PROFILE_CACHE_KEY` for the lifetime of the active conversation. Invalidated on `stop`, on new-conversation entry, AND on User/Directives graph mutations via the listener registered in [src/jarvis/daemon.py](src/jarvis/daemon.py) against `register_graph_mutation_listener` in [src/jarvis/memory/graph.py](src/jarvis/memory/graph.py); World-branch writes are ignored) - Digested memory enrichment (optional, see #4) - Time + location context (re-injected each turn) diff --git a/src/jarvis/reply/engine.py b/src/jarvis/reply/engine.py index 76e892e..ae43625 100644 --- a/src/jarvis/reply/engine.py +++ b/src/jarvis/reply/engine.py @@ -5,10 +5,11 @@ Handles memory enrichment, tool planning and execution. """ from __future__ import annotations +import os from typing import Optional, TYPE_CHECKING from ..utils.redact import redact -from ..system_prompt import build_system_prompt +from ..system_prompt import build_system_prompt, output_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 @@ -1441,6 +1442,14 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any], "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")) + if _lang_directive: + guidance.append(_lang_directive) + if warm_profile_block: # Pre-query, query-agnostic user context. Lives OUTSIDE the # conversation-history section because it isn't a history diff --git a/src/jarvis/system_prompt.py b/src/jarvis/system_prompt.py index d59e37e..b55aa61 100644 --- a/src/jarvis/system_prompt.py +++ b/src/jarvis/system_prompt.py @@ -6,6 +6,8 @@ who renames the wake word (e.g. "Friday") gets a butler with the matching name rather than a persona hardcoded to "Jarvis". """ +from typing import Optional + _SYSTEM_PROMPT_TEMPLATE: str = ( "Persona: you are a British butler named {name} — polite, composed, quietly amused, and " "quietly enjoying yourself. Default voice is dry, witty, and lightly sarcastic: you notice " @@ -87,3 +89,26 @@ def build_system_prompt(assistant_name: str = "Jarvis") -> str: """ name = (assistant_name or "Jarvis").strip() or "Jarvis" return _SYSTEM_PROMPT_TEMPLATE.format(name=name) + + +def output_language_directive(language: Optional[str]) -> Optional[str]: + """Return a 'respond only in ' instruction, or None when unset. + + Deployments that serve a single language set ``OUTPUT_LANGUAGE`` (read by + the reply engine). When it is empty/None the assistant keeps its default + multilingual behaviour of replying in whatever language the user wrote in, + so this returns ``None`` and no directive is injected. + + The instruction is language-agnostic — it names whatever language string it + is given — and forbids mixing in other scripts. That exclusivity also + suppresses the occasional trailing CJK/Hanja fragment some small models + leak on free-form chit-chat. + """ + lang = (language or "").strip() + if not lang: + return None + return ( + f"Always respond only in {lang}, regardless of the language the user " + f"writes in. Do not mix in words, characters, or punctuation from any " + f"other language or script." + ) diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py index ffeb1fa..761a314 100644 --- a/tests/test_system_prompt.py +++ b/tests/test_system_prompt.py @@ -5,7 +5,7 @@ wake word to e.g. "Friday" produces a butler named Friday, not one still hardcoded to Jarvis. """ -from jarvis.system_prompt import build_system_prompt +from jarvis.system_prompt import build_system_prompt, output_language_directive class TestBuildSystemPrompt: @@ -26,3 +26,35 @@ class TestBuildSystemPrompt: assert "named Jarvis" in build_system_prompt("") assert "named Jarvis" in build_system_prompt(" ") assert "named Jarvis" in build_system_prompt(None) # type: ignore[arg-type] + + +class TestOutputLanguageDirective: + """A deployment may lock replies to a single language via OUTPUT_LANGUAGE. + + Unset (the default) must keep the assistant's multilingual behaviour of + replying in the user's own language, so the helper returns None and no + directive is injected. + """ + + def test_unset_returns_none(self): + assert output_language_directive(None) is None + assert output_language_directive("") is None + assert output_language_directive(" ") is None + + def test_set_language_is_named_and_exclusive(self): + directive = output_language_directive("Korean") + assert directive is not None + assert "Korean" in directive + # Must force exclusivity, not merely prefer the language. + assert "only" in directive.lower() + + def test_language_agnostic(self): + # The helper takes any language string — no hardcoded single language. + assert "French" in (output_language_directive("French") or "") + assert "日本語" in (output_language_directive("日本語") or "") + + def test_strips_surrounding_whitespace(self): + directive = output_language_directive(" Korean ") + assert directive is not None + assert "Korean" in directive + assert " Korean" not in directive