i18n: prepend a strong language directive for portfolio + chat

Reports that portfolio AI analysis was coming back in English even
for IT-toggled users. Traced the chain (DB user.lang IS set to it,
router passes it into the payload, parse_request reads it, build_prompt
appends respond_in_clause), so the wiring is correct end-to-end. The
model was simply ignoring the single-sentence tail nudge: when the
system prompt is hundreds of lines of English and the user message
adds more English context, "Respond in Italian." at the end is easy
to drop on the floor.

Add a new services/i18n.language_directive_lead() that returns a
strong, explicit top-of-prompt block — "# LANGUAGE — write everything
in <X>" plus the verbatim-tickers-and-numbers carve-out — meant to
be PREPENDED so the model anchors on the target language before it
reads the bulk of the instructions. Combined with the existing tail
clause it's belt-and-suspenders: top + bottom of the prompt both
say "in this language".

Applied to portfolio_analysis.build_prompt() and chat.py — the two
surfaces that generate user-facing prose in real time (the strategic
log + indicator summaries get post-hoc translation via translate(),
so the directive isn't needed there).

Empty-string return for en / unknown lang means callers can wire
it in unconditionally; no extra plumbing in i18n callsites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-29 15:21:00 +02:00
parent 736d161990
commit 13dd3a8330
3 changed files with 52 additions and 9 deletions

View file

@ -21,7 +21,7 @@ from app.db import get_session, utcnow
from app.jobs._market_context import REFERENCE_LINE from app.jobs._market_context import REFERENCE_LINE
from app.models import AICall, Headline, Quote, StrategicLog from app.models import AICall, Headline, Quote, StrategicLog
from app.routers.api import _md_to_html 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.llm_prompts import build_chat_system_prompt
from app.services.openrouter import call_llm, month_start from app.services.openrouter import call_llm, month_start
from app.services.output_review import review_read from app.services.output_review import review_read
@ -165,13 +165,17 @@ async def chat(
headlines=headlines, headlines=headlines,
reference_line=REFERENCE_LINE, reference_line=REFERENCE_LINE,
) )
# Respect the user's interface language preference: append a single # Respect the user's interface language preference. The tail
# localized "respond in" nudge so the assistant answers in IT when # "Respond in X" clause is easy for the model to drop when the
# the user has lang=it. The prompt + history (which includes the # rest of the prompt is English (long log content, English
# user's own question, often in their language) are usually enough, # market data, English headlines), so we ALSO prepend a stronger
# but the nudge guarantees the first reply lands correctly. # language directive at the top — see services/i18n.
user_lang = principal.user.lang if principal and principal.user else "en" 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}] msgs = [{"role": "system", "content": system_prompt}]
for m in history: for m in history:

View file

@ -46,3 +46,31 @@ def respond_in_clause(lang: str | None) -> str:
if not lang or lang == "en" or lang not in LANGUAGES: if not lang or lang == "en" or lang not in LANGUAGES:
return "" return ""
return f"\n\nRespond in {LANGUAGES[lang]}." 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"
)

View file

@ -31,7 +31,7 @@ from app.config import get_settings
from app.db import utcnow from app.db import utcnow
from app.logging import get_logger from app.logging import get_logger
from app.models import AICall 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.llm_prompts import build_system_prompt
from app.services.output_review import review_read from app.services.output_review import review_read
from app.services.openrouter import ( from app.services.openrouter import (
@ -282,7 +282,18 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]:
head = enriched[:MAX_POSITIONS_INLINED] head = enriched[:MAX_POSITIONS_INLINED]
tail_count = max(0, len(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 = [ user_parts = [
f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}", f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}",