analyse: thread user.lang into the system prompt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-27 17:01:00 +02:00
parent e4982cdc04
commit d318039ad5
3 changed files with 89 additions and 4 deletions

View file

@ -36,7 +36,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_auth
from app.auth import CurrentUser, require_auth
from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
@ -341,10 +341,11 @@ async def parse_portfolio(
# ---------------------------------------------------------------------------
@router.post("/analyze", dependencies=[Depends(require_paid)])
@router.post("/analyze")
async def analyze_portfolio(
request: Request,
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_paid),
) -> dict:
"""Generate AI commentary for the supplied pie. The pie is held in
memory only for the duration of the LLM call; nothing about holdings
@ -364,6 +365,11 @@ async def analyze_portfolio(
except Exception:
raise HTTPException(status_code=400, detail="malformed JSON body")
user_lang = (
principal.user.lang if (principal.user and principal.user.lang) else "en"
)
payload["lang"] = user_lang
try:
req = portfolio_analysis.parse_request(payload)
except ValueError as e:

View file

@ -31,6 +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.openrouter import (
LogResult,
active_model,
@ -74,6 +75,7 @@ class AnalysisRequest:
anchor: str | None = None
tone: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO
analysis: str = "SPECULATIVE" # DRY | SPECULATIVE
lang: str = "en"
@dataclass
@ -163,10 +165,13 @@ def parse_request(payload: dict) -> AnalysisRequest:
anchor = _sanitise_text(payload.get("anchor") or "", 32) or None
tone = _sanitise_text(payload.get("tone", "INTERMEDIATE"), 16) or "INTERMEDIATE"
analysis = _sanitise_text(payload.get("analysis", "SPECULATIVE"), 16) or "SPECULATIVE"
lang = (payload.get("lang") or "en").strip().lower()
if lang not in LANGUAGES:
lang = "en"
return AnalysisRequest(
positions=positions, prices=prices, base_currency=base_currency,
anchor=anchor, tone=tone, analysis=analysis,
anchor=anchor, tone=tone, analysis=analysis, lang=lang,
)
@ -276,7 +281,7 @@ 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
system = 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')}",

View file

@ -176,3 +176,77 @@ async def test_log_translation_fanout_per_language_failure_isolated(tmp_path, mo
async with factory() as session:
rows = (await session.execute(select(StrategicLogTranslation))).scalars().all()
assert rows == []
@pytest.mark.asyncio
async def test_analyse_threads_lang_into_system_prompt(tmp_path, monkeypatch):
"""When lang='it', the system prompt sent to call_llm contains
'Respond in Italian.' the LLM does the rest."""
from app.services import portfolio_analysis as pa
from app.services.openrouter import LogResult
captured = {}
async def _fake_call_llm(client, messages, **kw):
captured["messages"] = messages
return LogResult(
content="Analisi del portafoglio in italiano.",
model="m", prompt_tokens=400, completion_tokens=100, cost_usd=0.0001,
)
monkeypatch.setattr(pa, "call_llm", _fake_call_llm)
_, factory, setup = _build_session_factory(tmp_path)
await setup()
payload = {
"positions": [{"yahoo_ticker": "AAPL", "qty": 10, "avg_cost": 150.0,
"currency": "USD", "name": "Apple Inc"}],
"prices": {"AAPL": {"p": 172.4, "c": "USD"}},
"fx": {"USD": 1.0},
"base_currency": "USD",
"tone": "INTERMEDIATE",
"analysis": "NORMAL",
"lang": "it",
}
req = pa.parse_request(payload)
assert req.lang == "it"
async with factory() as session:
await pa.analyse(session, req)
system = next(m["content"] for m in captured["messages"] if m["role"] == "system")
assert "Respond in Italian" in system
@pytest.mark.asyncio
async def test_analyse_no_clause_when_lang_is_en(tmp_path, monkeypatch):
from app.services import portfolio_analysis as pa
from app.services.openrouter import LogResult
captured = {}
async def _fake_call_llm(client, messages, **kw):
captured["messages"] = messages
return LogResult(
content="Portfolio analysis in English.",
model="m", prompt_tokens=400, completion_tokens=100, cost_usd=0.0001,
)
monkeypatch.setattr(pa, "call_llm", _fake_call_llm)
_, factory, setup = _build_session_factory(tmp_path)
await setup()
payload = {
"positions": [{"yahoo_ticker": "AAPL", "qty": 10, "avg_cost": 150.0,
"currency": "USD", "name": "Apple Inc"}],
"prices": {"AAPL": {"p": 172.4, "c": "USD"}},
"fx": {"USD": 1.0},
"base_currency": "USD",
"tone": "INTERMEDIATE",
"analysis": "NORMAL",
"lang": "en",
}
req = pa.parse_request(payload)
async with factory() as session:
await pa.analyse(session, req)
system = next(m["content"] for m in captured["messages"] if m["role"] == "system")
assert "Respond in" not in system