From 7870a763147515111a4b3e9dfd9ccff2a6140b7a Mon Sep 17 00:00:00 2001 From: javis-bot Date: Tue, 16 Jun 2026 19:47:26 +0900 Subject: [PATCH] fix: persona uses settings output_language, matching the reply directive The persona prompt was built from the raw OUTPUT_LANGUAGE env while the reply-language directive read the settings-web UI value (config JSON), so changing the language in the settings page was honoured by the directive but ignored by the persona, leaving them contradicting each other. Add _resolve_output_language() as the single source of truth (config wins over env) and feed the same resolved value to both build_system_prompt() and reply_language_directive(). Update docs/llm_contexts.md to match. Co-Authored-By: Claude Opus 4.7 --- docs/llm_contexts.md | 2 +- src/jarvis/reply/engine.py | 27 ++++++++- tests/test_output_language_resolution.py | 74 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/test_output_language_resolution.py diff --git a/docs/llm_contexts.md b/docs/llm_contexts.md index 5707e54..5a274dd 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. Reply language is resolved by `reply_language_directive(OUTPUT_LANGUAGE, cfg.tts_engine)`: an explicit `OUTPUT_LANGUAGE` env lock wins (forces "reply only in ``", also forbidding other scripts so small models stop leaking trailing CJK/Hanja); else a Piper/Chatterbox TTS forces English (English-only voices); else (multilingual TTS, no lock) the assistant replies in the user's own language. The directive is inserted near the FRONT of the guidance list so a small model gives it primacy, and when the lock is set `build_system_prompt()` also rewrites the persona's "in the user's language" clause to the locked language so the persona does not contradict the lock. Gated in `_build_initial_system_message()` at [engine.py](src/jarvis/reply/engine.py). + - Unified system prompt from [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) + ASR note + tool-protocol guidance. Reply language is resolved by `reply_language_directive(lang, cfg.tts_engine)` where `lang = _resolve_output_language()` — the single source of truth that prefers the settings-web UI value (config JSON `output_language`) over the compose `OUTPUT_LANGUAGE` env, so changing the language in the settings page takes effect. An explicit lock wins (forces "reply only in ``", also forbidding other scripts so small models stop leaking trailing CJK/Hanja); else a Piper/Chatterbox TTS forces English (English-only voices); else (multilingual TTS, no lock) the assistant replies in the user's own language. The directive is inserted near the FRONT of the guidance list so a small model gives it primacy, and the SAME resolved `lang` feeds `build_system_prompt()`, which rewrites the persona's "in the user's language" clause to the locked language so the persona cannot contradict the directive (previously the persona read the raw env while the directive read the config value, so a settings-UI change was honoured by one and ignored by the other). Gated in `_build_initial_system_message()` at [engine.py](src/jarvis/reply/engine.py). - **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 2052bae..1be1058 100644 --- a/src/jarvis/reply/engine.py +++ b/src/jarvis/reply/engine.py @@ -840,6 +840,21 @@ def _extra_config(key: str, default=""): return default +def _resolve_output_language() -> Optional[str]: + """Single source of truth for the locked reply language. + + Precedence: the settings-web UI value (config JSON) wins over the compose + ``OUTPUT_LANGUAGE`` env so changing the language in the settings page takes + effect. Returns None/empty when neither is set (multilingual default). + + Both the persona prompt and the reply-language directive MUST read from + here. Resolving the two independently let the persona use the env var while + the directive used the config value, so a settings-UI change rewrote the + reply directive but left the persona contradicting it. + """ + return _extra_config("output_language", "") or os.environ.get("OUTPUT_LANGUAGE") + + _SITE_TOKEN_MAP = { "네이버": "naver", "naver": "naver", "구글": "google", "google": "google", @@ -1681,7 +1696,12 @@ 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, os.environ.get("OUTPUT_LANGUAGE")) + # Resolve once so the persona and the reply-language directive agree: the + # settings-UI value wins over the compose OUTPUT_LANGUAGE env (see + # _resolve_output_language). Building the persona from the raw env var while + # the directive used the config value made the two contradict each other. + _output_language = _resolve_output_language() + _persona_prompt = build_system_prompt(_assistant_name, _output_language) def _build_initial_system_message() -> str: guidance = [_persona_prompt.strip()] @@ -1697,9 +1717,10 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any], # 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. # Settings-UI value (config) wins over the compose OUTPUT_LANGUAGE env so - # changing the language in the settings page actually takes effect. + # changing the language in the settings page actually takes effect. Same + # resolved value feeds the persona above, so they can't diverge. _lang_directive = reply_language_directive( - _extra_config("output_language", "") or os.environ.get("OUTPUT_LANGUAGE"), + _output_language, getattr(cfg, "tts_engine", "piper"), ) if _lang_directive: diff --git a/tests/test_output_language_resolution.py b/tests/test_output_language_resolution.py new file mode 100644 index 0000000..53cb0cf --- /dev/null +++ b/tests/test_output_language_resolution.py @@ -0,0 +1,74 @@ +"""The locked reply language must have a single source of truth. + +Regression: the persona prompt was built from the raw ``OUTPUT_LANGUAGE`` env +while the reply-language directive read the settings-UI value (config JSON). +Changing the language in the settings page rewrote the directive but left the +persona contradicting it. ``_resolve_output_language`` is now the one resolver +both call sites use, so they cannot diverge. +""" + +import pytest + + +@pytest.mark.unit +def test_settings_value_wins_over_env(monkeypatch, tmp_path): + from jarvis.reply.engine import _resolve_output_language + + cfg_path = tmp_path / "config.json" + cfg_path.write_text('{"output_language": "Korean"}', encoding="utf-8") + monkeypatch.setenv("JARVIS_CONFIG_PATH", str(cfg_path)) + monkeypatch.setenv("OUTPUT_LANGUAGE", "English") + + # The settings page value must take effect over the compose env default. + assert _resolve_output_language() == "Korean" + + +@pytest.mark.unit +def test_env_used_when_settings_absent(monkeypatch, tmp_path): + from jarvis.reply.engine import _resolve_output_language + + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}", encoding="utf-8") + monkeypatch.setenv("JARVIS_CONFIG_PATH", str(cfg_path)) + monkeypatch.setenv("OUTPUT_LANGUAGE", "English") + + assert _resolve_output_language() == "English" + + +@pytest.mark.unit +def test_unset_when_neither_configured(monkeypatch, tmp_path): + from jarvis.reply.engine import _resolve_output_language + + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}", encoding="utf-8") + monkeypatch.setenv("JARVIS_CONFIG_PATH", str(cfg_path)) + monkeypatch.delenv("OUTPUT_LANGUAGE", raising=False) + + # Empty string or None both mean "no lock" downstream; normalise the check. + assert not _resolve_output_language() + + +@pytest.mark.unit +def test_persona_and_directive_agree_on_settings_value(monkeypatch, tmp_path): + """End-to-end: the same resolved value feeds the persona and the directive, + so a settings-UI language can't be honoured by one and ignored by the other. + """ + from jarvis.reply.engine import _resolve_output_language + from jarvis.system_prompt import build_system_prompt, reply_language_directive + + cfg_path = tmp_path / "config.json" + cfg_path.write_text('{"output_language": "Korean"}', encoding="utf-8") + monkeypatch.setenv("JARVIS_CONFIG_PATH", str(cfg_path)) + monkeypatch.setenv("OUTPUT_LANGUAGE", "English") + + lang = _resolve_output_language() + persona = build_system_prompt("Jarvis", lang) + directive = reply_language_directive(lang, "melo") + + # Persona's user-language clause is rewritten to Korean, not English... + assert "in Korean" in persona + assert "in English" not in persona + # ...and the directive locks to the same Korean. (The directive may name + # English as a counter-example - "even if the user writes in English" - so + # we assert the lock target, not the mere absence of the word "English".) + assert directive is not None and "Korean" in directive