diff --git a/app/routers/chat.py b/app/routers/chat.py index 20f99e5..6e12a8e 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -21,7 +21,7 @@ from app.db import get_session, utcnow from app.jobs._market_context import REFERENCE_LINE from app.models import AICall, Headline, Quote, StrategicLog from app.routers.api import _md_to_html -from app.services.i18n import respond_in_clause +from app.services.i18n import language_directive_lead, respond_in_clause from app.services.llm_prompts import build_chat_system_prompt from app.services.openrouter import call_llm, month_start from app.services.output_review import review_read @@ -165,13 +165,17 @@ async def chat( headlines=headlines, reference_line=REFERENCE_LINE, ) - # Respect the user's interface language preference: append a single - # localized "respond in" nudge so the assistant answers in IT when - # the user has lang=it. The prompt + history (which includes the - # user's own question, often in their language) are usually enough, - # but the nudge guarantees the first reply lands correctly. + # Respect the user's interface language preference. The tail + # "Respond in X" clause is easy for the model to drop when the + # rest of the prompt is English (long log content, English + # market data, English headlines), so we ALSO prepend a stronger + # language directive at the top — see services/i18n. user_lang = principal.user.lang if principal and principal.user else "en" - system_prompt = system_prompt + respond_in_clause(user_lang) + system_prompt = ( + language_directive_lead(user_lang) + + system_prompt + + respond_in_clause(user_lang) + ) msgs = [{"role": "system", "content": system_prompt}] for m in history: diff --git a/app/services/i18n.py b/app/services/i18n.py index 742373d..55d00da 100644 --- a/app/services/i18n.py +++ b/app/services/i18n.py @@ -46,3 +46,31 @@ def respond_in_clause(lang: str | None) -> str: if not lang or lang == "en" or lang not in LANGUAGES: return "" return f"\n\nRespond in {LANGUAGES[lang]}." + + +def language_directive_lead(lang: str | None) -> str: + """Strong, top-of-prompt language directive for callers that + generate user-facing prose in real time (portfolio analysis, + chat) and need the output to actually land in the user's + preferred language. A single tail clause like + ``respond_in_clause`` is easy for the model to ignore when the + rest of the prompt + user message are entirely in English; this + leads with an explicit "all output in X" block, kept verbatim + rules for symbols/numbers, and is intended to be prepended to + the system prompt so the model anchors on the target language + before reading the rest. Combined with respond_in_clause at the + tail it gives a belt-and-suspenders defence. + + Empty string for English or unknown codes so callers can paste + it in unconditionally. + """ + if not lang or lang == "en" or lang not in LANGUAGES: + return "" + language = LANGUAGES[lang] + return ( + f"# LANGUAGE — write everything in {language}\n" + f"All output — section headers, prose, lists, and any inline " + f"labels — must be written in {language}. Do NOT mix English in. " + f"Ticker symbols (AAPL, MSFT, VOD.L), ISO currency codes " + f"(USD, EUR, GBP), and numeric values stay unchanged.\n\n" + ) diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index 1f6bea7..dfd3cb1 100644 --- a/app/services/portfolio_analysis.py +++ b/app/services/portfolio_analysis.py @@ -31,7 +31,7 @@ from app.config import get_settings from app.db import utcnow from app.logging import get_logger from app.models import AICall -from app.services.i18n import LANGUAGES, respond_in_clause +from app.services.i18n import LANGUAGES, language_directive_lead, respond_in_clause from app.services.llm_prompts import build_system_prompt from app.services.output_review import review_read from app.services.openrouter import ( @@ -282,7 +282,18 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]: head = enriched[:MAX_POSITIONS_INLINED] tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED) - system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES + respond_in_clause(req.lang) + # Language directive both prepended (so the model anchors on the + # target language before reading the long English instruction + # block) and appended (defence in depth — a tail nudge alone + # was being ignored by deepseek-v4-flash when most of the + # context is English). + system = ( + language_directive_lead(req.lang) + + build_system_prompt(req.tone, req.analysis) + + "\n\n" + + _SYSTEM_OVERRIDES + + respond_in_clause(req.lang) + ) user_parts = [ f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}",