models: add User.lang + StrategicLogTranslation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7683f82820
commit
9423fa81b7
2 changed files with 93 additions and 0 deletions
|
|
@ -120,6 +120,37 @@ class StrategicLog(Base):
|
||||||
cost_usd: Mapped[float | None] = mapped_column(Float)
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IndicatorSummary(Base):
|
class IndicatorSummary(Base):
|
||||||
"""Short AI-generated read for one indicator group, regenerated hourly.
|
"""Short AI-generated read for one indicator group, regenerated hourly.
|
||||||
The latest row per group_name is what the dashboard renders."""
|
The latest row per group_name is what the dashboard renders."""
|
||||||
|
|
@ -189,6 +220,13 @@ class User(Base):
|
||||||
# NULL = use INTERMEDIATE at render time. Server-side mirror of the
|
# NULL = use INTERMEDIATE at render time. Server-side mirror of the
|
||||||
# dashboard tone, decoupled because the dashboard pref is localStorage.
|
# dashboard tone, decoupled because the dashboard pref is localStorage.
|
||||||
digest_tone: Mapped[str | None] = mapped_column(String(16))
|
digest_tone: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
# Polar (MoR) linkage — populated by the polar_webhook handler the
|
# Polar (MoR) linkage — populated by the polar_webhook handler the
|
||||||
# first time we see a subscription/order event for the user. The
|
# first time we see a subscription/order event for the user. The
|
||||||
# customer id is the stable join key; the subscription id is what
|
# customer id is the stable join key; the subscription id is what
|
||||||
|
|
|
||||||
55
tests/test_localization_integration.py
Normal file
55
tests/test_localization_integration.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue