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