From 8af1da12dddf9fce8a21cc6e47150ec618476600 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 16:13:29 +0200 Subject: [PATCH] docs: implementation plan for Italian localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 TDD-style tasks: i18n service, translation helper, model + migration, ai_log_job translation fan-out, per-user surfaces (analyse, digest), localized /log endpoint, PATCH /api/settings/language, dropdown UI, and final regression + manual smoke. Per-user surfaces append "Respond in Italian." to the system prompt (one extra line, no extra LLM call). The strategic log is generated in English, then fanned out to translate() per active non-en language in parallel via asyncio.gather. The /log endpoint serves the matching translation row when present, English fallback otherwise. Translation uses the default call_llm provider chain — no separate cheap-model carve-out needed at DeepSeek's $0.28/M output pricing. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-27-localization-italian.md | 1589 +++++++++++++++++ 1 file changed, 1589 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-localization-italian.md diff --git a/docs/superpowers/plans/2026-05-27-localization-italian.md b/docs/superpowers/plans/2026-05-27-localization-italian.md new file mode 100644 index 0000000..363689b --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-localization-italian.md @@ -0,0 +1,1589 @@ +# Localization (Italian active, ES/FR/DE WIP) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every AI-generated user-facing surface render in Italian when the user picks `Italiano` in settings; lay the wiring so adding ES/FR/DE later is a one-line constant change. + +**Architecture:** Per-user surfaces (`portfolio_analysis`, `email_digest_job`, follow-up chat if present) thread the user's `lang` into the LLM prompt via a `respond_in_clause()` helper — one extra line on the system prompt, no extra call. The hourly `ai_log_job` writes the English `StrategicLog` row as today, then fans out parallel `translate()` calls (`asyncio.gather`) — one per active non-en language with at least one user — and persists each result in a new `strategic_log_translations` table. The `/log` endpoint serves the matching translation when present and falls back to English otherwise. + +**Tech Stack:** FastAPI · SQLAlchemy 2.0 async · Alembic · MariaDB (prod) / aiosqlite (tests) · existing `openrouter.call_llm` (DeepSeek-4-flash primary, OpenRouter fallback) · Jinja2 templates + +**Spec:** `docs/superpowers/specs/2026-05-27-localization-italian-design.md` + +--- + +## File Structure + +**Create:** +- `app/services/i18n.py` — `LANGUAGES`, `ACTIVE_LANGUAGES`, `respond_in_clause()` +- `app/services/translation.py` — `translate(client, text, target_lang)` wrapping `call_llm` +- `alembic/versions/0022_localization.py` — adds `users.lang`, creates `strategic_log_translations` +- `tests/test_i18n.py` — unit tests for the two new services +- `tests/test_localization_integration.py` — wiring/fan-out + route-level integration + +**Modify:** +- `app/models.py` — add `User.lang`, new `StrategicLogTranslation` model +- `app/jobs/ai_log_job.py` — translation fan-out after English row is committed +- `app/services/portfolio_analysis.py` — accept + thread `lang` field +- `app/routers/universe.py` — pass `cu.user.lang` (or `"en"` for admin) into `parse_request` +- `app/jobs/email_digest_job.py` — thread `user.lang` into the per-user prompt +- `app/routers/api.py` — add `PATCH /api/settings/language` endpoint +- `app/routers/pages.py` — `log_page` / `log_page_day` serve translated content when available +- `app/templates/settings.html` — language dropdown + small JS handler + +**Reuse without modification:** +- `app/services/openrouter.call_llm`, `LogResult` +- `app/auth.require_auth` / `require_token` / `CurrentUser` +- `app/db.Base`, `utcnow`, `get_session` +- Test session-factory pattern from `tests/test_referral_conversion.py::_build_session_factory` + +--- + +## Test Conventions + +All tests runnable in the project-isolated container: + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_i18n.py tests/test_localization_integration.py -v +``` + +DB-touching tests use the per-test `_build_session_factory(tmp_path)` pattern from `tests/test_referral_conversion.py`. LLM calls mocked via `monkeypatch.setattr(, "call_llm", AsyncMock(...))`. Real Yahoo/network calls forbidden. + +--- + +### Task 1: i18n service — LANGUAGES, ACTIVE_LANGUAGES, respond_in_clause + +**Files:** +- Create: `app/services/i18n.py` +- Test: `tests/test_i18n.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_i18n.py`: + +```python +"""Unit tests for app.services.i18n.""" +from __future__ import annotations + +import pytest + + +def test_languages_contains_all_four_plus_english(): + from app.services.i18n import LANGUAGES + assert set(LANGUAGES.keys()) == {"en", "it", "es", "fr", "de"} + assert LANGUAGES["en"] == "English" + assert LANGUAGES["it"] == "Italian" + assert LANGUAGES["es"] == "Spanish" + assert LANGUAGES["fr"] == "French" + assert LANGUAGES["de"] == "German" + + +def test_active_languages_is_en_and_it_only(): + from app.services.i18n import ACTIVE_LANGUAGES + assert ACTIVE_LANGUAGES == {"en", "it"} + + +def test_respond_in_clause_empty_for_english(): + from app.services.i18n import respond_in_clause + assert respond_in_clause("en") == "" + + +def test_respond_in_clause_empty_for_none_or_empty(): + from app.services.i18n import respond_in_clause + assert respond_in_clause("") == "" + assert respond_in_clause(None) == "" + + +def test_respond_in_clause_italian(): + from app.services.i18n import respond_in_clause + result = respond_in_clause("it") + assert "Italian" in result + assert result.startswith("\n\n") + + +def test_respond_in_clause_unknown_lang_falls_back_to_english(): + """Defensive: a raw POST or stale lang code should not crash the + prompt assembly. Unknown codes map to no-suffix (English default).""" + from app.services.i18n import respond_in_clause + assert respond_in_clause("xx") == "" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_i18n.py -v +``` + +Expected: 6 FAIL with `ImportError`. + +- [ ] **Step 3: Implement `app/services/i18n.py`** + +```python +"""Language registry + prompt helpers for localized AI output. + +Two surfaces consume this module: +- Per-user LLM call sites (portfolio analysis, digest, chat) call + ``respond_in_clause(user.lang)`` and append the result to their + system prompt. +- The settings dropdown + its PATCH endpoint consult ``ACTIVE_LANGUAGES`` + to decide which options are selectable. + +Adding Spanish/French/German support later is a one-line constant +change: extend ``ACTIVE_LANGUAGES`` to include the new code. No other +code change is required — the rest of the system already treats them +as first-class via ``LANGUAGES``. +""" +from __future__ import annotations + + +# Display labels for every language the system knows about. ES/FR/DE +# are kept here so labels still render in the dropdown (as disabled +# options) without requiring code changes to enable them later. +LANGUAGES: dict[str, str] = { + "en": "English", + "it": "Italian", + "es": "Spanish", + "fr": "French", + "de": "German", +} + + +# Languages users can actually select. Settings POST validates against +# this; the strategic-log translation fan-out only considers these. +ACTIVE_LANGUAGES: set[str] = {"en", "it"} + + +def respond_in_clause(lang: str | None) -> str: + """Suffix appended to per-user LLM system prompts. + + Returns an empty string for ``en`` (no nudge needed), an unknown + code, or ``None``/empty input — those callers want the default + English path. Otherwise returns ``"\\n\\nRespond in ."`` + keyed off ``LANGUAGES``. + """ + if not lang or lang == "en" or lang not in LANGUAGES: + return "" + return f"\n\nRespond in {LANGUAGES[lang]}." +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_i18n.py -v +``` + +Expected: 6 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/services/i18n.py tests/test_i18n.py +git commit -m "i18n: add LANGUAGES, ACTIVE_LANGUAGES, respond_in_clause helper" +``` + +## Context + +- Working directory: `/home/gg/mydocker_images/products/read.markets`. Branch `main`. Commit directly. +- Test runner: ONLY `docker compose -f docker-compose.test.yml ...`. NEVER plain `docker compose ...` against the prod stack. +- If `git commit` is blocked by the auto-mode classifier, leave the tree dirty and report — the controller will commit. + +--- + +### Task 2: translation service + +**Files:** +- Create: `app/services/translation.py` +- Test: `tests/test_i18n.py` (append) + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_i18n.py`: + +```python +@pytest.mark.asyncio +async def test_translate_happy_path(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + from app.services import translation as mod + from app.services.openrouter import LogResult + + monkeypatch.setattr(mod, "call_llm", AsyncMock(return_value=LogResult( + content="# Apertura\n\nIl mercato è in calo dello 0,4%.", + model="deepseek/deepseek-v4-flash", + prompt_tokens=300, completion_tokens=80, cost_usd=0.00002, + ))) + + client = MagicMock() + translated, llm_log = await mod.translate( + client, "# Open\n\nThe market is down 0.4%.", "it", + ) + assert "Apertura" in translated + assert llm_log.model == "deepseek/deepseek-v4-flash" + assert llm_log.cost_usd == pytest.approx(0.00002) + + +@pytest.mark.asyncio +async def test_translate_strips_code_fences(monkeypatch): + """If the LLM wraps the output in ```markdown ... ```, strip it.""" + from unittest.mock import AsyncMock, MagicMock + + from app.services import translation as mod + from app.services.openrouter import LogResult + + fenced = "```markdown\n# Titolo\n\nCorpo.\n```" + monkeypatch.setattr(mod, "call_llm", AsyncMock(return_value=LogResult( + content=fenced, model="m", prompt_tokens=10, completion_tokens=20, cost_usd=0.0, + ))) + + client = MagicMock() + translated, _ = await mod.translate(client, "# Title\n\nBody.", "it") + assert "```" not in translated + assert translated.startswith("# Titolo") + + +@pytest.mark.asyncio +async def test_translate_provider_failure_propagates(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + from app.services import translation as mod + + monkeypatch.setattr(mod, "call_llm", AsyncMock(side_effect=RuntimeError("upstream down"))) + + client = MagicMock() + with pytest.raises(RuntimeError, match="upstream down"): + await mod.translate(client, "# Title\n\nBody.", "it") + + +@pytest.mark.asyncio +async def test_translate_unknown_lang_returns_source_unchanged(monkeypatch): + """Defensive: an unknown lang code (or 'en') short-circuits without + calling the LLM. Callers shouldn't have to gate the call themselves.""" + from unittest.mock import AsyncMock, MagicMock + + from app.services import translation as mod + from app.services.openrouter import LogResult + + call_mock = AsyncMock(return_value=LogResult( + content="should not be returned", + model="m", prompt_tokens=0, completion_tokens=0, cost_usd=0.0, + )) + monkeypatch.setattr(mod, "call_llm", call_mock) + + client = MagicMock() + out, _ = await mod.translate(client, "Hello world.", "en") + assert out == "Hello world." + call_mock.assert_not_awaited() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_i18n.py -k translate -v +``` + +Expected: 4 FAIL with `ImportError`. + +- [ ] **Step 3: Implement `app/services/translation.py`** + +```python +"""Markdown translation via the existing LLM provider chain. + +DeepSeek-4-flash at ~$0.28/M output tokens is cheap enough that we +don't bother with a separate translation-only model. ``call_llm``'s +provider chain (DeepSeek primary, OpenRouter fallback) handles this +path identically to any other LLM call. + +The translator is content-aware in one important way: it instructs the +model to preserve markdown structure, ticker symbols, numbers, dates, +and percentages verbatim. This keeps generated artefacts (tables of +quotes, embedded percentages, dated references) intact across the +translation boundary. +""" +from __future__ import annotations + +import httpx + +from app.services.i18n import LANGUAGES +from app.services.openrouter import LogResult, call_llm + + +_SYSTEM_PROMPT_TMPL = """\ +You are an expert translator working on financial-markets commentary. +Translate the following markdown text to {language}. + +Strict rules: +- Preserve ALL markdown formatting (headings, lists, emphasis, links, + tables, code spans). +- Do NOT translate ticker symbols (AAPL, MSFT, VOD.L, ASML.AS, etc.), + company legal names, percentages, dates, ISO currency codes, or any + numbers. +- Do NOT add commentary, preambles, or apologies. Output ONLY the + translated markdown. +""" + + +async def translate( + client: httpx.AsyncClient, + text: str, + target_lang: str, +) -> tuple[str, LogResult]: + """Translate markdown ``text`` to ``target_lang``. + + Returns ``(translated_markdown, LogResult)``. Caller persists the + cost/model provenance from LogResult next to the cached row. + + Short-circuits without calling the LLM when ``target_lang`` is + ``'en'``, unknown, or empty — returns the source unchanged with a + zero-cost stub LogResult. This lets fan-out callers iterate over + all languages without per-call gating. + + Raises on provider failure (HTTP error, all chain providers down). + Callers in fan-out paths should catch and log per-language. + """ + if not target_lang or target_lang == "en" or target_lang not in LANGUAGES: + # No-op fast path. Returning a fake LogResult keeps the call + # signature stable for callers who unpack the tuple. + return text, LogResult( + content=text, model="noop", + prompt_tokens=0, completion_tokens=0, cost_usd=0.0, + ) + + system_prompt = _SYSTEM_PROMPT_TMPL.format(language=LANGUAGES[target_lang]) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": text}, + ] + result = await call_llm(client, messages) + + content = (result.content or "").strip() + # Strip code fences if the model wrapped its output despite the system rule. + if content.startswith("```"): + # Drop the opening fence (with optional language tag). + first_nl = content.find("\n") + if first_nl != -1: + content = content[first_nl + 1:] + # Drop the closing fence. + if content.rstrip().endswith("```"): + content = content.rstrip()[:-3].rstrip() + content = content.strip() + + return content, result +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_i18n.py -v +``` + +Expected: 10 tests pass total (6 from Task 1 + 4 new). + +- [ ] **Step 5: Commit** + +```bash +git add app/services/translation.py tests/test_i18n.py +git commit -m "i18n: add translate() helper backed by call_llm" +``` + +--- + +### Task 3: User.lang column + StrategicLogTranslation model + +**Files:** +- Modify: `app/models.py` +- Test: `tests/test_localization_integration.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_localization_integration.py`: + +```python +"""Integration tests: model surface, ai_log_job translation fan-out, +route-level localized fetch, settings PATCH validation.""" +from __future__ import annotations + +import pytest + + +def _build_session_factory(tmp_path): + """Per-test sqlite engine + factory. Mirrors test_referral_conversion.py.""" + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base + import app.models # noqa: F401 — registers models on Base.metadata + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/loc.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + async def _setup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + return engine, factory, _setup + + +def test_user_has_lang_column_with_default_en(): + from sqlalchemy import inspect + from app.models import User + + cols = {c.name: c for c in inspect(User).columns} + assert "lang" in cols + assert cols["lang"].nullable is False + # SQLAlchemy default may be a callable or a literal — check both. + default = cols["lang"].default + assert default is not None + if hasattr(default, "arg"): + assert default.arg == "en" + + +def test_strategic_log_translation_model_columns(): + from sqlalchemy import inspect + from app.models import StrategicLogTranslation + + cols = {c.name: c for c in inspect(StrategicLogTranslation).columns} + assert "log_id" in cols + assert "lang" in cols + assert "content_md" in cols + assert "generated_at" in cols + assert "llm_model" in cols + assert "llm_cost_usd" in cols + assert cols["log_id"].nullable is False + assert cols["lang"].nullable is False + assert cols["content_md"].nullable is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: 2 FAIL — first on `User.lang` missing, second on `StrategicLogTranslation` import error. + +- [ ] **Step 3: Add `User.lang` column** + +In `app/models.py`, find the `User` class. Find a sensible place near other user-preference columns (next to `tone` or `digest_tone`) and add: + +```python + # Preferred language for AI-generated content (strategic log, + # digest emails, portfolio commentary). Default 'en'. The settings + # PATCH endpoint validates against ACTIVE_LANGUAGES in + # app/services/i18n.py before writing. + lang: Mapped[str] = mapped_column( + String(8), nullable=False, default="en", server_default="en", + ) +``` + +- [ ] **Step 4: Add `StrategicLogTranslation` model** + +In `app/models.py`, append after the existing `StrategicLog` class (around line 108-122): + +```python +class StrategicLogTranslation(Base): + """Cached translation of a single StrategicLog row. + + Populated by ai_log_job after the English row is committed: one + row per (log_id, lang) combination. The /log endpoint serves the + matching row when available and falls back to the English source + when no row exists yet (e.g. translation failed or the language + was added after the log was generated). + + No user attribution — the cache is shared. Setting `lang` on a + user just selects which (already-translated) variant they see. + """ + __tablename__ = "strategic_log_translations" + + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + log_id: Mapped[int] = mapped_column( + _PK, ForeignKey("strategic_logs.id", ondelete="CASCADE"), nullable=False, + ) + lang: Mapped[str] = mapped_column(String(8), nullable=False) + content_md: Mapped[str] = mapped_column(Text, nullable=False) + generated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, + ) + llm_model: Mapped[str | None] = mapped_column(String(64)) + llm_cost_usd: Mapped[float | None] = mapped_column(Float) + + __table_args__ = ( + UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"), + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: 2 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add app/models.py tests/test_localization_integration.py +git commit -m "models: add User.lang + StrategicLogTranslation" +``` + +## Context for this task + +- The existing `User` class already imports `String`, `mapped_column`, `Mapped`. No new imports needed for `User.lang`. +- For `StrategicLogTranslation`, `_PK`, `String`, `Text`, `DateTime`, `Float`, `ForeignKey`, `UniqueConstraint`, `Mapped`, `mapped_column`, `Base`, `utcnow`, `datetime` are all already imported at the top of `app/models.py` — no new imports needed. + +--- + +### Task 4: Alembic migration 0022 + +**Files:** +- Create: `alembic/versions/0022_localization.py` + +- [ ] **Step 1: Write the migration** + +```python +"""localization: users.lang + strategic_log_translations. + +Revision ID: 0022 +Revises: 0021 +Create Date: 2026-05-27 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0022" +down_revision: Union[str, None] = "0021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "lang", sa.String(length=8), nullable=False, + server_default="en", + ), + ) + op.create_table( + "strategic_log_translations", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("log_id", sa.BigInteger(), nullable=False), + sa.Column("lang", sa.String(length=8), nullable=False), + sa.Column("content_md", sa.Text(), nullable=False), + sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("llm_model", sa.String(length=64), nullable=True), + sa.Column("llm_cost_usd", sa.Float(), nullable=True), + sa.ForeignKeyConstraint( + ["log_id"], ["strategic_logs.id"], + ondelete="CASCADE", name="fk_slt_log", + ), + sa.UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"), + ) + + +def downgrade() -> None: + op.drop_table("strategic_log_translations") + op.drop_column("users", "lang") +``` + +- [ ] **Step 2: Verify migration chain integrity** + +```bash +docker compose -f docker-compose.test.yml run --rm test python -c " +from alembic.config import Config +from alembic.script import ScriptDirectory +sd = ScriptDirectory.from_config(Config('alembic.ini')) +heads = sd.get_heads() +assert heads == ('0022',), heads +rev = sd.get_revision('0022') +assert rev.down_revision == '0021' +print('OK') +" +``` + +Expected: prints `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add alembic/versions/0022_localization.py +git commit -m "alembic: add 0022 localization (users.lang + strategic_log_translations)" +``` + +## Context + +- Previous migration is `0021_csv_format_template.py`. The new file follows the same hand-rolled style. +- Codebase convention for integer server_defaults is `sa.text("0")` — but here we have no integer defaults to set. String default `"en"` uses the bare string per existing migrations (e.g. `0011_drop_portfolio_tables.py` uses `server_default="GBP"`). + +--- + +### Task 5: ai_log_job translation fan-out + +**Files:** +- Modify: `app/jobs/ai_log_job.py` +- Test: `tests/test_localization_integration.py` (append) + +- [ ] **Step 1: Inspect the existing log-writing path** + +```bash +grep -n "def \|StrategicLog\|session.commit\|session.add" app/jobs/ai_log_job.py | head -20 +``` + +Locate the function and the line where the new `StrategicLog` row is committed. The fan-out runs **after** that commit. + +- [ ] **Step 2: Write failing tests** + +Append to `tests/test_localization_integration.py`: + +```python +@pytest.mark.asyncio +async def test_log_translation_fanout_no_active_non_en_users(tmp_path, monkeypatch): + """When no users have an active non-en lang, the fan-out makes no + translation calls and no rows are inserted.""" + from unittest.mock import AsyncMock + from sqlalchemy import select + + from app.db import utcnow + from app.models import StrategicLog, StrategicLogTranslation, User + from app.jobs import ai_log_job + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + fake_translate = AsyncMock() + monkeypatch.setattr(ai_log_job, "translate", fake_translate) + + # Seed an English user (no non-en users). + async with factory() as session: + session.add(User(id=1, email="en@x", tier="paid", lang="en")) + slog = StrategicLog( + generated_at=utcnow(), content_md="# Open\n\nDown 0.4%.", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + log_id = slog.id + + async with factory() as session: + await ai_log_job.translate_log_for_active_languages(session, log_id) + + fake_translate.assert_not_awaited() + async with factory() as session: + rows = (await session.execute(select(StrategicLogTranslation))).scalars().all() + assert rows == [] + + +@pytest.mark.asyncio +async def test_log_translation_fanout_italian_user(tmp_path, monkeypatch): + """One user at lang=it triggers one translation; the row lands with + the right lang and log_id.""" + from unittest.mock import AsyncMock + from sqlalchemy import select + + from app.db import utcnow + from app.models import StrategicLog, StrategicLogTranslation, User + from app.services.openrouter import LogResult + from app.jobs import ai_log_job + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async def _fake_translate(client, text, target_lang): + assert target_lang == "it" + return "# Apertura\n\nIn calo 0,4%.", LogResult( + content="# Apertura\n\nIn calo 0,4%.", + model="deepseek/deepseek-v4-flash", + prompt_tokens=300, completion_tokens=80, cost_usd=0.00002, + ) + monkeypatch.setattr(ai_log_job, "translate", _fake_translate) + + async with factory() as session: + session.add(User(id=2, email="it@x", tier="paid", lang="it")) + slog = StrategicLog( + generated_at=utcnow(), content_md="# Open\n\nDown 0.4%.", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + log_id = slog.id + + async with factory() as session: + await ai_log_job.translate_log_for_active_languages(session, log_id) + + async with factory() as session: + rows = (await session.execute(select(StrategicLogTranslation))).scalars().all() + assert len(rows) == 1 + row = rows[0] + assert row.log_id == log_id + assert row.lang == "it" + assert row.content_md.startswith("# Apertura") + assert row.llm_model == "deepseek/deepseek-v4-flash" + assert row.llm_cost_usd == pytest.approx(0.00002) + + +@pytest.mark.asyncio +async def test_log_translation_fanout_per_language_failure_isolated(tmp_path, monkeypatch): + """If one language's translation fails, the others (if any) still land + and the job does not raise.""" + from unittest.mock import AsyncMock + from sqlalchemy import select + + from app.db import utcnow + from app.models import StrategicLog, StrategicLogTranslation, User + from app.jobs import ai_log_job + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async def _fake_translate(client, text, target_lang): + raise RuntimeError("upstream down") + monkeypatch.setattr(ai_log_job, "translate", _fake_translate) + + async with factory() as session: + session.add(User(id=3, email="it@x", tier="paid", lang="it")) + slog = StrategicLog( + generated_at=utcnow(), content_md="# Open", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + log_id = slog.id + + # Must NOT raise. + async with factory() as session: + await ai_log_job.translate_log_for_active_languages(session, log_id) + + async with factory() as session: + rows = (await session.execute(select(StrategicLogTranslation))).scalars().all() + assert rows == [] +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -k log_translation_fanout -v +``` + +Expected: 3 FAIL with `AttributeError: module 'app.jobs.ai_log_job' has no attribute 'translate_log_for_active_languages'`. + +- [ ] **Step 4: Add the fan-out function** + +In `app/jobs/ai_log_job.py`, add (at module scope, alongside other helpers; use existing imports + add what's missing — `httpx`, `asyncio`, `select`, the i18n + translation modules, and the model imports): + +```python +import asyncio +import httpx + +from sqlalchemy import select + +from app.db import utcnow +from app.models import User, StrategicLogTranslation +from app.services.i18n import ACTIVE_LANGUAGES +from app.services.translation import translate +``` + +(Add only the lines not already present.) + +Then add the function: + +```python +async def translate_log_for_active_languages(session, log_id: int) -> None: + """Fan out per-language translations for the strategic log identified + by ``log_id``. + + Reads ``users.lang`` (deduplicated, restricted to ACTIVE_LANGUAGES + minus English), one translation call per language in parallel via + ``asyncio.gather``, persists each successful result as a + ``StrategicLogTranslation`` row. Per-language failures are logged + but never raise — the strategic log itself is already committed at + this point and translation is a best-effort enhancement. + + The job orchestrator calls this AFTER the English ``StrategicLog`` + row is committed; pass the row's ``id`` in. + """ + from app.models import StrategicLog # local import: avoid widening top-level imports + target_langs = sorted({l for l in ACTIVE_LANGUAGES if l != "en"}) + if not target_langs: + return + + active_langs = (await session.execute( + select(User.lang).distinct().where(User.lang.in_(target_langs)) + )).scalars().all() + if not active_langs: + return + + log_row = await session.get(StrategicLog, log_id) + if log_row is None: + log.warning("log.translate.missing_log", log_id=log_id) + return + + async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: + results = await asyncio.gather(*[ + translate(client, log_row.content_md, lang) + for lang in active_langs + ], return_exceptions=True) + + for lang, result in zip(active_langs, results): + if isinstance(result, Exception): + log.warning("log.translate.failed", lang=lang, log_id=log_id, + error=str(result)[:200]) + continue + translated_md, llm_log = result + session.add(StrategicLogTranslation( + log_id=log_id, lang=lang, + content_md=translated_md, + generated_at=utcnow(), + llm_model=llm_log.model, + llm_cost_usd=llm_log.cost_usd, + )) + await session.commit() +``` + +- [ ] **Step 5: Wire the fan-out into the existing log-write path** + +Find the function in `ai_log_job.py` that writes a `StrategicLog` row and calls `session.commit()`. After that commit, capture the row's `id` and call: + +```python +await translate_log_for_active_languages(session, slog.id) +``` + +(Use whatever the actual local variable name for the row is.) + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: 5 tests pass (2 from Task 3 + 3 new). + +- [ ] **Step 7: Commit** + +```bash +git add app/jobs/ai_log_job.py tests/test_localization_integration.py +git commit -m "ai-log-job: translate strategic log for active non-en languages" +``` + +--- + +### Task 6: portfolio_analysis localization + +**Files:** +- Modify: `app/services/portfolio_analysis.py` +- Modify: `app/routers/universe.py` — the `/api/analyze` route +- Test: `tests/test_localization_integration.py` (append) + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_localization_integration.py`: + +```python +@pytest.mark.asyncio +async def test_analyse_threads_lang_into_system_prompt(monkeypatch): + """When lang='it', the system prompt sent to call_llm contains + 'Respond in Italian.' — the LLM does the rest.""" + from unittest.mock import AsyncMock + 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) + + 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" + + # Direct call into analyse() to inspect the captured prompt. + # Use None session — analyse should not touch the DB in this code path + # because we mocked call_llm before any AICall ledger write. + # If analyse insists on a session, wrap with the test factory. + result = await pa.analyse(None, req) # noqa: ARG — session ignored by mock + 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(monkeypatch): + from unittest.mock import AsyncMock + 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) + + 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) + await pa.analyse(None, req) + system = next(m["content"] for m in captured["messages"] if m["role"] == "system") + assert "Respond in" not in system +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -k analyse -v +``` + +Expected: 2 FAIL — either `lang` field on `AnalysisRequest` missing, or "Respond in Italian" not in the prompt. + +- [ ] **Step 3: Add `lang` to AnalysisRequest and thread through** + +In `app/services/portfolio_analysis.py`: + +1. Locate the `AnalysisRequest` dataclass (or pydantic model). Add a `lang: str = "en"` field next to `tone` / `analysis`. +2. Locate `parse_request`. Read `payload.get("lang", "en")` and pass it to the request constructor. Validate against `LANGUAGES`: + +```python +from app.services.i18n import LANGUAGES, respond_in_clause + +# Inside parse_request, alongside the existing tone/analysis parsing: +lang = (payload.get("lang") or "en").strip().lower() +if lang not in LANGUAGES: + lang = "en" +``` + +Then build the request with `lang=lang`. + +3. Locate `analyse` (the async function that builds messages and calls `call_llm`). After the system prompt is composed, append the i18n clause: + +```python +system_prompt = system_prompt + respond_in_clause(req.lang) +``` + +(Use whatever the local variable name for the system prompt is.) + +- [ ] **Step 4: Pass the user's lang from the route** + +In `app/routers/universe.py`, find `analyze_portfolio` (the `/api/analyze` route handler). Add the user's lang to the payload before calling `parse_request`: + +```python +# Just before parse_request: +user_lang = ( + principal.user.lang if (principal.user and principal.user.lang) else "en" +) +payload["lang"] = user_lang +``` + +(The handler receives `principal` via Depends. Confirm by reading the handler's signature; if the principal isn't already wired in, add `principal: CurrentUser = Depends(require_paid)` matching the existing dep.) + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: 7 tests pass (5 from Tasks 3+5 + 2 new). + +- [ ] **Step 6: Commit** + +```bash +git add app/services/portfolio_analysis.py app/routers/universe.py tests/test_localization_integration.py +git commit -m "analyse: thread user.lang into the system prompt" +``` + +--- + +### Task 7: email_digest_job localization + +**Files:** +- Modify: `app/jobs/email_digest_job.py` +- Test: `tests/test_localization_integration.py` (append) + +- [ ] **Step 1: Write failing test** + +Append to `tests/test_localization_integration.py`: + +```python +@pytest.mark.asyncio +async def test_digest_threads_lang_into_system_prompt(monkeypatch): + """The per-user digest generation appends 'Respond in Italian.' to + the system prompt when the user is Italian.""" + from unittest.mock import AsyncMock + from app.jobs import email_digest_job as ed + from app.services.openrouter import LogResult + + captured = [] + + async def _fake_call_llm(client, messages, **kw): + captured.append(messages) + return LogResult( + content="**Apertura.** Il mercato è in calo.", + model="m", prompt_tokens=300, completion_tokens=400, cost_usd=0.0001, + ) + monkeypatch.setattr(ed, "call_llm", _fake_call_llm) + + # _generate_variants is the helper that runs one LLM call per tone. + # It takes a context dict and a 'kind' (daily/weekly). The exact + # signature is in app/jobs/email_digest_job.py — inspect before + # calling. The test below assumes it accepts a `target_lang` kwarg. + from datetime import datetime, timezone + + ctx = { + "today": datetime.now(timezone.utc), + "quotes_by_group": {}, + "headlines_by_bucket": {}, + "reference_line": None, + } + + # `_generate_variants` should iterate tones internally; we just need + # to assert at least one captured system prompt has the IT clause. + import httpx + async with httpx.AsyncClient() as client: + await ed._generate_variants(None, client, "daily", ctx, target_lang="it") + + assert captured, "no LLM call was made" + italian_found = any( + any( + m["role"] == "system" and "Respond in Italian" in m["content"] + for m in messages + ) + for messages in captured + ) + assert italian_found, "no system prompt contained 'Respond in Italian'" +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -k digest_threads_lang -v +``` + +Expected: FAIL — either `_generate_variants` doesn't accept `target_lang`, or the IT clause isn't in the prompt. + +- [ ] **Step 3: Thread `target_lang` through `_generate_variants` and the per-user driver** + +In `app/jobs/email_digest_job.py`: + +1. Import the helper: + ```python + from app.services.i18n import respond_in_clause + ``` + +2. Find `_generate_variants`. Add `target_lang: str = "en"` to its signature. Where it composes each variant's system prompt, append: + ```python + system_prompt = system_prompt + respond_in_clause(target_lang) + ``` + +3. Find the per-user send path (the function that actually iterates users — likely `_send_for_user` or similar, called from the job's main loop). Where it calls `_generate_variants`, pass `target_lang=user.lang`: + ```python + variants = await _generate_variants( + session, client, kind, ctx, target_lang=user.lang, + ) + ``` + + If the existing call site is in the main job loop and constructs `variants` once for all users, that breaks the "per-user language" contract. In that case the variants must be generated PER USER, not globally. Look for the caller; if it caches `variants` across users, restructure to call `_generate_variants` inside the per-user loop. **Important:** if this requires more than a few lines of change, stop and report a concern — the existing assumption may be wrong and we want explicit guidance. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: all tests pass (8 total now). + +- [ ] **Step 5: Commit** + +```bash +git add app/jobs/email_digest_job.py tests/test_localization_integration.py +git commit -m "digest: thread user.lang into per-user generation" +``` + +--- + +### Task 8: /log endpoint localized fetch + +**Files:** +- Modify: `app/routers/pages.py` — `log_page` and `log_page_day` +- Test: `tests/test_localization_integration.py` (append) + +- [ ] **Step 1: Inspect the existing log endpoints** + +```bash +grep -n "def log_page\|StrategicLog\|content_md\|generated_at" app/routers/pages.py | head -20 +``` + +Locate the function(s) that fetch the strategic log and pass `content_md` to the template. + +- [ ] **Step 2: Write a failing test** + +Append to `tests/test_localization_integration.py`: + +```python +@pytest.mark.asyncio +async def test_log_endpoint_serves_italian_when_user_is_italian(tmp_path): + """When a user with lang='it' opens /log, the served content_md is + the Italian translation, not the English original.""" + from datetime import datetime, timezone + + from app.db import utcnow + from app.models import StrategicLog, StrategicLogTranslation, User + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=10, email="it@x", tier="paid", lang="it")) + slog = StrategicLog( + generated_at=utcnow(), content_md="# Open\n\nDown 0.4%.", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + session.add(StrategicLogTranslation( + log_id=slog.id, lang="it", + content_md="# Apertura\n\nIn calo 0,4%.", + generated_at=utcnow(), llm_model="m", llm_cost_usd=0.0, + )) + await session.commit() + log_id = slog.id + + # We test the resolver function directly rather than spinning up the + # FastAPI TestClient — the resolver shape returns the rendered MD. + from app.routers.pages import _resolve_log_content + async with factory() as session: + user = await session.get(User, 10) + content = await _resolve_log_content(session, log_id, user.lang) + assert "Apertura" in content + assert "Open" not in content + + +@pytest.mark.asyncio +async def test_log_endpoint_falls_back_to_english_when_no_translation(tmp_path): + """User lang='it' but no IT translation exists → English fallback.""" + from app.db import utcnow + from app.models import StrategicLog, User + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=11, email="it2@x", tier="paid", lang="it")) + slog = StrategicLog( + generated_at=utcnow(), content_md="# Open\n\nDown 0.4%.", + tone="INTERMEDIATE", analysis="NORMAL", + ) + session.add(slog) + await session.commit() + log_id = slog.id + + from app.routers.pages import _resolve_log_content + async with factory() as session: + user = await session.get(User, 11) + content = await _resolve_log_content(session, log_id, user.lang) + assert "Open" in content +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -k log_endpoint -v +``` + +Expected: 2 FAIL — `_resolve_log_content` doesn't exist yet. + +- [ ] **Step 4: Add the resolver and wire it into `log_page` / `log_page_day`** + +In `app/routers/pages.py`, add the resolver as a module-level async function: + +```python +async def _resolve_log_content( + session: AsyncSession, log_id: int, lang: str, +) -> str: + """Return the markdown content of strategic log ``log_id`` in the + user's preferred language. + + If ``lang`` is ``en`` or no translation exists for the requested + language, returns the English original. The fallback is silent — + a missing translation is the expected case for hours where + translation hasn't yet run.""" + from app.models import StrategicLog, StrategicLogTranslation + + if lang and lang != "en": + row = (await session.execute( + select(StrategicLogTranslation) + .where(StrategicLogTranslation.log_id == log_id) + .where(StrategicLogTranslation.lang == lang) + )).scalar_one_or_none() + if row is not None: + return row.content_md + log_row = await session.get(StrategicLog, log_id) + return log_row.content_md if log_row is not None else "" +``` + +Then in `log_page` (and `log_page_day` if present), replace the line that pulls `content_md` directly from the StrategicLog row with a call to `_resolve_log_content(session, log.id, cu.user.lang if cu.user else "en")`. + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: all 10 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/routers/pages.py tests/test_localization_integration.py +git commit -m "log: serve translated content when available; English fallback" +``` + +--- + +### Task 9: PATCH /api/settings/language endpoint + +**Files:** +- Modify: `app/routers/api.py` +- Test: `tests/test_localization_integration.py` (append) + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_localization_integration.py`: + +```python +@pytest.mark.asyncio +async def test_patch_language_accepts_active(tmp_path): + """PATCH /api/settings/language accepts 'en' and 'it' and persists.""" + from app.models import User + from app.routers.api import patch_language_prefs, LanguagePrefsIn + from app.auth import CurrentUser + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=20, email="u@x", tier="paid", lang="en")) + await session.commit() + + class _P: + is_admin = False + def __init__(self, u): self.user = u + + async with factory() as session: + user = await session.get(User, 20) + result = await patch_language_prefs( + payload=LanguagePrefsIn(lang="it"), + principal=_P(user), + session=session, + ) + assert result.lang == "it" + + async with factory() as session: + user = await session.get(User, 20) + assert user.lang == "it" + + +@pytest.mark.asyncio +async def test_patch_language_rejects_wip(tmp_path): + """PATCH rejects 'es'/'fr'/'de'/'xx' with 400 — ACTIVE_LANGUAGES gate.""" + from fastapi import HTTPException + from app.models import User + from app.routers.api import patch_language_prefs, LanguagePrefsIn + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=21, email="u2@x", tier="paid", lang="en")) + await session.commit() + + class _P: + is_admin = False + def __init__(self, u): self.user = u + + for bad in ("es", "fr", "de", "xx"): + async with factory() as session: + user = await session.get(User, 21) + with pytest.raises(HTTPException) as exc: + await patch_language_prefs( + payload=LanguagePrefsIn(lang=bad), + principal=_P(user), + session=session, + ) + assert exc.value.status_code == 400 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -k patch_language -v +``` + +Expected: 2 FAIL with `ImportError` for `patch_language_prefs` / `LanguagePrefsIn`. + +- [ ] **Step 3: Add the endpoint** + +In `app/routers/api.py`, near the existing `patch_digest_prefs` (around lines 868-897), add: + +```python +from app.services.i18n import ACTIVE_LANGUAGES + + +# --------------------------------------------------------------------------- +# Settings — language preference +# --------------------------------------------------------------------------- + + +class LanguagePrefsIn(BaseModel): + lang: str + + +class LanguagePrefsOut(BaseModel): + lang: str + + +@router.patch("/settings/language", response_model=LanguagePrefsOut) +async def patch_language_prefs( + payload: LanguagePrefsIn, + principal: CurrentUser = Depends(require_token), + session: AsyncSession = Depends(get_session), +) -> LanguagePrefsOut: + if principal.user is None: + raise HTTPException(status_code=400, detail="no_user_context") + lang = (payload.lang or "").strip().lower() + if lang not in ACTIVE_LANGUAGES: + raise HTTPException( + status_code=400, + detail=f"unsupported language: {payload.lang!r}", + ) + user = await session.get(User, principal.user.id) + if user is None: + raise HTTPException(status_code=404, detail="user_not_found") + user.lang = lang + await session.commit() + return LanguagePrefsOut(lang=lang) +``` + +(`User` and `BaseModel` are already imported at the top of `app/routers/api.py`. If `ACTIVE_LANGUAGES` import collides with anything else, alias it.) + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_localization_integration.py -v +``` + +Expected: all 12 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/routers/api.py tests/test_localization_integration.py +git commit -m "settings: PATCH /api/settings/language with ACTIVE_LANGUAGES gate" +``` + +--- + +### Task 10: Settings UI dropdown + +**Files:** +- Modify: `app/templates/settings.html` +- Modify: `app/routers/pages.py::settings_page` — pass `user.lang` to the template context + +- [ ] **Step 1: Add the language section to settings.html** + +In `app/templates/settings.html`, find the existing settings sections (`
` blocks). Add a new section next to the email-digest preferences: + +```html +
+ Language +

+ Language the AI uses for the strategic log, your daily digest, and + portfolio commentary. The interface itself stays in English for now. +

+
+ + +
+ +
+``` + +- [ ] **Step 2: Confirm `settings_page` passes `user.lang`** + +In `app/routers/pages.py::settings_page`, the template context already includes `user`. The template reads `user.lang` directly from that object. No code change required — the Jinja2 expression `{% if (user.lang or 'en') == 'it' %}` handles old rows whose `lang` field hasn't been populated yet (defensive, post-migration). + +If the existing template context does NOT pass the `user` object (it should, based on the digest-prefs section), add it. + +- [ ] **Step 3: Manual smoke verification step** + +Smoke is deferred to Task 11. No test step here — pure markup + small inline JS. + +- [ ] **Step 4: Commit** + +```bash +git add app/templates/settings.html +git commit -m "settings: add language dropdown (IT active, ES/FR/DE WIP)" +``` + +--- + +### Task 11: Final regression + deploy + manual smoke + +**Files:** +- (no code changes — verification only) + +- [ ] **Step 1: Full test suite** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/ 2>&1 | tail -5 +``` + +Expected: every previous test plus the new `tests/test_i18n.py` and `tests/test_localization_integration.py` pass. Total should now be ~280 passing. + +- [ ] **Step 2: Apply migration to prod DB (requires explicit user approval)** + +```bash +docker compose exec app alembic upgrade head +``` + +Expected: `Running upgrade 0021 -> 0022, localization`. + +- [ ] **Step 3: Restart prod app (requires explicit user approval)** + +```bash +docker compose restart app +docker compose logs app --tail 30 | grep -E "(Uvicorn|startup complete|ERROR|Traceback)" +``` + +Expected: `Application startup complete.` cleanly; no tracebacks. + +- [ ] **Step 4: Manual smoke — switch a paid test user to Italian** + +In a paid-tier browser session, open `/settings`. Confirm the Language dropdown appears with all five options, the English option selected, ES/FR/DE disabled and labelled "coming soon". Pick `Italiano`, confirm the inline `✓ saved` status appears. Refresh — Italian remains selected. + +- [ ] **Step 5: Manual smoke — strategic log translation** + +Wait for the next hourly `ai_log_job` tick (or trigger via the scheduler/admin). Confirm a row appears in `strategic_log_translations` with `lang='it'`. NOTE: this requires a prod DB read; only run with explicit user approval: + +```bash +docker compose exec app python -c " +import asyncio +from sqlalchemy import select +from app.db import get_session_factory +from app.models import StrategicLogTranslation + +async def main(): + factory = get_session_factory() + async with factory() as s: + rows = (await s.execute( + select(StrategicLogTranslation).order_by(StrategicLogTranslation.id.desc()).limit(3) + )).scalars().all() + for r in rows: + print(r.id, r.log_id, r.lang, r.llm_model, r.llm_cost_usd, r.content_md[:80]) +asyncio.run(main()) +" +``` + +Expected: at least one row with `lang='it'`, `llm_model` containing `deepseek`, `llm_cost_usd` a small positive number. + +- [ ] **Step 6: Manual smoke — portfolio analysis** + +On the dashboard as the Italian user, click "Analyse" (or whatever triggers `/api/analyze`). Confirm the rendered AI commentary is in Italian. + +- [ ] **Step 7: Manual smoke — email digest** + +```bash +docker compose exec app python -m app.cli send-test-digest daily +``` + +Expected: digest email lands in Italian, including the subject line. + +- [ ] **Step 8: Manual smoke — Edge cases** + +- Direct `curl -X PATCH /api/settings/language` with `{"lang": "es"}` → 400. +- Switch user back to English, refresh dashboard — log renders in English again. + +--- + +## Self-Review + +**Spec coverage walkthrough:** + +- **`users.lang` column with default 'en'** → Task 3 model + Task 4 migration +- **`strategic_log_translations` table** → Task 3 model + Task 4 migration +- **`i18n.LANGUAGES` + `ACTIVE_LANGUAGES`** → Task 1 +- **`respond_in_clause()`** → Task 1 +- **`translate()` helper** → Task 2 (no-op fast path for `en`/unknown; code-fence stripping; raises on provider failure) +- **`ai_log_job` translation fan-out** → Task 5 (parallel via `asyncio.gather`; per-language failure isolated) +- **Portfolio analysis `lang`-aware system prompt** → Task 6 +- **Email digest `lang`-aware per-user generation** → Task 7 +- **`/log` localized fetch with English fallback** → Task 8 +- **`PATCH /api/settings/language` with ACTIVE_LANGUAGES gate** → Task 9 +- **Settings dropdown with IT active + ES/FR/DE disabled** → Task 10 +- **No tier gating on translation** → Task 5 query selects on `User.lang` only, no `tier` filter +- **No retroactive backfill** → not built; only forward-going translations +- **No UI label translation** → out of scope, Task 10 surfaces this in the section copy + +**Type / signature consistency:** + +- `respond_in_clause(lang: str | None) -> str` — used in Tasks 6, 7. Consistent. +- `translate(client, text, target_lang) -> tuple[str, LogResult]` — used in Tasks 2, 5. Consistent. +- `ACTIVE_LANGUAGES: set[str]` — used in Tasks 5, 9. Consistent. +- `LanguagePrefsIn { lang: str }` / `LanguagePrefsOut { lang: str }` — used in Task 9 only. +- `_resolve_log_content(session, log_id, lang) -> str` — used in Task 8 only. +- `translate_log_for_active_languages(session, log_id) -> None` — used in Task 5 only. + +**Note on Task 7:** if `_generate_variants` is currently called ONCE for all users in the digest job (variants shared), the localization plan requires it to be called per-user. The plan flags this and asks the engineer to surface a concern rather than silently restructuring. If the structure differs from expectation, the engineer should escalate before proceeding.