Compare commits
69 commits
11662c0ea8
...
f9534f7ad6
| Author | SHA1 | Date | |
|---|---|---|---|
| f9534f7ad6 | |||
| a6e476b851 | |||
| cd485fe646 | |||
| 385c5fdc60 | |||
| 788563a81f | |||
| 8b9d3c9c3e | |||
| 0550063316 | |||
| 45fa31bb2b | |||
| 19d4854f50 | |||
| 8347c90235 | |||
| a55168d20a | |||
| 6c4c711830 | |||
| 259146ecdc | |||
| fca05aef7a | |||
| 48f022b71b | |||
| 3e1a14f334 | |||
| 71155a67be | |||
| f57c863145 | |||
| 31a8efc27d | |||
| daa3f79a52 | |||
| 1a20f0a15b | |||
| 6459e8c43d | |||
| 8ec4ea1c72 | |||
| 5ceee96135 | |||
| b6da1983d3 | |||
| 2b3ea33884 | |||
| 4c1793e4e9 | |||
| f9f4f25ef7 | |||
| 83995e96c8 | |||
| c5fb4525f3 | |||
| 7348055d72 | |||
| 355593c4f7 | |||
| 78ce8c8b0d | |||
| 2b9cd875b4 | |||
| f9d448d57b | |||
| 74b61a59ed | |||
| 833d1775ab | |||
| b055eea1c2 | |||
| 4adc8dfe82 | |||
| a6d686324c | |||
| e4dc6d0071 | |||
| f4d9c9f2ec | |||
| dcc2c07111 | |||
| b13caa4c51 | |||
| eedd32b885 | |||
| 664757ea8a | |||
| 7acd191051 | |||
| eb31d09782 | |||
| 308878749f | |||
| b47c45e218 | |||
| a2bcb2c053 | |||
| 59900f126f | |||
| e807e58629 | |||
| fb71854238 | |||
| 50ac6b9366 | |||
| f4025e3cbb | |||
| 1ea71bc160 | |||
| 924f37548b | |||
| d318039ad5 | |||
| e4982cdc04 | |||
| e190d0e35b | |||
| 9423fa81b7 | |||
| 7683f82820 | |||
| 5730aad73c | |||
| 2ecf250d53 | |||
| 8af1da12dd | |||
| e6308260a5 | |||
| 76f81648e5 | |||
| 1ecc527118 |
88 changed files with 10351 additions and 4744 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,3 +16,4 @@ build/
|
||||||
dist/
|
dist/
|
||||||
.coverage
|
.coverage
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
.superpowers/
|
||||||
|
|
|
||||||
18
Dockerfile
18
Dockerfile
|
|
@ -6,11 +6,17 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml requirements.lock ./
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
# requirements.lock pins every transitive dependency to the known-good
|
||||||
|
# versions captured by `pip freeze` against a clean install. Install
|
||||||
|
# from it first, then add the project itself with --no-deps so the
|
||||||
|
# lockfile is the single source of truth and pyproject's range pins
|
||||||
|
# (>=) can't drift on rebuild.
|
||||||
RUN python -m venv /opt/venv \
|
RUN python -m venv /opt/venv \
|
||||||
&& /opt/venv/bin/pip install --upgrade pip \
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
&& /opt/venv/bin/pip install .
|
&& /opt/venv/bin/pip install -r requirements.lock \
|
||||||
|
&& /opt/venv/bin/pip install --no-deps .
|
||||||
|
|
||||||
FROM python:3.13-slim AS runtime
|
FROM python:3.13-slim AS runtime
|
||||||
|
|
||||||
|
|
@ -49,7 +55,7 @@ ENV PYTHONUNBUFFERED=1 \
|
||||||
|
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml requirements.lock ./
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY alembic ./alembic
|
COPY alembic ./alembic
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
|
|
@ -57,6 +63,10 @@ COPY alembic.ini ./
|
||||||
# a shipped image). docker-compose.test.yml bind-mounts ./tests:/app/tests
|
# a shipped image). docker-compose.test.yml bind-mounts ./tests:/app/tests
|
||||||
# at run time, so the suite is always available without baking it in.
|
# at run time, so the suite is always available without baking it in.
|
||||||
|
|
||||||
RUN /opt/venv/bin/pip install ".[dev]"
|
# The lockfile already contains the dev extras (pytest, ruff, aiosqlite,
|
||||||
|
# ...) because it was generated against a test-stage install. Same
|
||||||
|
# install pattern as the builder stage: lockfile first, project --no-deps.
|
||||||
|
RUN /opt/venv/bin/pip install -r requirements.lock \
|
||||||
|
&& /opt/venv/bin/pip install --no-deps .
|
||||||
|
|
||||||
CMD ["pytest", "tests/", "-v"]
|
CMD ["pytest", "tests/", "-v"]
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,17 @@ def run_migrations_offline() -> None:
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> None:
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
# render_as_batch is required for SQLite, which doesn't support
|
||||||
|
# most ALTER COLUMN / ADD CONSTRAINT operations natively. With
|
||||||
|
# batch mode enabled, Alembic emits a copy-and-rename dance under
|
||||||
|
# SQLite while still producing plain ALTER on MariaDB / Postgres,
|
||||||
|
# so prod migrations are unchanged. Detect via the dialect name.
|
||||||
|
render_as_batch = connection.dialect.name == "sqlite"
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
compare_type=True,
|
compare_type=True,
|
||||||
|
render_as_batch=render_as_batch,
|
||||||
)
|
)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,14 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.alter_column(
|
# batch_alter_table wraps the ALTER in a copy-and-rename dance for
|
||||||
"quotes", "symbol",
|
# SQLite (which doesn't support ALTER COLUMN TYPE) while remaining a
|
||||||
|
# plain ALTER on MariaDB / Postgres. Required for `alembic upgrade
|
||||||
|
# head` to work against a fresh SQLite database during local tooling
|
||||||
|
# or test bootstrap.
|
||||||
|
with op.batch_alter_table("quotes") as bop:
|
||||||
|
bop.alter_column(
|
||||||
|
"symbol",
|
||||||
existing_type=sa.String(64),
|
existing_type=sa.String(64),
|
||||||
type_=sa.String(128),
|
type_=sa.String(128),
|
||||||
existing_nullable=False,
|
existing_nullable=False,
|
||||||
|
|
@ -26,8 +32,9 @@ def upgrade() -> None:
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.alter_column(
|
with op.batch_alter_table("quotes") as bop:
|
||||||
"quotes", "symbol",
|
bop.alter_column(
|
||||||
|
"symbol",
|
||||||
existing_type=sa.String(128),
|
existing_type=sa.String(128),
|
||||||
type_=sa.String(64),
|
type_=sa.String(64),
|
||||||
existing_nullable=False,
|
existing_nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -30,20 +30,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
# batch_alter_table wraps ADD CONSTRAINT in a copy-and-rename for
|
||||||
"users",
|
# SQLite (no native ALTER constraints support); on MariaDB/Postgres
|
||||||
sa.Column("referral_code", sa.String(16), nullable=True),
|
# it falls through to plain ALTER statements.
|
||||||
|
with op.batch_alter_table("users") as bop:
|
||||||
|
bop.add_column(sa.Column("referral_code", sa.String(16), nullable=True))
|
||||||
|
bop.create_unique_constraint(
|
||||||
|
"uq_users_referral_code", ["referral_code"],
|
||||||
)
|
)
|
||||||
op.create_unique_constraint(
|
bop.add_column(sa.Column("referred_by_user_id", sa.Integer, nullable=True))
|
||||||
"uq_users_referral_code", "users", ["referral_code"],
|
bop.create_foreign_key(
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("referred_by_user_id", sa.Integer, nullable=True),
|
|
||||||
)
|
|
||||||
op.create_foreign_key(
|
|
||||||
"fk_users_referred_by",
|
"fk_users_referred_by",
|
||||||
"users", "users",
|
"users",
|
||||||
["referred_by_user_id"], ["id"],
|
["referred_by_user_id"], ["id"],
|
||||||
ondelete="SET NULL",
|
ondelete="SET NULL",
|
||||||
)
|
)
|
||||||
|
|
@ -71,7 +69,8 @@ def upgrade() -> None:
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_index("ix_referrals_referrer", table_name="referrals")
|
op.drop_index("ix_referrals_referrer", table_name="referrals")
|
||||||
op.drop_table("referrals")
|
op.drop_table("referrals")
|
||||||
op.drop_constraint("fk_users_referred_by", "users", type_="foreignkey")
|
with op.batch_alter_table("users") as bop:
|
||||||
op.drop_column("users", "referred_by_user_id")
|
bop.drop_constraint("fk_users_referred_by", type_="foreignkey")
|
||||||
op.drop_constraint("uq_users_referral_code", "users", type_="unique")
|
bop.drop_column("referred_by_user_id")
|
||||||
op.drop_column("users", "referral_code")
|
bop.drop_constraint("uq_users_referral_code", type_="unique")
|
||||||
|
bop.drop_column("referral_code")
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,11 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
with op.batch_alter_table("users") as bop:
|
||||||
"users",
|
bop.add_column(sa.Column("polar_customer_id", sa.String(length=64), nullable=True))
|
||||||
sa.Column("polar_customer_id", sa.String(length=64), nullable=True),
|
bop.add_column(sa.Column("polar_subscription_id", sa.String(length=64), nullable=True))
|
||||||
)
|
bop.create_unique_constraint(
|
||||||
op.add_column(
|
"uq_users_polar_customer", ["polar_customer_id"],
|
||||||
"users",
|
|
||||||
sa.Column("polar_subscription_id", sa.String(length=64), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_unique_constraint(
|
|
||||||
"uq_users_polar_customer", "users", ["polar_customer_id"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|
@ -50,6 +45,7 @@ def upgrade() -> None:
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_index("ix_polar_events_type_received", table_name="polar_events")
|
op.drop_index("ix_polar_events_type_received", table_name="polar_events")
|
||||||
op.drop_table("polar_events")
|
op.drop_table("polar_events")
|
||||||
op.drop_constraint("uq_users_polar_customer", "users", type_="unique")
|
with op.batch_alter_table("users") as bop:
|
||||||
op.drop_column("users", "polar_subscription_id")
|
bop.drop_constraint("uq_users_polar_customer", type_="unique")
|
||||||
|
bop.drop_column("polar_subscription_id")
|
||||||
op.drop_column("users", "polar_customer_id")
|
op.drop_column("users", "polar_customer_id")
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,11 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
with op.batch_alter_table("users") as bop:
|
||||||
"users",
|
bop.add_column(sa.Column("stripe_customer_id", sa.String(length=64), nullable=True))
|
||||||
sa.Column("stripe_customer_id", sa.String(length=64), nullable=True),
|
bop.add_column(sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True))
|
||||||
)
|
bop.create_unique_constraint(
|
||||||
op.add_column(
|
"uq_users_stripe_customer", ["stripe_customer_id"],
|
||||||
"users",
|
|
||||||
sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_unique_constraint(
|
|
||||||
"uq_users_stripe_customer", "users", ["stripe_customer_id"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|
@ -51,6 +46,7 @@ def upgrade() -> None:
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_index("ix_stripe_events_type_received", table_name="stripe_events")
|
op.drop_index("ix_stripe_events_type_received", table_name="stripe_events")
|
||||||
op.drop_table("stripe_events")
|
op.drop_table("stripe_events")
|
||||||
op.drop_constraint("uq_users_stripe_customer", "users", type_="unique")
|
with op.batch_alter_table("users") as bop:
|
||||||
op.drop_column("users", "stripe_subscription_id")
|
bop.drop_constraint("uq_users_stripe_customer", type_="unique")
|
||||||
op.drop_column("users", "stripe_customer_id")
|
bop.drop_column("stripe_subscription_id")
|
||||||
|
bop.drop_column("stripe_customer_id")
|
||||||
|
|
|
||||||
46
alembic/versions/0022_localization.py
Normal file
46
alembic/versions/0022_localization.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""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")
|
||||||
38
alembic/versions/0023_lang_index_and_qd_symbol_widen.py
Normal file
38
alembic/versions/0023_lang_index_and_qd_symbol_widen.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""users.lang index + widen quotes_daily.symbol to VARCHAR(128).
|
||||||
|
|
||||||
|
Revision ID: 0023
|
||||||
|
Revises: 0022
|
||||||
|
Create Date: 2026-05-27
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0023"
|
||||||
|
down_revision: Union[str, None] = "0022"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index("ix_users_lang", "users", ["lang"])
|
||||||
|
with op.batch_alter_table("quotes_daily") as bop:
|
||||||
|
bop.alter_column(
|
||||||
|
"symbol",
|
||||||
|
existing_type=sa.String(length=64),
|
||||||
|
type_=sa.String(length=128),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("quotes_daily") as bop:
|
||||||
|
bop.alter_column(
|
||||||
|
"symbol",
|
||||||
|
existing_type=sa.String(length=128),
|
||||||
|
type_=sa.String(length=64),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
op.drop_index("ix_users_lang", table_name="users")
|
||||||
38
alembic/versions/0024_ind_summary_translations.py
Normal file
38
alembic/versions/0024_ind_summary_translations.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""indicator_summary_translations table.
|
||||||
|
|
||||||
|
Revision ID: 0024
|
||||||
|
Revises: 0023
|
||||||
|
Create Date: 2026-05-27
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0024"
|
||||||
|
down_revision: Union[str, None] = "0023"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"indicator_summary_translations",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("summary_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(
|
||||||
|
["summary_id"], ["indicator_summaries.id"],
|
||||||
|
ondelete="CASCADE", name="fk_ist_summary",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("indicator_summary_translations")
|
||||||
79
alembic/versions/0025_align_translation_columns.py
Normal file
79
alembic/versions/0025_align_translation_columns.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""align translation column naming + add token counts.
|
||||||
|
|
||||||
|
Revision ID: 0025
|
||||||
|
Revises: 0024
|
||||||
|
Create Date: 2026-05-27
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0025"
|
||||||
|
down_revision: Union[str, None] = "0024"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# strategic_log_translations
|
||||||
|
with op.batch_alter_table("strategic_log_translations") as bop:
|
||||||
|
bop.alter_column("llm_model", new_column_name="model",
|
||||||
|
existing_type=sa.String(length=64), existing_nullable=True)
|
||||||
|
bop.alter_column("llm_cost_usd", new_column_name="cost_usd",
|
||||||
|
existing_type=sa.Float(), existing_nullable=True)
|
||||||
|
bop.alter_column("content_md", new_column_name="content",
|
||||||
|
existing_type=sa.Text(), existing_nullable=False)
|
||||||
|
bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True))
|
||||||
|
bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# indicator_summary_translations
|
||||||
|
with op.batch_alter_table("indicator_summary_translations") as bop:
|
||||||
|
bop.alter_column("llm_model", new_column_name="model",
|
||||||
|
existing_type=sa.String(length=64), existing_nullable=True)
|
||||||
|
bop.alter_column("llm_cost_usd", new_column_name="cost_usd",
|
||||||
|
existing_type=sa.Float(), existing_nullable=True)
|
||||||
|
bop.alter_column("content_md", new_column_name="content",
|
||||||
|
existing_type=sa.Text(), existing_nullable=False)
|
||||||
|
bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True))
|
||||||
|
bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# csv_format_templates
|
||||||
|
with op.batch_alter_table("csv_format_templates") as bop:
|
||||||
|
bop.alter_column("llm_model", new_column_name="model",
|
||||||
|
existing_type=sa.String(length=64), existing_nullable=True)
|
||||||
|
bop.alter_column("llm_cost_usd", new_column_name="cost_usd",
|
||||||
|
existing_type=sa.Float(), existing_nullable=True)
|
||||||
|
bop.add_column(sa.Column("prompt_tokens", sa.Integer(), nullable=True))
|
||||||
|
bop.add_column(sa.Column("completion_tokens", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("csv_format_templates") as bop:
|
||||||
|
bop.drop_column("completion_tokens")
|
||||||
|
bop.drop_column("prompt_tokens")
|
||||||
|
bop.alter_column("cost_usd", new_column_name="llm_cost_usd",
|
||||||
|
existing_type=sa.Float(), existing_nullable=True)
|
||||||
|
bop.alter_column("model", new_column_name="llm_model",
|
||||||
|
existing_type=sa.String(length=64), existing_nullable=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table("indicator_summary_translations") as bop:
|
||||||
|
bop.drop_column("completion_tokens")
|
||||||
|
bop.drop_column("prompt_tokens")
|
||||||
|
bop.alter_column("content", new_column_name="content_md",
|
||||||
|
existing_type=sa.Text(), existing_nullable=False)
|
||||||
|
bop.alter_column("cost_usd", new_column_name="llm_cost_usd",
|
||||||
|
existing_type=sa.Float(), existing_nullable=True)
|
||||||
|
bop.alter_column("model", new_column_name="llm_model",
|
||||||
|
existing_type=sa.String(length=64), existing_nullable=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table("strategic_log_translations") as bop:
|
||||||
|
bop.drop_column("completion_tokens")
|
||||||
|
bop.drop_column("prompt_tokens")
|
||||||
|
bop.alter_column("content", new_column_name="content_md",
|
||||||
|
existing_type=sa.Text(), existing_nullable=False)
|
||||||
|
bop.alter_column("cost_usd", new_column_name="llm_cost_usd",
|
||||||
|
existing_type=sa.Float(), existing_nullable=True)
|
||||||
|
bop.alter_column("model", new_column_name="llm_model",
|
||||||
|
existing_type=sa.String(length=64), existing_nullable=True)
|
||||||
|
|
@ -7,13 +7,13 @@ into user-visible chrome (page titles, email headers, OpenRouter referer)
|
||||||
must read `BRAND_NAME` from here; do not hard-code the string.
|
must read `BRAND_NAME` from here; do not hard-code the string.
|
||||||
|
|
||||||
Internal identifiers (`cassandra_session` cookie, pyproject package name,
|
Internal identifiers (`cassandra_session` cookie, pyproject package name,
|
||||||
SQLAlchemy GET_LOCK keys, file `cassandra.css`, env var `CASSANDRA_TOKEN`)
|
SQLAlchemy GET_LOCK keys, env var `CASSANDRA_TOKEN`) keep the legacy
|
||||||
keep the legacy name on purpose — renaming them would invalidate live
|
name on purpose — renaming them would invalidate live sessions /
|
||||||
sessions / advisory locks / configs for zero brand benefit.
|
advisory locks / configs for zero brand benefit.
|
||||||
|
|
||||||
The colour palette below is hand-authored in CSS as well; a drift-
|
The colour palette below is hand-authored in CSS as well; a drift-
|
||||||
detection test (`tests/test_branding_consistency.py`) parses
|
detection test (`tests/test_branding_consistency.py`) parses
|
||||||
`cassandra.css` and asserts every variable matches. Update both or
|
`tokens.css` and asserts every variable matches. Update both or
|
||||||
neither.
|
neither.
|
||||||
|
|
||||||
The light theme is the *default* everywhere — dashboard `:root` block,
|
The light theme is the *default* everywhere — dashboard `:root` block,
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# App
|
# App
|
||||||
CASSANDRA_TOKEN: str = ""
|
CASSANDRA_TOKEN: str = ""
|
||||||
CASSANDRA_PORT: int = 8000
|
|
||||||
# Signing key for session cookies. Generate with:
|
# Signing key for session cookies. Generate with:
|
||||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev).
|
# Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev).
|
||||||
|
|
@ -59,9 +58,7 @@ class Settings(BaseSettings):
|
||||||
SMTP_PASSWORD: str = ""
|
SMTP_PASSWORD: str = ""
|
||||||
SMTP_USE_TLS: bool = True
|
SMTP_USE_TLS: bool = True
|
||||||
SMTP_FROM: str = "" # Defaults to SMTP_USER if blank
|
SMTP_FROM: str = "" # Defaults to SMTP_USER if blank
|
||||||
CASSANDRA_BASE_CURRENCY: str = "GBP"
|
|
||||||
CASSANDRA_ANCHOR_DATE: str = ""
|
CASSANDRA_ANCHOR_DATE: str = ""
|
||||||
CASSANDRA_MOCK: bool = False
|
|
||||||
|
|
||||||
# Server-side pepper for the cloud-sync outer wrap. Generate with:
|
# Server-side pepper for the cloud-sync outer wrap. Generate with:
|
||||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|
@ -97,7 +94,6 @@ class Settings(BaseSettings):
|
||||||
# env var. Empty = webhook endpoint refuses with 503 (so a misconfig
|
# env var. Empty = webhook endpoint refuses with 503 (so a misconfig
|
||||||
# is loud rather than silently accepting unsigned events).
|
# is loud rather than silently accepting unsigned events).
|
||||||
POLAR_WEBHOOK_SECRET: str = ""
|
POLAR_WEBHOOK_SECRET: str = ""
|
||||||
POLAR_API_KEY: str = ""
|
|
||||||
|
|
||||||
# Stripe (merchant-on-record for read.markets after Polar/Paddle
|
# Stripe (merchant-on-record for read.markets after Polar/Paddle
|
||||||
# both declined the financial-media category). Test-mode keys are
|
# both declined the financial-media category). Test-mode keys are
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ async def latest_quotes_by_group(session) -> dict[str, list[dict]]:
|
||||||
& (Quote.symbol == sub.c.symbol)
|
& (Quote.symbol == sub.c.symbol)
|
||||||
& (Quote.fetched_at == sub.c.mx),
|
& (Quote.fetched_at == sub.c.mx),
|
||||||
)
|
)
|
||||||
|
.where(Quote.price.is_not(None))
|
||||||
.order_by(Quote.group_name, Quote.symbol)
|
.order_by(Quote.group_name, Quote.symbol)
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).scalars().all()
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,91 @@ from app.jobs._market_context import (
|
||||||
month_spend,
|
month_spend,
|
||||||
recent_headlines_by_bucket,
|
recent_headlines_by_bucket,
|
||||||
)
|
)
|
||||||
from app.models import AICall, JobRun, StrategicLog
|
from app.models import AICall, JobRun, StrategicLog, StrategicLogTranslation, User
|
||||||
from app.services.cadence import DEFAULT_POLICY
|
from app.services.cadence import DEFAULT_POLICY
|
||||||
from app.services.openrouter import (
|
from app.services.i18n import ACTIVE_LANGUAGES
|
||||||
|
from app.services.llm_prompts import (
|
||||||
PROMPT_VERSION,
|
PROMPT_VERSION,
|
||||||
active_model,
|
|
||||||
build_system_prompt,
|
build_system_prompt,
|
||||||
build_user_prompt,
|
build_user_prompt,
|
||||||
|
)
|
||||||
|
from app.services.output_review import review_read
|
||||||
|
from app.services.openrouter import (
|
||||||
|
active_model,
|
||||||
call_llm,
|
call_llm,
|
||||||
llm_configured,
|
llm_configured,
|
||||||
)
|
)
|
||||||
|
from app.services.translation import translate
|
||||||
|
|
||||||
|
|
||||||
|
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. Each row is committed in its own
|
||||||
|
savepoint so a per-language LLM error or DB error doesn't roll back
|
||||||
|
the languages that already succeeded.
|
||||||
|
|
||||||
|
The job orchestrator calls this AFTER the English ``StrategicLog``
|
||||||
|
row is committed; pass the row's ``id`` in.
|
||||||
|
"""
|
||||||
|
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, lang)
|
||||||
|
for lang in active_langs
|
||||||
|
], return_exceptions=True)
|
||||||
|
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
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])
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
translated_md, llm_result = result
|
||||||
|
try:
|
||||||
|
async with session.begin_nested():
|
||||||
|
session.add(StrategicLogTranslation(
|
||||||
|
log_id=log_id, lang=lang,
|
||||||
|
content=translated_md,
|
||||||
|
generated_at=utcnow(),
|
||||||
|
model=llm_result.model,
|
||||||
|
prompt_tokens=llm_result.prompt_tokens,
|
||||||
|
completion_tokens=llm_result.completion_tokens,
|
||||||
|
cost_usd=llm_result.cost_usd,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
succeeded += 1
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("log.translate.persist_failed",
|
||||||
|
lang=lang, log_id=log_id, error=str(exc)[:200])
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if failed and succeeded == 0:
|
||||||
|
log.error("log.translate.all_failed",
|
||||||
|
log_id=log_id, attempted=len(active_langs))
|
||||||
|
else:
|
||||||
|
log.info("log.translate.done",
|
||||||
|
log_id=log_id, succeeded=succeeded, failed=failed)
|
||||||
|
|
||||||
|
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
|
|
@ -126,7 +201,28 @@ async def run() -> None:
|
||||||
tone=tone, analysis=analysis, error=str(e)[:200])
|
tone=tone, analysis=analysis, error=str(e)[:200])
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session.add(StrategicLog(
|
# Reviewer gate: catches chain-of-thought, truncation,
|
||||||
|
# and (regulatory-critical) any financial-advice phrasing
|
||||||
|
# that drifted past the generator's system prompt. Drop
|
||||||
|
# rejected variants; the API falls back to the previous
|
||||||
|
# clean StrategicLog row.
|
||||||
|
verdict = await review_read(client, result.content)
|
||||||
|
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
|
||||||
|
if not verdict.clean:
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=full_cost, status="leaked",
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
log.warning("ai_log.reviewer_rejected",
|
||||||
|
tone=tone, analysis=analysis,
|
||||||
|
reason=verdict.reason,
|
||||||
|
preview=result.content[:120])
|
||||||
|
continue
|
||||||
|
|
||||||
|
slog = StrategicLog(
|
||||||
generated_at=utcnow(),
|
generated_at=utcnow(),
|
||||||
model=result.model,
|
model=result.model,
|
||||||
anchor_date=anchor,
|
anchor_date=anchor,
|
||||||
|
|
@ -136,16 +232,18 @@ async def run() -> None:
|
||||||
content=result.content,
|
content=result.content,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd,
|
cost_usd=full_cost,
|
||||||
))
|
)
|
||||||
|
session.add(slog)
|
||||||
session.add(AICall(
|
session.add(AICall(
|
||||||
model=result.model,
|
model=result.model,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd,
|
cost_usd=full_cost,
|
||||||
status="ok",
|
status="ok",
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await translate_log_for_active_languages(session, slog.id)
|
||||||
written += 1
|
written += 1
|
||||||
log.info("ai_log.variant_done",
|
log.info("ai_log.variant_done",
|
||||||
tone=tone, analysis=analysis,
|
tone=tone, analysis=analysis,
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,20 @@ from app.jobs._market_context import (
|
||||||
from app.models import EmailSend, User
|
from app.models import EmailSend, User
|
||||||
from app.routers.email import sign_unsubscribe_token
|
from app.routers.email import sign_unsubscribe_token
|
||||||
from app.services.access import paid_status
|
from app.services.access import paid_status
|
||||||
from app.services.email_service import render_digest_email, send_email
|
from app.services.digest_email import render_digest_email
|
||||||
from app.services.openrouter import (
|
from app.services.email_service import send_email
|
||||||
|
from app.services.i18n import ACTIVE_LANGUAGES
|
||||||
|
from app.services.llm_prompts import (
|
||||||
PROMPT_VERSION,
|
PROMPT_VERSION,
|
||||||
build_daily_digest_prompt,
|
build_daily_digest_prompt,
|
||||||
build_weekly_digest_prompt,
|
build_weekly_digest_prompt,
|
||||||
|
)
|
||||||
|
from app.services.openrouter import (
|
||||||
call_llm,
|
call_llm,
|
||||||
llm_configured,
|
llm_configured,
|
||||||
)
|
)
|
||||||
|
from app.services.output_review import review_read
|
||||||
|
from app.services.translation import translate
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
|
|
@ -88,12 +94,31 @@ async def _generate_variants(session, client, kind: str, ctx: dict) -> dict[str,
|
||||||
[{"role": "system", "content": sys_},
|
[{"role": "system", "content": sys_},
|
||||||
{"role": "user", "content": usr}],
|
{"role": "user", "content": usr}],
|
||||||
)
|
)
|
||||||
|
# Reviewer gate. Digest emails land in inboxes — once
|
||||||
|
# delivered they're unrecallable, so a financial-advice slip
|
||||||
|
# has more reach than the dashboard. Drop rejected variants;
|
||||||
|
# users on that tone get no digest this cycle (better than
|
||||||
|
# delivering bad copy).
|
||||||
|
verdict = await review_read(client, result.content)
|
||||||
|
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
|
||||||
|
if not verdict.clean:
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=full_cost, status="leaked",
|
||||||
|
error=f"reviewer: {verdict.reason}",
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
log.warning("digest.reviewer_rejected", kind=kind, tone=tone,
|
||||||
|
reason=verdict.reason, preview=result.content[:120])
|
||||||
|
continue
|
||||||
out[tone] = result.content
|
out[tone] = result.content
|
||||||
session.add(AICall(
|
session.add(AICall(
|
||||||
model=result.model,
|
model=result.model,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd,
|
cost_usd=full_cost,
|
||||||
status="ok",
|
status="ok",
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -116,6 +141,62 @@ def _kind_for_today(today: datetime) -> str:
|
||||||
return "weekly" if today.weekday() == 6 else "daily"
|
return "weekly" if today.weekday() == 6 else "daily"
|
||||||
|
|
||||||
|
|
||||||
|
async def _translate_variants_for_active_langs(
|
||||||
|
client,
|
||||||
|
english_variants: dict[str, str],
|
||||||
|
target_langs: list[str],
|
||||||
|
) -> dict[tuple[str, str], str]:
|
||||||
|
"""Build a {(tone, lang): content_md} table.
|
||||||
|
|
||||||
|
Starts with the English variants as the canonical cells. For each
|
||||||
|
(tone, target_lang) pair where target_lang != 'en', calls translate()
|
||||||
|
in parallel; on failure the cell falls back to the English variant
|
||||||
|
of the same tone so the digest still goes out, just untranslated.
|
||||||
|
"""
|
||||||
|
table: dict[tuple[str, str], str] = {
|
||||||
|
(tone, "en"): content for tone, content in english_variants.items()
|
||||||
|
}
|
||||||
|
pairs = [
|
||||||
|
(tone, lang)
|
||||||
|
for tone in english_variants
|
||||||
|
for lang in target_langs
|
||||||
|
if lang != "en"
|
||||||
|
]
|
||||||
|
if not pairs:
|
||||||
|
return table
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[
|
||||||
|
translate(client, english_variants[tone], lang) for tone, lang in pairs
|
||||||
|
], return_exceptions=True)
|
||||||
|
for (tone, lang), result in zip(pairs, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
log.warning("digest.translate.failed",
|
||||||
|
tone=tone, lang=lang, error=str(result)[:200])
|
||||||
|
table[(tone, lang)] = english_variants[tone]
|
||||||
|
continue
|
||||||
|
translated_md, _llm_log = result
|
||||||
|
table[(tone, lang)] = translated_md
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_variant(
|
||||||
|
table: dict[tuple[str, str], str], tone: str, lang: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return the digest content for a recipient.
|
||||||
|
|
||||||
|
Lookup order: exact (tone, lang) → (tone, 'en') → ('INTERMEDIATE',
|
||||||
|
'en') → first table value. The last falls are defensive; the table
|
||||||
|
always contains at least one English entry when the job is sending.
|
||||||
|
"""
|
||||||
|
if (tone, lang) in table:
|
||||||
|
return table[(tone, lang)]
|
||||||
|
if (tone, "en") in table:
|
||||||
|
return table[(tone, "en")]
|
||||||
|
if ("INTERMEDIATE", "en") in table:
|
||||||
|
return table[("INTERMEDIATE", "en")]
|
||||||
|
return next(iter(table.values()))
|
||||||
|
|
||||||
|
|
||||||
async def _send_one(user: User, kind: str, content_html: str, date_str: str,
|
async def _send_one(user: User, kind: str, content_html: str, date_str: str,
|
||||||
session) -> None:
|
session) -> None:
|
||||||
settings_url = f"{branding.SITE_URL}/settings"
|
settings_url = f"{branding.SITE_URL}/settings"
|
||||||
|
|
@ -200,17 +281,21 @@ async def run() -> None:
|
||||||
jr.error = "all variants failed"
|
jr.error = "all variants failed"
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Build the per-language translation table once per job run.
|
||||||
|
active_non_en = sorted({l for l in ACTIVE_LANGUAGES if l != "en"})
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
variant_table = await _translate_variants_for_active_langs(
|
||||||
|
client, variants, active_non_en,
|
||||||
|
)
|
||||||
|
|
||||||
written = 0
|
written = 0
|
||||||
for u in fresh:
|
for u in fresh:
|
||||||
tone = (u.digest_tone or "INTERMEDIATE").upper()
|
tone = (u.digest_tone or "INTERMEDIATE").upper()
|
||||||
# Fall back to INTERMEDIATE first (the more common tone) and then
|
content = _pick_variant(
|
||||||
# to whatever variant succeeded, so an asymmetric LLM failure
|
variant_table,
|
||||||
# doesn't silently skip the user.
|
tone=tone,
|
||||||
content = (variants.get(tone)
|
lang=(u.lang or "en"),
|
||||||
or variants.get("INTERMEDIATE")
|
)
|
||||||
or next(iter(variants.values()), None))
|
|
||||||
if content is None:
|
|
||||||
continue
|
|
||||||
await _send_one(u, kind, content, date_str, session)
|
await _send_one(u, kind, content, date_str, session)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
written += 1
|
written += 1
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ hourly stays comfortably under the monthly cap."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import json
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, select
|
||||||
|
|
@ -13,169 +12,146 @@ from sqlalchemy import desc, func, select
|
||||||
from app.config import get_settings, load_groups
|
from app.config import get_settings, load_groups
|
||||||
from app.db import utcnow
|
from app.db import utcnow
|
||||||
from app.jobs._helpers import job_lifecycle, log
|
from app.jobs._helpers import job_lifecycle, log
|
||||||
from app.models import AICall, IndicatorSummary, JobRun, Quote
|
from app.jobs._market_context import latest_quotes_by_group, month_spend
|
||||||
|
from app.models import (
|
||||||
|
AICall,
|
||||||
|
IndicatorSummary,
|
||||||
|
IndicatorSummaryTranslation,
|
||||||
|
JobRun,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from app.services.cadence import DEFAULT_POLICY
|
from app.services.cadence import DEFAULT_POLICY
|
||||||
from app.services.openrouter import (
|
from app.services.i18n import ACTIVE_LANGUAGES
|
||||||
|
from app.services.llm_prompts import (
|
||||||
PROMPT_VERSION,
|
PROMPT_VERSION,
|
||||||
active_model,
|
|
||||||
build_aggregate_summary_system_prompt,
|
build_aggregate_summary_system_prompt,
|
||||||
build_aggregate_summary_user_prompt,
|
build_aggregate_summary_user_prompt,
|
||||||
build_summary_system_prompt,
|
build_summary_system_prompt,
|
||||||
build_summary_user_prompt,
|
build_summary_user_prompt,
|
||||||
|
)
|
||||||
|
from app.services.openrouter import (
|
||||||
|
active_model,
|
||||||
call_llm,
|
call_llm,
|
||||||
llm_configured,
|
llm_configured,
|
||||||
month_start,
|
month_start,
|
||||||
)
|
)
|
||||||
|
from app.services.output_review import review_read
|
||||||
|
from app.services.translation import translate
|
||||||
|
|
||||||
|
|
||||||
AGGREGATE_GROUP_NAME = "__all__"
|
AGGREGATE_GROUP_NAME = "__all__"
|
||||||
|
|
||||||
|
|
||||||
# Strip known meta-commentary openers the model sometimes leaks despite the
|
async def translate_summary_for_active_languages(session, summary_id: int) -> None:
|
||||||
# prompt's hard constraints. Each pattern matches one leading sentence.
|
"""Fan out per-language translations for one IndicatorSummary row.
|
||||||
_LEAK_PATTERNS = [
|
|
||||||
re.compile(p, re.IGNORECASE | re.DOTALL)
|
|
||||||
for p in (
|
|
||||||
# First-person meta — "I need to / I'll / I have to / I'm going to ..."
|
|
||||||
r"^i\s+(?:need|have|must|should|am going|'ll|will|shall|can|am)[^.]*\.\s*",
|
|
||||||
# "We need / we're / we are asked / we will ..."
|
|
||||||
r"^we\s+(?:need|are|'re|will|shall|can|should|must|have)[^.]*\.\s*",
|
|
||||||
r"^let\s+(?:me|us|'?s)[^.]*\.\s*",
|
|
||||||
r"^here['’]s[^.]*\.\s*",
|
|
||||||
r"^sure[,!]?\s[^.]*\.\s*",
|
|
||||||
r"^looking at[^.]*\.\s*",
|
|
||||||
r"^based on[^.]*\.\s*",
|
|
||||||
r"^to (?:address|answer|write|summarise|summarize)[^.]*\.\s*",
|
|
||||||
r"^first[,]?\s[^.]*\.\s*",
|
|
||||||
r"^the (?:user|data shows|reader|task|request|reader sees|instructions?)[^.]*\.\s*",
|
|
||||||
r"^summary[:.]\s*",
|
|
||||||
r"^key\s*[:\-—]\s*",
|
|
||||||
r"^must\s+(?:be|cite|explain|avoid|give|stay|provide)[^.]*\.\s*",
|
|
||||||
r"^should\s+(?:be|give|cite|explain|avoid|provide)[^.]*\.\s*",
|
|
||||||
r"^avoid[^.]*\.\s*",
|
|
||||||
r"^cite\s+at\s+most[^.]*\.\s*",
|
|
||||||
r"^be\s+(?:speculative|specific|concise|brief)[^.]*\.\s*",
|
|
||||||
r"^stay\s+on[^.]*\.\s*",
|
|
||||||
r"^okay[,]?\s+",
|
|
||||||
r"^alright[,]?\s+",
|
|
||||||
r"^thinking[^.]*\.\s*",
|
|
||||||
# Prompt-leak prefixes — the model echoes example framing or rule
|
|
||||||
# headers from the system prompt.
|
|
||||||
r"^(?:good|bad|positive|negative)\s+example\s*[:\-—]\s*",
|
|
||||||
r"^example\s+(?:good|bad)\s*[:\-—]\s*",
|
|
||||||
r"^example\s*[:\-—]\s*",
|
|
||||||
r"^reference\s+style\s*[:\-—]\s*",
|
|
||||||
# Prompt label echoes (markdown-style or plain-text)
|
|
||||||
r"^(?:hard\s+)?constraints?\s*[:\-—][^.\n]*[.\n]\s*",
|
|
||||||
r"^key\s+observations?\s*[:\-—]\s*",
|
|
||||||
r"^observations?\s*[:\-—]\s*",
|
|
||||||
r"^focus\s+on[^.]*\.\s*",
|
|
||||||
r"^output\s+the\s+read[^.]*\.\s*",
|
|
||||||
r"^plain\s+prose[^.]*\.\s*",
|
|
||||||
r"^the\s+indicators?[^.]*\.\s*", # "The indicators include..." / "The indicators are..."
|
|
||||||
r"^indicators?\s*[:\-—]\s*",
|
|
||||||
r"^data\s*[:\-—]\s*",
|
|
||||||
r"^analysis\s*[:\-—]\s*",
|
|
||||||
r"^interpretation\s*[:\-—]\s*",
|
|
||||||
r"^read\s*[:\-—]\s*",
|
|
||||||
r"^note\s*[:\-—]\s*",
|
|
||||||
# Sometimes the response gets wrapped in literal quotes
|
|
||||||
r"^[\"“'`]+",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
Mirrors ``ai_log_job.translate_log_for_active_languages``: reads the
|
||||||
_TRAILING_QUOTE = re.compile(r"[\"”'`]+\s*$")
|
distinct non-en ``users.lang`` set, translates the English content
|
||||||
|
once per active language in parallel via ``asyncio.gather``, and
|
||||||
# Tell-tale phrases that mean the model regurgitated the prompt as its
|
persists each result as an ``IndicatorSummaryTranslation`` row in
|
||||||
# "answer" — we'd rather show nothing than show this.
|
its own savepoint so one bad row doesn't lose the rest.
|
||||||
_LEAKAGE_FLAGS = (
|
"""
|
||||||
"≤60 words", "60 words", "must be under", "must cite", "must explain",
|
target_langs = sorted({l for l in ACTIVE_LANGUAGES if l != "en"})
|
||||||
"no meta-commentary", "no buy/sell", "horizon. ", "1-day moves",
|
if not target_langs:
|
||||||
"the instructions are", "instructions:", "constraints:", "hard constraints",
|
return
|
||||||
"good example", "bad example", "reference style",
|
active_langs = (await session.execute(
|
||||||
)
|
select(User.lang).distinct().where(User.lang.in_(target_langs))
|
||||||
|
|
||||||
|
|
||||||
def looks_like_leakage(text: str) -> bool:
|
|
||||||
"""Heuristic: after cleaning, if these phrases still appear, the output
|
|
||||||
is contaminated prompt-regurgitation and shouldn't be shown."""
|
|
||||||
low = text.lower()
|
|
||||||
return any(flag in low for flag in _LEAKAGE_FLAGS)
|
|
||||||
|
|
||||||
|
|
||||||
def clean_summary(text: str) -> str:
|
|
||||||
"""Strip leading meta-commentary. If cleaning removes nearly everything
|
|
||||||
(suggesting the model emitted reasoning then ran out of tokens), fall
|
|
||||||
back to the last non-empty paragraph of the raw output — that's usually
|
|
||||||
where the actual answer ended up."""
|
|
||||||
raw = text.strip()
|
|
||||||
out = raw
|
|
||||||
# Up to 6 passes: handles compound leakage like
|
|
||||||
# "Constraints: <...>. The indicators are: <...>. <actual answer>"
|
|
||||||
for _ in range(6):
|
|
||||||
before = out
|
|
||||||
for pat in _LEAK_PATTERNS:
|
|
||||||
out = pat.sub("", out, count=1).lstrip()
|
|
||||||
if out == before:
|
|
||||||
break
|
|
||||||
if len(out) < 60 and len(raw) > 120:
|
|
||||||
# Cleaning ate too much; take the last non-empty paragraph of raw.
|
|
||||||
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()]
|
|
||||||
if paragraphs:
|
|
||||||
out = paragraphs[-1]
|
|
||||||
# Re-strip leaders from the recovered paragraph too.
|
|
||||||
for _ in range(2):
|
|
||||||
before = out
|
|
||||||
for pat in _LEAK_PATTERNS:
|
|
||||||
out = pat.sub("", out, count=1).lstrip()
|
|
||||||
if out == before:
|
|
||||||
break
|
|
||||||
# Trim any orphan closing quote/backtick from the wrap-strip above.
|
|
||||||
out = _TRAILING_QUOTE.sub("", out).rstrip()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _latest_quotes_by_group(session) -> dict[str, list[dict]]:
|
|
||||||
"""Latest non-null quote per (group, symbol). Drops error rows."""
|
|
||||||
sub = (
|
|
||||||
select(Quote.group_name, Quote.symbol,
|
|
||||||
func.max(Quote.fetched_at).label("mx"))
|
|
||||||
.group_by(Quote.group_name, Quote.symbol)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(Quote).join(
|
|
||||||
sub,
|
|
||||||
(Quote.group_name == sub.c.group_name)
|
|
||||||
& (Quote.symbol == sub.c.symbol)
|
|
||||||
& (Quote.fetched_at == sub.c.mx),
|
|
||||||
).where(Quote.price.is_not(None))
|
|
||||||
.order_by(Quote.group_name, Quote.symbol)
|
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
by_group: dict[str, list[dict]] = defaultdict(list)
|
if not active_langs:
|
||||||
for q in rows:
|
return
|
||||||
by_group[q.group_name].append({
|
|
||||||
"symbol": q.symbol, "label": q.label,
|
summary_row = await session.get(IndicatorSummary, summary_id)
|
||||||
"price": q.price, "currency": q.currency,
|
if summary_row is None:
|
||||||
"as_of": q.as_of, "changes": q.changes,
|
log.warning("ind_summary.translate.missing_summary", summary_id=summary_id)
|
||||||
})
|
return
|
||||||
return by_group
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
|
||||||
|
results = await asyncio.gather(*[
|
||||||
|
translate(client, summary_row.content, lang)
|
||||||
|
for lang in active_langs
|
||||||
|
], return_exceptions=True)
|
||||||
|
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
for lang, result in zip(active_langs, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
log.warning("ind_summary.translate.failed",
|
||||||
|
lang=lang, summary_id=summary_id,
|
||||||
|
error=str(result)[:200])
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
translated_md, llm_result = result
|
||||||
|
try:
|
||||||
|
async with session.begin_nested():
|
||||||
|
session.add(IndicatorSummaryTranslation(
|
||||||
|
summary_id=summary_id, lang=lang,
|
||||||
|
content=translated_md,
|
||||||
|
generated_at=utcnow(),
|
||||||
|
model=llm_result.model,
|
||||||
|
prompt_tokens=llm_result.prompt_tokens,
|
||||||
|
completion_tokens=llm_result.completion_tokens,
|
||||||
|
cost_usd=llm_result.cost_usd,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
succeeded += 1
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("ind_summary.translate.persist_failed",
|
||||||
|
lang=lang, summary_id=summary_id, error=str(exc)[:200])
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if failed and succeeded == 0:
|
||||||
|
log.error("ind_summary.translate.all_failed",
|
||||||
|
summary_id=summary_id, attempted=len(active_langs))
|
||||||
|
else:
|
||||||
|
log.info("ind_summary.translate.done",
|
||||||
|
summary_id=summary_id, succeeded=succeeded, failed=failed)
|
||||||
|
|
||||||
|
|
||||||
async def _month_spend(session) -> float:
|
# Defence-in-depth: read generation goes through JSON mode + a reviewer.
|
||||||
total = (await session.execute(
|
#
|
||||||
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
|
# 1. The system prompt instructs the model to emit {"read": "..."} only;
|
||||||
.where(AICall.called_at >= month_start())
|
# response_format={"type":"json_object"} forces well-formed JSON at
|
||||||
)).scalar()
|
# the API layer, so prose outside the field is impossible.
|
||||||
return float(total or 0.0)
|
# 2. We extract `read`, then ask a second LLM call (services/output_review)
|
||||||
|
# whether the candidate text is publishable. Scratchpad INSIDE the
|
||||||
|
# field — "Let's see…", "X? Actually Y?" — is caught here.
|
||||||
|
# 3. Any failure at either stage (parse, missing field, reviewer veto,
|
||||||
|
# reviewer error) drops the candidate. The previous good
|
||||||
|
# IndicatorSummary stays visible.
|
||||||
|
#
|
||||||
|
# The old _LEAK_PATTERNS / clean_summary / looks_like_leakage regex
|
||||||
|
# scaffolding lived here previously. It produced false positives (e.g.
|
||||||
|
# chopping off a legitimate leading sentence like "The indicators are
|
||||||
|
# pricing…") and false negatives (it never caught the chain-of-thought
|
||||||
|
# patterns the model actually emits). The reviewer agent replaces it.
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_read(raw: str) -> str | None:
|
||||||
|
"""Parse the model's JSON envelope and return the "read" field, or
|
||||||
|
None if the body isn't valid JSON / the field is missing / the field
|
||||||
|
isn't a string. Conservative: on any deviation from the schema we
|
||||||
|
drop the candidate rather than try to salvage it."""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return None
|
||||||
|
read = parsed.get("read")
|
||||||
|
if not isinstance(read, str):
|
||||||
|
return None
|
||||||
|
read = read.strip()
|
||||||
|
return read or None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_one(
|
async def _generate_one(
|
||||||
session, client: httpx.AsyncClient, group: str, quotes: list[dict],
|
session, client: httpx.AsyncClient, group: str, quotes: list[dict],
|
||||||
system_prompt: str, model: str, tone: str, analysis: str,
|
system_prompt: str, model: str, tone: str, analysis: str,
|
||||||
) -> bool:
|
) -> IndicatorSummary | None:
|
||||||
"""Generate + persist one group's summary. Returns True on success.
|
"""Generate + persist one group's summary. Returns the new row on
|
||||||
|
success (so the caller can fan out localized translations after
|
||||||
|
the commit picks up its id) or None on failure.
|
||||||
`model` is retained for ledger labelling but call_llm now picks the
|
`model` is retained for ledger labelling but call_llm now picks the
|
||||||
active-provider model itself."""
|
active-provider model itself."""
|
||||||
user_prompt = build_summary_user_prompt(group, quotes)
|
user_prompt = build_summary_user_prompt(group, quotes)
|
||||||
|
|
@ -185,19 +161,20 @@ async def _generate_one(
|
||||||
[{"role": "system", "content": system_prompt},
|
[{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_prompt}],
|
{"role": "user", "content": user_prompt}],
|
||||||
max_tokens=800, # DeepSeek sometimes spends 300+ on internal reasoning
|
max_tokens=800, # DeepSeek sometimes spends 300+ on internal reasoning
|
||||||
|
response_format={"type": "json_object"},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.add(AICall(model=active_model(), status="error", error=str(e)[:500]))
|
session.add(AICall(model=active_model(), status="error", error=str(e)[:500]))
|
||||||
log.warning("ind_summary.failed", group=group, error=str(e)[:120])
|
log.warning("ind_summary.failed", group=group, error=str(e)[:120])
|
||||||
return False
|
return None
|
||||||
|
|
||||||
cleaned = clean_summary(result.content)
|
candidate = _extract_read(result.content)
|
||||||
if looks_like_leakage(cleaned) or len(cleaned) < 40:
|
if candidate is None or len(candidate) < 40:
|
||||||
# Model regurgitated the prompt or produced nothing usable.
|
# JSON envelope malformed, "read" field missing/wrong type, or
|
||||||
# Don't persist — keep the last good summary visible. Log it so
|
# the candidate is too short to be a real read. Don't persist;
|
||||||
# we can see the rate of failures over time.
|
# the last good summary stays visible.
|
||||||
log.warning("ind_summary.leakage_detected",
|
log.warning("ind_summary.json_invalid",
|
||||||
group=group, preview=cleaned[:120])
|
group=group, preview=result.content[:160])
|
||||||
session.add(AICall(
|
session.add(AICall(
|
||||||
model=result.model,
|
model=result.model,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
|
@ -205,28 +182,48 @@ async def _generate_one(
|
||||||
cost_usd=result.cost_usd,
|
cost_usd=result.cost_usd,
|
||||||
status="leaked",
|
status="leaked",
|
||||||
))
|
))
|
||||||
return False
|
return None
|
||||||
|
|
||||||
session.add(IndicatorSummary(
|
verdict = await review_read(client, candidate)
|
||||||
|
if not verdict.clean:
|
||||||
|
# Reviewer caught scratchpad / meta-commentary / partial text
|
||||||
|
# INSIDE the read field. Drop the candidate; the previous good
|
||||||
|
# summary continues to serve.
|
||||||
|
log.warning("ind_summary.reviewer_rejected",
|
||||||
|
group=group, reason=verdict.reason,
|
||||||
|
preview=candidate[:120])
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0),
|
||||||
|
status="leaked",
|
||||||
|
))
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary = IndicatorSummary(
|
||||||
group_name=group,
|
group_name=group,
|
||||||
generated_at=utcnow(),
|
generated_at=utcnow(),
|
||||||
model=result.model,
|
model=result.model,
|
||||||
tone=tone,
|
tone=tone,
|
||||||
analysis=analysis,
|
analysis=analysis,
|
||||||
prompt_version=PROMPT_VERSION,
|
prompt_version=PROMPT_VERSION,
|
||||||
content=cleaned,
|
content=candidate,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd,
|
# Include the reviewer's cost in the row's recorded spend so the
|
||||||
))
|
# monthly budget tracking covers the full pipeline cost.
|
||||||
|
cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0),
|
||||||
|
)
|
||||||
|
session.add(summary)
|
||||||
session.add(AICall(
|
session.add(AICall(
|
||||||
model=result.model,
|
model=result.model,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd,
|
cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0),
|
||||||
status="ok",
|
status="ok",
|
||||||
))
|
))
|
||||||
return True
|
return summary
|
||||||
|
|
||||||
|
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
|
|
@ -254,13 +251,13 @@ async def run() -> None:
|
||||||
jr.error = reason
|
jr.error = reason
|
||||||
return
|
return
|
||||||
|
|
||||||
spent = await _month_spend(session)
|
spent = await month_spend(session)
|
||||||
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
|
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
|
||||||
jr.status = "skipped"
|
jr.status = "skipped"
|
||||||
jr.error = f"monthly cap reached (${spent:.2f})"
|
jr.error = f"monthly cap reached (${spent:.2f})"
|
||||||
return
|
return
|
||||||
|
|
||||||
groups = await _latest_quotes_by_group(session)
|
groups = await latest_quotes_by_group(session)
|
||||||
# Only summarise groups currently configured in TOML — drops stale
|
# Only summarise groups currently configured in TOML — drops stale
|
||||||
# group names (e.g. an old "pie" before T212 sourcing) that still have
|
# group names (e.g. an old "pie" before T212 sourcing) that still have
|
||||||
# quotes in the table but no UI presence.
|
# quotes in the table but no UI presence.
|
||||||
|
|
@ -283,41 +280,71 @@ async def run() -> None:
|
||||||
for tone in tones:
|
for tone in tones:
|
||||||
system_prompt = build_summary_system_prompt(tone, analysis)
|
system_prompt = build_summary_system_prompt(tone, analysis)
|
||||||
for group, quotes in groups.items():
|
for group, quotes in groups.items():
|
||||||
ok = await _generate_one(
|
summary = await _generate_one(
|
||||||
session, client, group, quotes,
|
session, client, group, quotes,
|
||||||
system_prompt, active_model(), tone, analysis,
|
system_prompt, active_model(), tone, analysis,
|
||||||
)
|
)
|
||||||
if ok:
|
if summary is not None:
|
||||||
written += 1
|
written += 1
|
||||||
await session.commit() # partial progress survives mid-job error
|
await session.commit() # partial progress survives mid-job error
|
||||||
|
if summary is not None:
|
||||||
|
await translate_summary_for_active_languages(session, summary.id)
|
||||||
|
|
||||||
# One aggregate read across all groups, stored under __all__.
|
# One aggregate read across all groups, stored under __all__.
|
||||||
|
# Same JSON-mode + reviewer-agent path as per-group reads.
|
||||||
agg_system = build_aggregate_summary_system_prompt(tone, analysis)
|
agg_system = build_aggregate_summary_system_prompt(tone, analysis)
|
||||||
agg_user = build_aggregate_summary_user_prompt(groups)
|
agg_user = build_aggregate_summary_user_prompt(groups)
|
||||||
|
agg_summary: IndicatorSummary | None = None
|
||||||
try:
|
try:
|
||||||
result = await call_llm(
|
result = await call_llm(
|
||||||
client,
|
client,
|
||||||
[{"role": "system", "content": agg_system},
|
[{"role": "system", "content": agg_system},
|
||||||
{"role": "user", "content": agg_user}],
|
{"role": "user", "content": agg_user}],
|
||||||
max_tokens=1500, # room for reasoning + 80-word output
|
max_tokens=1500,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
)
|
)
|
||||||
session.add(IndicatorSummary(
|
candidate = _extract_read(result.content)
|
||||||
|
if candidate is None or len(candidate) < 40:
|
||||||
|
log.warning("ind_summary.agg_json_invalid",
|
||||||
|
tone=tone, preview=result.content[:160])
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=result.cost_usd, status="leaked",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
verdict = await review_read(client, candidate)
|
||||||
|
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
|
||||||
|
if not verdict.clean:
|
||||||
|
log.warning("ind_summary.agg_reviewer_rejected",
|
||||||
|
tone=tone, reason=verdict.reason,
|
||||||
|
preview=candidate[:120])
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=full_cost, status="leaked",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
agg_summary = IndicatorSummary(
|
||||||
group_name=AGGREGATE_GROUP_NAME,
|
group_name=AGGREGATE_GROUP_NAME,
|
||||||
generated_at=utcnow(),
|
generated_at=utcnow(),
|
||||||
model=result.model,
|
model=result.model,
|
||||||
tone=tone,
|
tone=tone,
|
||||||
analysis=analysis,
|
analysis=analysis,
|
||||||
prompt_version=PROMPT_VERSION,
|
prompt_version=PROMPT_VERSION,
|
||||||
content=clean_summary(result.content),
|
content=candidate,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd,
|
cost_usd=full_cost,
|
||||||
))
|
)
|
||||||
|
session.add(agg_summary)
|
||||||
session.add(AICall(
|
session.add(AICall(
|
||||||
model=result.model,
|
model=result.model,
|
||||||
prompt_tokens=result.prompt_tokens,
|
prompt_tokens=result.prompt_tokens,
|
||||||
completion_tokens=result.completion_tokens,
|
completion_tokens=result.completion_tokens,
|
||||||
cost_usd=result.cost_usd, status="ok",
|
cost_usd=full_cost, status="ok",
|
||||||
))
|
))
|
||||||
written += 1
|
written += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -328,6 +355,8 @@ async def run() -> None:
|
||||||
log.warning("ind_summary.agg_failed",
|
log.warning("ind_summary.agg_failed",
|
||||||
tone=tone, error=str(e)[:120])
|
tone=tone, error=str(e)[:120])
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
if agg_summary is not None:
|
||||||
|
await translate_summary_for_active_languages(session, agg_summary.id)
|
||||||
|
|
||||||
jr.items_written = written
|
jr.items_written = written
|
||||||
log.info("ind_summary.done",
|
log.info("ind_summary.done",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ from app.db import get_session_factory
|
||||||
from app.logging import configure_logging, get_logger
|
from app.logging import configure_logging, get_logger
|
||||||
from app.routers import api as api_router
|
from app.routers import api as api_router
|
||||||
from app.routers import auth as auth_router
|
from app.routers import auth as auth_router
|
||||||
|
from app.routers import chat as chat_router
|
||||||
from app.routers import email as email_router
|
from app.routers import email as email_router
|
||||||
|
from app.routers import ops as ops_router
|
||||||
from app.routers import pages as pages_router
|
from app.routers import pages as pages_router
|
||||||
from app.routers import polar_webhook as polar_webhook_router
|
from app.routers import polar_webhook as polar_webhook_router
|
||||||
from app.routers import public as public_router
|
from app.routers import public as public_router
|
||||||
|
|
@ -89,6 +91,8 @@ app.mount(
|
||||||
app.include_router(auth_router.router, tags=["auth"])
|
app.include_router(auth_router.router, tags=["auth"])
|
||||||
app.include_router(email_router.router, tags=["email"])
|
app.include_router(email_router.router, tags=["email"])
|
||||||
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
||||||
|
app.include_router(chat_router.router, prefix="/api", tags=["chat"])
|
||||||
|
app.include_router(ops_router.router, prefix="/api", tags=["ops"])
|
||||||
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
|
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
|
||||||
app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"])
|
app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"])
|
||||||
app.include_router(sync_router.router, tags=["portfolio-sync"])
|
app.include_router(sync_router.router, tags=["portfolio-sync"])
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class Quote(Base):
|
||||||
class QuoteDaily(Base):
|
class QuoteDaily(Base):
|
||||||
"""Daily rollup — sparkline source. PK on (symbol, date)."""
|
"""Daily rollup — sparkline source. PK on (symbol, date)."""
|
||||||
__tablename__ = "quotes_daily"
|
__tablename__ = "quotes_daily"
|
||||||
symbol: Mapped[str] = mapped_column(String(64), primary_key=True)
|
symbol: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
date: Mapped[date] = mapped_column(Date, primary_key=True)
|
date: Mapped[date] = mapped_column(Date, primary_key=True)
|
||||||
close: Mapped[float | None] = mapped_column(Float)
|
close: Mapped[float | None] = mapped_column(Float)
|
||||||
high: Mapped[float | None] = mapped_column(Float)
|
high: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
@ -120,6 +120,41 @@ 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(
|
||||||
|
BigInteger().with_variant(Integer(), "sqlite"),
|
||||||
|
ForeignKey("strategic_logs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
||||||
|
)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
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."""
|
||||||
|
|
@ -139,6 +174,39 @@ class IndicatorSummary(Base):
|
||||||
__table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),)
|
__table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),)
|
||||||
|
|
||||||
|
|
||||||
|
class IndicatorSummaryTranslation(Base):
|
||||||
|
"""Cached translation of a single IndicatorSummary row.
|
||||||
|
|
||||||
|
Same pattern as StrategicLogTranslation: one row per
|
||||||
|
(summary_id, lang). Populated by indicator_summary_job after the
|
||||||
|
English row is committed. The dashboard / indicators endpoints
|
||||||
|
swap in the matching translation when a user with a non-en
|
||||||
|
lang preference loads them, falling back silently to the English
|
||||||
|
source when no row exists yet.
|
||||||
|
"""
|
||||||
|
__tablename__ = "indicator_summary_translations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
||||||
|
summary_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger().with_variant(Integer(), "sqlite"),
|
||||||
|
ForeignKey("indicator_summaries.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
||||||
|
)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AICall(Base):
|
class AICall(Base):
|
||||||
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
|
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
|
||||||
__tablename__ = "ai_calls"
|
__tablename__ = "ai_calls"
|
||||||
|
|
@ -189,6 +257,14 @@ 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",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
# 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
|
||||||
|
|
@ -463,5 +539,7 @@ class CsvFormatTemplate(Base):
|
||||||
last_used_at: Mapped[datetime] = mapped_column(
|
last_used_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, default=utcnow,
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
||||||
)
|
)
|
||||||
llm_model: Mapped[str | None] = mapped_column(String(64))
|
model: Mapped[str | None] = mapped_column(String(64))
|
||||||
llm_cost_usd: Mapped[float | None] = mapped_column(Float)
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
|
||||||
|
|
@ -10,39 +10,29 @@ import re
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from collections import defaultdict
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import httpx
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.auth import require_token, maybe_current_user, CurrentUser
|
from app.auth import require_token, maybe_current_user, CurrentUser
|
||||||
|
from app.services.i18n import ACTIVE_LANGUAGES
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session, utcnow
|
from app.db import get_session, utcnow
|
||||||
from app.services.openrouter import (
|
|
||||||
PROMPT_VERSION,
|
|
||||||
build_chat_system_prompt,
|
|
||||||
call_openrouter,
|
|
||||||
month_start,
|
|
||||||
)
|
|
||||||
from app.templates_env import templates
|
from app.templates_env import templates
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AICall,
|
|
||||||
Headline,
|
Headline,
|
||||||
IndicatorSummary,
|
IndicatorSummary,
|
||||||
JobRun,
|
IndicatorSummaryTranslation,
|
||||||
Quote,
|
Quote,
|
||||||
StrategicLog,
|
StrategicLog,
|
||||||
|
StrategicLogTranslation,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
HealthOut,
|
|
||||||
HeadlineOut,
|
HeadlineOut,
|
||||||
JobStatus,
|
|
||||||
QuoteOut,
|
QuoteOut,
|
||||||
StrategicLogOut,
|
StrategicLogOut,
|
||||||
)
|
)
|
||||||
|
|
@ -50,11 +40,6 @@ from app.schemas import (
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_token)])
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
|
|
||||||
"indicator_summary_job", "universe_flush_job",
|
|
||||||
"email_digest_job")
|
|
||||||
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
|
||||||
|
|
||||||
# Per-group expected freshness — bonds and intraday tape want daily data,
|
# Per-group expected freshness — bonds and intraday tape want daily data,
|
||||||
# macro/economy/valuation are monthly/quarterly by nature. Older than this
|
# macro/economy/valuation are monthly/quarterly by nature. Older than this
|
||||||
# many days from today → row gets a "stale" badge.
|
# many days from today → row gets a "stale" badge.
|
||||||
|
|
@ -135,6 +120,7 @@ async def indicators(
|
||||||
as_: str | None = Query(default=None, alias="as"),
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
tone: str | None = Query(default=None),
|
tone: str | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
principal: CurrentUser | None = Depends(maybe_current_user),
|
||||||
):
|
):
|
||||||
sub = (
|
sub = (
|
||||||
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
||||||
|
|
@ -202,6 +188,7 @@ async def indicators(
|
||||||
if as_of_d and (today - as_of_d).days > threshold:
|
if as_of_d and (today - as_of_d).days > threshold:
|
||||||
stale_symbols.add(r.symbol)
|
stale_symbols.add(r.symbol)
|
||||||
|
|
||||||
|
await _apply_localized_summary(session, summary, principal)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "partials/indicators.html",
|
request, "partials/indicators.html",
|
||||||
{"quotes": rows, "has_anchor": has_anchor,
|
{"quotes": rows, "has_anchor": has_anchor,
|
||||||
|
|
@ -295,11 +282,15 @@ async def news_list(
|
||||||
# --- Strategic log -----------------------------------------------------------
|
# --- Strategic log -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _log_partial_payload(row: StrategicLog | None) -> dict | None:
|
def _log_partial_payload(
|
||||||
|
row: StrategicLog | None,
|
||||||
|
content_override: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
content = content_override if content_override is not None else row.content
|
||||||
return {
|
return {
|
||||||
"content_html": _md_to_html(row.content),
|
"content_html": _md_to_html(content),
|
||||||
"generated_at": row.generated_at,
|
"generated_at": row.generated_at,
|
||||||
"model": row.model,
|
"model": row.model,
|
||||||
"tone": row.tone,
|
"tone": row.tone,
|
||||||
|
|
@ -311,6 +302,52 @@ def _log_partial_payload(row: StrategicLog | None) -> dict | None:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _localized_content(
|
||||||
|
session: AsyncSession,
|
||||||
|
row: StrategicLog | None,
|
||||||
|
principal: CurrentUser | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the translated content for ``row`` when the principal has
|
||||||
|
a non-English lang preference and a matching translation row exists.
|
||||||
|
Returns None to signal 'use row.content as-is' (the default English
|
||||||
|
path)."""
|
||||||
|
if row is None or principal is None or principal.user is None:
|
||||||
|
return None
|
||||||
|
lang = (principal.user.lang or "en")
|
||||||
|
if lang == "en":
|
||||||
|
return None
|
||||||
|
t = (await session.execute(
|
||||||
|
select(StrategicLogTranslation)
|
||||||
|
.where(StrategicLogTranslation.log_id == row.id)
|
||||||
|
.where(StrategicLogTranslation.lang == lang)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
return t.content if t is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_localized_summary(
|
||||||
|
session: AsyncSession,
|
||||||
|
row: IndicatorSummary | None,
|
||||||
|
principal: CurrentUser | None,
|
||||||
|
) -> None:
|
||||||
|
"""If ``row`` has a matching translation for ``principal.user.lang``,
|
||||||
|
overwrite the in-memory ``content`` attribute so the template renders
|
||||||
|
the localized version. No DB write happens — the mutation lives only
|
||||||
|
for the lifetime of this GET request.
|
||||||
|
"""
|
||||||
|
if row is None or principal is None or principal.user is None:
|
||||||
|
return
|
||||||
|
lang = (principal.user.lang or "en")
|
||||||
|
if lang == "en":
|
||||||
|
return
|
||||||
|
t = (await session.execute(
|
||||||
|
select(IndicatorSummaryTranslation)
|
||||||
|
.where(IndicatorSummaryTranslation.summary_id == row.id)
|
||||||
|
.where(IndicatorSummaryTranslation.lang == lang)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if t is not None:
|
||||||
|
row.content = t.content
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tone_param(tone: str | None) -> str:
|
def _resolve_tone_param(tone: str | None) -> str:
|
||||||
"""Normalise a query-param tone to one of the two valid values.
|
"""Normalise a query-param tone to one of the two valid values.
|
||||||
PRO is silently mapped to INTERMEDIATE (see openrouter.PROMPT_VERSION 6)."""
|
PRO is silently mapped to INTERMEDIATE (see openrouter.PROMPT_VERSION 6)."""
|
||||||
|
|
@ -366,10 +403,11 @@ async def log_latest(
|
||||||
row = (await session.execute(fallback)).scalar_one_or_none()
|
row = (await session.execute(fallback)).scalar_one_or_none()
|
||||||
|
|
||||||
if as_ == "html":
|
if as_ == "html":
|
||||||
|
content_override = await _localized_content(session, row, principal)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "partials/log.html",
|
request, "partials/log.html",
|
||||||
{"log": _log_partial_payload(row), "tone": wanted_tone,
|
{"log": _log_partial_payload(row, content_override=content_override),
|
||||||
"paid": not free_only},
|
"tone": wanted_tone, "paid": not free_only},
|
||||||
)
|
)
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
@ -420,10 +458,11 @@ async def log_by_date(
|
||||||
row = (await session.execute(fallback)).scalar_one_or_none()
|
row = (await session.execute(fallback)).scalar_one_or_none()
|
||||||
|
|
||||||
if as_ == "html":
|
if as_ == "html":
|
||||||
|
content_override = await _localized_content(session, row, principal)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "partials/log.html",
|
request, "partials/log.html",
|
||||||
{"log": _log_partial_payload(row), "tone": wanted_tone,
|
{"log": _log_partial_payload(row, content_override=content_override),
|
||||||
"paid": not free_only},
|
"tone": wanted_tone, "paid": not free_only},
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="No log on this date")
|
raise HTTPException(status_code=404, detail="No log on this date")
|
||||||
|
|
@ -505,14 +544,6 @@ async def log_days(
|
||||||
return templates.TemplateResponse(request, "partials/calendar.html", payload)
|
return templates.TemplateResponse(request, "partials/calendar.html", payload)
|
||||||
|
|
||||||
|
|
||||||
# Portfolio endpoints moved to app/routers/universe.py (Phase G). The
|
|
||||||
# server no longer persists per-user portfolio data; holdings live in
|
|
||||||
# the browser's localStorage and prices come from /api/universe.
|
|
||||||
|
|
||||||
|
|
||||||
# --- Health / ops footer -----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# --- Aggregate summary + market status (dashboard header) -------------------
|
# --- Aggregate summary + market status (dashboard header) -------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -525,6 +556,7 @@ async def aggregate_summary(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
as_: str | None = Query(default=None, alias="as"),
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
tone: str | None = Query(default=None),
|
tone: str | None = Query(default=None),
|
||||||
|
principal: CurrentUser | None = Depends(maybe_current_user),
|
||||||
):
|
):
|
||||||
wanted_tone = _resolve_tone_param(tone)
|
wanted_tone = _resolve_tone_param(tone)
|
||||||
row = (await session.execute(
|
row = (await session.execute(
|
||||||
|
|
@ -546,6 +578,7 @@ async def aggregate_summary(
|
||||||
statuses = all_statuses()
|
statuses = all_statuses()
|
||||||
|
|
||||||
if as_ == "html":
|
if as_ == "html":
|
||||||
|
await _apply_localized_summary(session, row, principal)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "partials/dashboard_header.html",
|
request, "partials/dashboard_header.html",
|
||||||
{"summary": row, "markets": statuses, "tone": wanted_tone},
|
{"summary": row, "markets": statuses, "tone": wanted_tone},
|
||||||
|
|
@ -563,303 +596,6 @@ async def aggregate_summary(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Market → headline index mapping for the sticky bottom bar. Symbols must
|
|
||||||
# be present in config/default.toml so market_job populates `quotes`.
|
|
||||||
_MARKET_INDEX = {
|
|
||||||
"NYSE": ("^GSPC", "S&P 500"),
|
|
||||||
"LSE": ("^FTSE", "FTSE 100"),
|
|
||||||
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
|
|
||||||
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
|
|
||||||
# config/default.toml's equity group.
|
|
||||||
"XETRA": ("^STOXX50E", "STOXX 50"),
|
|
||||||
"JPX": ("^N225", "Nikkei 225"),
|
|
||||||
"HKEX": ("^HSI", "Hang Seng"),
|
|
||||||
"SSE": ("000300.SS", "CSI 300"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_price(p: float | None) -> str:
|
|
||||||
if p is None:
|
|
||||||
return "—"
|
|
||||||
if abs(p) >= 1000:
|
|
||||||
return f"{p:,.0f}"
|
|
||||||
if abs(p) >= 100:
|
|
||||||
return f"{p:,.1f}"
|
|
||||||
return f"{p:,.2f}"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def markets_bar(
|
|
||||||
request: Request,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
as_: str | None = Query(default=None, alias="as"),
|
|
||||||
):
|
|
||||||
"""The sticky bottom-bar payload: per-market open/close status with the
|
|
||||||
market's headline index price + 1d change. Refreshed by HTMX every 60s.
|
|
||||||
"""
|
|
||||||
from app.services.markets import all_statuses
|
|
||||||
|
|
||||||
statuses = all_statuses()
|
|
||||||
# Latest quote per headline-index symbol in one query.
|
|
||||||
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
|
|
||||||
sub = (
|
|
||||||
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
|
||||||
.where(Quote.symbol.in_(wanted_syms))
|
|
||||||
.group_by(Quote.symbol)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(Quote).join(
|
|
||||||
sub,
|
|
||||||
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
|
|
||||||
)
|
|
||||||
)).scalars().all()
|
|
||||||
by_sym = {q.symbol: q for q in rows}
|
|
||||||
|
|
||||||
markets: list[dict] = []
|
|
||||||
for st in statuses:
|
|
||||||
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
|
|
||||||
q = by_sym.get(sym) if sym else None
|
|
||||||
idx = None
|
|
||||||
if q is not None and q.price is not None:
|
|
||||||
idx = {
|
|
||||||
"symbol": q.symbol,
|
|
||||||
"label": label,
|
|
||||||
"price_fmt": _fmt_price(q.price),
|
|
||||||
"change_1d_pct": (q.changes or {}).get("1d"),
|
|
||||||
}
|
|
||||||
markets.append({
|
|
||||||
"code": st["code"],
|
|
||||||
"label": st["label"],
|
|
||||||
"open": st["open"],
|
|
||||||
"until_iso": st["until"].isoformat(),
|
|
||||||
"until_hhmm": st["until"].strftime("%H:%M"),
|
|
||||||
"index": idx,
|
|
||||||
})
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request, "partials/markets_bar.html",
|
|
||||||
{"markets": markets},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def health_html(
|
|
||||||
request: Request,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
as_: str | None = Query(default=None, alias="as"),
|
|
||||||
):
|
|
||||||
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
|
|
||||||
structured object. The default is HTML because that's how the dashboard
|
|
||||||
consumes it; CLI/curl users will pass ?as=json."""
|
|
||||||
try:
|
|
||||||
await session.execute(select(func.now()))
|
|
||||||
db_ok = True
|
|
||||||
except Exception:
|
|
||||||
db_ok = False
|
|
||||||
|
|
||||||
now = utcnow()
|
|
||||||
jobs: list[dict] = []
|
|
||||||
structured: list[JobStatus] = []
|
|
||||||
for name in JOB_NAMES:
|
|
||||||
row = (await session.execute(
|
|
||||||
select(JobRun).where(JobRun.name == name)
|
|
||||||
.order_by(desc(JobRun.started_at)).limit(1)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
if row is None:
|
|
||||||
jobs.append({"name": name, "led": "idle", "age": "—",
|
|
||||||
"last_finished": None})
|
|
||||||
structured.append(JobStatus(name=name))
|
|
||||||
continue
|
|
||||||
if row.status == "success":
|
|
||||||
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
|
|
||||||
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
|
|
||||||
elif row.status == "skipped":
|
|
||||||
led = "warn"
|
|
||||||
elif row.status == "running":
|
|
||||||
led = "warn"
|
|
||||||
else:
|
|
||||||
led = "err"
|
|
||||||
jobs.append({
|
|
||||||
"name": name, "led": led,
|
|
||||||
"age": _fmt_age(now, row.finished_at or row.started_at),
|
|
||||||
"last_finished": row.finished_at,
|
|
||||||
})
|
|
||||||
structured.append(JobStatus(
|
|
||||||
name=name, last_started=row.started_at,
|
|
||||||
last_finished=row.finished_at, status=row.status,
|
|
||||||
error=row.error, items_written=row.items_written,
|
|
||||||
))
|
|
||||||
|
|
||||||
if as_ == "json":
|
|
||||||
return JSONResponse(
|
|
||||||
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
|
|
||||||
)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request, "partials/ops_footer.html",
|
|
||||||
{"db_ok": db_ok, "jobs": jobs},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Chat -------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
|
||||||
role: str = Field(pattern="^(user|assistant)$")
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
|
||||||
messages: list[ChatMessage]
|
|
||||||
|
|
||||||
|
|
||||||
CHAT_REFERENCE_LINE = (
|
|
||||||
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
|
|
||||||
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
|
|
||||||
)
|
|
||||||
THESIS_KEYWORDS_FALLBACK = [
|
|
||||||
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
|
|
||||||
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
|
|
||||||
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
|
|
||||||
"nato", "defence", "defense",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
|
|
||||||
sub = (
|
|
||||||
select(Quote.group_name, Quote.symbol,
|
|
||||||
func.max(Quote.fetched_at).label("mx"))
|
|
||||||
.group_by(Quote.group_name, Quote.symbol)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(Quote).join(
|
|
||||||
sub,
|
|
||||||
(Quote.group_name == sub.c.group_name)
|
|
||||||
& (Quote.symbol == sub.c.symbol)
|
|
||||||
& (Quote.fetched_at == sub.c.mx),
|
|
||||||
).order_by(Quote.group_name, Quote.symbol)
|
|
||||||
)).scalars().all()
|
|
||||||
by_group: dict[str, list[dict]] = defaultdict(list)
|
|
||||||
for q in rows:
|
|
||||||
by_group[q.group_name].append({
|
|
||||||
"symbol": q.symbol, "label": q.label,
|
|
||||||
"price": q.price, "currency": q.currency,
|
|
||||||
"as_of": q.as_of, "changes": q.changes,
|
|
||||||
})
|
|
||||||
return by_group
|
|
||||||
|
|
||||||
|
|
||||||
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
|
|
||||||
cutoff = utcnow() - timedelta(hours=24)
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(Headline)
|
|
||||||
.where(Headline.published_at >= cutoff)
|
|
||||||
.order_by(desc(Headline.published_at))
|
|
||||||
.limit(300)
|
|
||||||
)).scalars().all()
|
|
||||||
out = []
|
|
||||||
for h in rows:
|
|
||||||
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
|
|
||||||
out.append({"source": h.source, "title": h.title})
|
|
||||||
if len(out) >= limit:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _month_spend(session: AsyncSession) -> float:
|
|
||||||
total = (await session.execute(
|
|
||||||
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
|
|
||||||
.where(AICall.called_at >= month_start())
|
|
||||||
)).scalar()
|
|
||||||
return float(total or 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat")
|
|
||||||
async def chat(
|
|
||||||
body: ChatRequest,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
principal: CurrentUser | None = Depends(maybe_current_user),
|
|
||||||
):
|
|
||||||
"""Answer one user turn given the conversation so far. Grounded on the
|
|
||||||
latest strategic log + market data + thesis-filtered headlines.
|
|
||||||
Ephemeral — the conversation lives entirely in the client; the endpoint
|
|
||||||
just records each call's cost in `ai_calls`."""
|
|
||||||
# Paid-only feature. Free users get the static log but not the
|
|
||||||
# interactive chat (see /pricing).
|
|
||||||
from app.services.access import is_paid_active
|
|
||||||
if not is_paid_active(principal):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=402,
|
|
||||||
detail={"code": "paid_required",
|
|
||||||
"message": "Follow-up chat is a paid-tier feature."},
|
|
||||||
)
|
|
||||||
|
|
||||||
s = get_settings()
|
|
||||||
if not s.OPENROUTER_API_KEY:
|
|
||||||
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
|
|
||||||
|
|
||||||
# Monthly cost cap — same one the log job respects.
|
|
||||||
spent = await _month_spend(session)
|
|
||||||
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trim runaway conversations: keep last 20 turns.
|
|
||||||
history = body.messages[-20:]
|
|
||||||
if not history or history[-1].role != "user":
|
|
||||||
raise HTTPException(status_code=400, detail="Last message must be user")
|
|
||||||
|
|
||||||
# Gather grounding context.
|
|
||||||
log_row = (await session.execute(
|
|
||||||
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
quotes = await _latest_quotes_by_group_chat(session)
|
|
||||||
headlines = await _thesis_headlines_for_chat(session)
|
|
||||||
|
|
||||||
system_prompt = build_chat_system_prompt(
|
|
||||||
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
|
|
||||||
log_content=log_row.content if log_row else None,
|
|
||||||
log_generated_at=log_row.generated_at if log_row else None,
|
|
||||||
quotes_by_group=quotes,
|
|
||||||
headlines=headlines,
|
|
||||||
reference_line=CHAT_REFERENCE_LINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
msgs = [{"role": "system", "content": system_prompt}]
|
|
||||||
for m in history:
|
|
||||||
msgs.append({"role": m.role, "content": m.content})
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
||||||
result = await call_openrouter(client, msgs, model=s.OPENROUTER_MODEL)
|
|
||||||
except Exception as e:
|
|
||||||
session.add(AICall(
|
|
||||||
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
|
|
||||||
))
|
|
||||||
await session.commit()
|
|
||||||
raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
|
|
||||||
|
|
||||||
session.add(AICall(
|
|
||||||
model=result.model,
|
|
||||||
prompt_tokens=result.prompt_tokens,
|
|
||||||
completion_tokens=result.completion_tokens,
|
|
||||||
cost_usd=result.cost_usd,
|
|
||||||
status="ok",
|
|
||||||
))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": result.content,
|
|
||||||
"content_html": _md_to_html(result.content),
|
|
||||||
"prompt_tokens": result.prompt_tokens,
|
|
||||||
"completion_tokens": result.completion_tokens,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Settings — digest preferences
|
# Settings — digest preferences
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -895,3 +631,38 @@ async def patch_digest_prefs(
|
||||||
user.digest_tone = payload.tone
|
user.digest_tone = payload.tone
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone)
|
return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
|
|
||||||
239
app/routers/chat.py
Normal file
239
app/routers/chat.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""Chat endpoint — POST /api/chat.
|
||||||
|
|
||||||
|
Grounded on the latest strategic log, current market quotes, and
|
||||||
|
thesis-filtered headlines. Ephemeral: the conversation lives in the
|
||||||
|
client; this endpoint just records each call's cost in `ai_calls`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_token, maybe_current_user, CurrentUser
|
||||||
|
from app.config import get_settings
|
||||||
|
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.llm_prompts import build_chat_system_prompt
|
||||||
|
from app.services.openrouter import call_llm, month_start
|
||||||
|
from app.services.output_review import review_read
|
||||||
|
|
||||||
|
from app.logging import get_logger
|
||||||
|
log = get_logger("chat")
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: str = Field(pattern="^(user|assistant)$")
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
messages: list[ChatMessage]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
THESIS_KEYWORDS_FALLBACK = [
|
||||||
|
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
|
||||||
|
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
|
||||||
|
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
|
||||||
|
"nato", "defence", "defense",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
|
||||||
|
sub = (
|
||||||
|
select(Quote.group_name, Quote.symbol,
|
||||||
|
func.max(Quote.fetched_at).label("mx"))
|
||||||
|
.group_by(Quote.group_name, Quote.symbol)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Quote).join(
|
||||||
|
sub,
|
||||||
|
(Quote.group_name == sub.c.group_name)
|
||||||
|
& (Quote.symbol == sub.c.symbol)
|
||||||
|
& (Quote.fetched_at == sub.c.mx),
|
||||||
|
).order_by(Quote.group_name, Quote.symbol)
|
||||||
|
)).scalars().all()
|
||||||
|
by_group: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for q in rows:
|
||||||
|
by_group[q.group_name].append({
|
||||||
|
"symbol": q.symbol, "label": q.label,
|
||||||
|
"price": q.price, "currency": q.currency,
|
||||||
|
"as_of": q.as_of, "changes": q.changes,
|
||||||
|
})
|
||||||
|
return by_group
|
||||||
|
|
||||||
|
|
||||||
|
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
|
||||||
|
cutoff = utcnow() - timedelta(hours=24)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Headline)
|
||||||
|
.where(Headline.published_at >= cutoff)
|
||||||
|
.order_by(desc(Headline.published_at))
|
||||||
|
.limit(300)
|
||||||
|
)).scalars().all()
|
||||||
|
out = []
|
||||||
|
for h in rows:
|
||||||
|
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
|
||||||
|
out.append({"source": h.source, "title": h.title})
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _month_spend(session: AsyncSession) -> float:
|
||||||
|
total = (await session.execute(
|
||||||
|
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
|
||||||
|
.where(AICall.called_at >= month_start())
|
||||||
|
)).scalar()
|
||||||
|
return float(total or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat(
|
||||||
|
body: ChatRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
principal: CurrentUser | None = Depends(maybe_current_user),
|
||||||
|
):
|
||||||
|
"""Answer one user turn given the conversation so far. Grounded on the
|
||||||
|
latest strategic log + market data + thesis-filtered headlines.
|
||||||
|
Ephemeral — the conversation lives entirely in the client; the endpoint
|
||||||
|
just records each call's cost in `ai_calls`."""
|
||||||
|
# Paid-only feature. Free users get the static log but not the
|
||||||
|
# interactive chat (see /pricing).
|
||||||
|
from app.services.access import is_paid_active
|
||||||
|
if not is_paid_active(principal):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail={"code": "paid_required",
|
||||||
|
"message": "Follow-up chat is a paid-tier feature."},
|
||||||
|
)
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
if not s.OPENROUTER_API_KEY:
|
||||||
|
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
|
||||||
|
|
||||||
|
# Monthly cost cap — same one the log job respects.
|
||||||
|
spent = await _month_spend(session)
|
||||||
|
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trim runaway conversations: keep last 20 turns.
|
||||||
|
history = body.messages[-20:]
|
||||||
|
if not history or history[-1].role != "user":
|
||||||
|
raise HTTPException(status_code=400, detail="Last message must be user")
|
||||||
|
|
||||||
|
# Gather grounding context.
|
||||||
|
log_row = (await session.execute(
|
||||||
|
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
quotes = await _latest_quotes_by_group_chat(session)
|
||||||
|
headlines = await _thesis_headlines_for_chat(session)
|
||||||
|
|
||||||
|
system_prompt = build_chat_system_prompt(
|
||||||
|
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
|
||||||
|
log_content=log_row.content if log_row else None,
|
||||||
|
log_generated_at=log_row.generated_at if log_row else None,
|
||||||
|
quotes_by_group=quotes,
|
||||||
|
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.
|
||||||
|
user_lang = principal.user.lang if principal and principal.user else "en"
|
||||||
|
system_prompt = system_prompt + respond_in_clause(user_lang)
|
||||||
|
|
||||||
|
msgs = [{"role": "system", "content": system_prompt}]
|
||||||
|
for m in history:
|
||||||
|
msgs.append({"role": m.role, "content": m.content})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
result = await call_llm(client, msgs)
|
||||||
|
# Reviewer gate. The chat turn could solicit advice with a
|
||||||
|
# leading question; the generator's system prompt forbids it,
|
||||||
|
# but the reviewer is the enforcement layer. ~1-2 s extra
|
||||||
|
# latency per turn on top of the generation call.
|
||||||
|
verdict = await review_read(client, result.content)
|
||||||
|
except Exception as e:
|
||||||
|
session.add(AICall(
|
||||||
|
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
|
||||||
|
|
||||||
|
full_cost = (result.cost_usd or 0.0) + (verdict.cost_usd or 0.0)
|
||||||
|
if not verdict.clean:
|
||||||
|
# Rejected reply. Record the cost and surface a generic refusal
|
||||||
|
# the user can retry, rather than letting potentially non-compliant
|
||||||
|
# text reach them.
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=full_cost, status="leaked",
|
||||||
|
error=f"reviewer: {verdict.reason}",
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
log.warning("chat.reviewer_rejected", reason=verdict.reason,
|
||||||
|
preview=result.content[:120])
|
||||||
|
refusal = (
|
||||||
|
"I can't generate that reply — it would have crossed into "
|
||||||
|
"investment advice or specific recommendations, which I'm "
|
||||||
|
"not licensed to give. Try rephrasing as a question about "
|
||||||
|
"what the data means rather than what to do."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": refusal,
|
||||||
|
"content_html": _md_to_html(refusal),
|
||||||
|
"prompt_tokens": result.prompt_tokens,
|
||||||
|
"completion_tokens": result.completion_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=full_cost,
|
||||||
|
status="ok",
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": result.content,
|
||||||
|
"content_html": _md_to_html(result.content),
|
||||||
|
"prompt_tokens": result.prompt_tokens,
|
||||||
|
"completion_tokens": result.completion_tokens,
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,9 @@ _CONFIRM_PAGE = """\
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Unsubscribed — {brand}</title>
|
<title>Unsubscribed — {brand}</title>
|
||||||
<link rel="stylesheet" href="/static/css/cassandra.css">
|
<link rel="stylesheet" href="/static/css/tokens.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/auth.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-shell">
|
<body class="auth-shell">
|
||||||
<div class="auth-card" style="max-width:480px;">
|
<div class="auth-card" style="max-width:480px;">
|
||||||
|
|
|
||||||
162
app/routers/ops.py
Normal file
162
app/routers/ops.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""HTML-only ops endpoints — /api/markets-bar and /api/health.
|
||||||
|
|
||||||
|
These are HTMX partials consumed by the dashboard. They return HTML by
|
||||||
|
default (not JSON) and are not included in the OpenAPI schema.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_token
|
||||||
|
from app.db import get_session, utcnow
|
||||||
|
from app.models import JobRun, Quote
|
||||||
|
from app.routers.api import _age_seconds, _fmt_age
|
||||||
|
from app.schemas import HealthOut, JobStatus
|
||||||
|
from app.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
|
||||||
|
"indicator_summary_job", "universe_flush_job",
|
||||||
|
"email_digest_job")
|
||||||
|
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
||||||
|
|
||||||
|
# Market → headline index mapping for the sticky bottom bar. Symbols must
|
||||||
|
# be present in config/default.toml so market_job populates `quotes`.
|
||||||
|
_MARKET_INDEX = {
|
||||||
|
"NYSE": ("^GSPC", "S&P 500"),
|
||||||
|
"LSE": ("^FTSE", "FTSE 100"),
|
||||||
|
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
|
||||||
|
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
|
||||||
|
# config/default.toml's equity group.
|
||||||
|
"XETRA": ("^STOXX50E", "STOXX 50"),
|
||||||
|
"JPX": ("^N225", "Nikkei 225"),
|
||||||
|
"HKEX": ("^HSI", "Hang Seng"),
|
||||||
|
"SSE": ("000300.SS", "CSI 300"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_price(p: float | None) -> str:
|
||||||
|
if p is None:
|
||||||
|
return "—"
|
||||||
|
if abs(p) >= 1000:
|
||||||
|
return f"{p:,.0f}"
|
||||||
|
if abs(p) >= 100:
|
||||||
|
return f"{p:,.1f}"
|
||||||
|
return f"{p:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def markets_bar(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
"""The sticky bottom-bar payload: per-market open/close status with the
|
||||||
|
market's headline index price + 1d change. Refreshed by HTMX every 60s.
|
||||||
|
"""
|
||||||
|
from app.services.markets import all_statuses
|
||||||
|
|
||||||
|
statuses = all_statuses()
|
||||||
|
# Latest quote per headline-index symbol in one query.
|
||||||
|
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
|
||||||
|
sub = (
|
||||||
|
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
||||||
|
.where(Quote.symbol.in_(wanted_syms))
|
||||||
|
.group_by(Quote.symbol)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Quote).join(
|
||||||
|
sub,
|
||||||
|
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
by_sym = {q.symbol: q for q in rows}
|
||||||
|
|
||||||
|
markets: list[dict] = []
|
||||||
|
for st in statuses:
|
||||||
|
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
|
||||||
|
q = by_sym.get(sym) if sym else None
|
||||||
|
idx = None
|
||||||
|
if q is not None and q.price is not None:
|
||||||
|
idx = {
|
||||||
|
"symbol": q.symbol,
|
||||||
|
"label": label,
|
||||||
|
"price_fmt": _fmt_price(q.price),
|
||||||
|
"change_1d_pct": (q.changes or {}).get("1d"),
|
||||||
|
}
|
||||||
|
markets.append({
|
||||||
|
"code": st["code"],
|
||||||
|
"label": st["label"],
|
||||||
|
"open": st["open"],
|
||||||
|
"until_iso": st["until"].isoformat(),
|
||||||
|
"until_hhmm": st["until"].strftime("%H:%M"),
|
||||||
|
"index": idx,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/markets_bar.html",
|
||||||
|
{"markets": markets},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def health_html(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
|
||||||
|
structured object. The default is HTML because that's how the dashboard
|
||||||
|
consumes it; CLI/curl users will pass ?as=json."""
|
||||||
|
try:
|
||||||
|
await session.execute(select(func.now()))
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
db_ok = False
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
jobs: list[dict] = []
|
||||||
|
structured: list[JobStatus] = []
|
||||||
|
for name in JOB_NAMES:
|
||||||
|
row = (await session.execute(
|
||||||
|
select(JobRun).where(JobRun.name == name)
|
||||||
|
.order_by(desc(JobRun.started_at)).limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
jobs.append({"name": name, "led": "idle", "age": "—",
|
||||||
|
"last_finished": None})
|
||||||
|
structured.append(JobStatus(name=name))
|
||||||
|
continue
|
||||||
|
if row.status == "success":
|
||||||
|
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
|
||||||
|
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
|
||||||
|
elif row.status == "skipped":
|
||||||
|
led = "warn"
|
||||||
|
elif row.status == "running":
|
||||||
|
led = "warn"
|
||||||
|
else:
|
||||||
|
led = "err"
|
||||||
|
jobs.append({
|
||||||
|
"name": name, "led": led,
|
||||||
|
"age": _fmt_age(now, row.finished_at or row.started_at),
|
||||||
|
"last_finished": row.finished_at,
|
||||||
|
})
|
||||||
|
structured.append(JobStatus(
|
||||||
|
name=name, last_started=row.started_at,
|
||||||
|
last_finished=row.finished_at, status=row.status,
|
||||||
|
error=row.error, items_written=row.items_written,
|
||||||
|
))
|
||||||
|
|
||||||
|
if as_ == "json":
|
||||||
|
return JSONResponse(
|
||||||
|
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/ops_footer.html",
|
||||||
|
{"db_ok": db_ok, "jobs": jobs},
|
||||||
|
)
|
||||||
|
|
@ -75,14 +75,13 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
|
||||||
return datetime.now(timezone.utc).date()
|
return datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
|
||||||
def _log_page_context(target: date, paid: bool) -> dict:
|
|
||||||
s = get_settings()
|
def _log_page_context(target: date, paid: bool, user_lang: str = "en") -> dict:
|
||||||
return {
|
return {
|
||||||
"selected_iso": target.isoformat(),
|
"selected_iso": target.isoformat(),
|
||||||
"selected_month": target.strftime("%Y-%m"),
|
"selected_month": target.strftime("%Y-%m"),
|
||||||
"current_tone": s.CASSANDRA_TONE.upper(),
|
|
||||||
"current_analysis": s.CASSANDRA_ANALYSIS.upper(),
|
|
||||||
"paid": paid,
|
"paid": paid,
|
||||||
|
"user_lang": user_lang,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,8 +92,9 @@ async def log_page(
|
||||||
cu: CurrentUser = Depends(require_auth),
|
cu: CurrentUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
target = await _resolve_log_date(session, None)
|
target = await _resolve_log_date(session, None)
|
||||||
|
user_lang = cu.user.lang if cu.user else "en"
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "log.html", _log_page_context(target, is_paid_active(cu)),
|
request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,8 +106,9 @@ async def log_page_day(
|
||||||
cu: CurrentUser = Depends(require_auth),
|
cu: CurrentUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
target = await _resolve_log_date(session, day)
|
target = await _resolve_log_date(session, day)
|
||||||
|
user_lang = cu.user.lang if cu.user else "en"
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "log.html", _log_page_context(target, is_paid_active(cu)),
|
request, "log.html", _log_page_context(target, is_paid_active(cu), user_lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
|
|
@ -69,6 +69,53 @@ def _price_for(cadence: str) -> str:
|
||||||
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
|
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
|
||||||
|
|
||||||
|
|
||||||
|
# Rough country → currency mapping. Covers the markets we have a stated
|
||||||
|
# rate for; everything else falls back to GBP (the home currency) and
|
||||||
|
# Stripe handles the FX at checkout. Configure the per-currency
|
||||||
|
# unit_amount on each Price's `currency_options` in the Stripe Dashboard
|
||||||
|
# — we just signal which option to use here.
|
||||||
|
_COUNTRY_CURRENCY: dict[str, str] = {
|
||||||
|
"US": "usd", "CA": "usd",
|
||||||
|
"GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp",
|
||||||
|
**dict.fromkeys((
|
||||||
|
"DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI",
|
||||||
|
"GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR",
|
||||||
|
), "eur"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Accept-Language locale → currency, used when CF-IPCountry is absent.
|
||||||
|
# Ambiguous locales (e.g. plain "fr" without region) get EUR because
|
||||||
|
# that's the majority outcome.
|
||||||
|
_LOCALE_CURRENCY: dict[str, str] = {
|
||||||
|
"en-gb": "gbp", "en": "gbp",
|
||||||
|
"en-us": "usd", "en-ca": "usd",
|
||||||
|
"fr": "eur", "de": "eur", "it": "eur", "es": "eur",
|
||||||
|
"pt": "eur", "nl": "eur",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_currency(request: Request) -> str:
|
||||||
|
"""Best-effort currency detection for new-customer checkouts.
|
||||||
|
|
||||||
|
Order: explicit Cloudflare country header, then Accept-Language
|
||||||
|
(exact match then language-only). GBP as the final fallback. Only
|
||||||
|
consulted when the user has no Stripe customer record yet — Stripe
|
||||||
|
locks currency at customer creation, so an existing customer's
|
||||||
|
currency wins regardless of the request locale.
|
||||||
|
"""
|
||||||
|
cc = (request.headers.get("cf-ipcountry") or "").upper()
|
||||||
|
if cc in _COUNTRY_CURRENCY:
|
||||||
|
return _COUNTRY_CURRENCY[cc]
|
||||||
|
al = (request.headers.get("accept-language") or "").lower()
|
||||||
|
first = al.split(",", 1)[0].split(";", 1)[0].strip()
|
||||||
|
if first in _LOCALE_CURRENCY:
|
||||||
|
return _LOCALE_CURRENCY[first]
|
||||||
|
short = first.split("-", 1)[0]
|
||||||
|
if short in _LOCALE_CURRENCY:
|
||||||
|
return _LOCALE_CURRENCY[short]
|
||||||
|
return "gbp"
|
||||||
|
|
||||||
|
|
||||||
def _stripe_client() -> stripe.StripeClient:
|
def _stripe_client() -> stripe.StripeClient:
|
||||||
"""Per-call client so we read the secret at request time (lets us
|
"""Per-call client so we read the secret at request time (lets us
|
||||||
rotate the key by editing .env + reloading without rebuilding any
|
rotate the key by editing .env + reloading without rebuilding any
|
||||||
|
|
@ -83,6 +130,10 @@ def _stripe_client() -> stripe.StripeClient:
|
||||||
|
|
||||||
class CheckoutRequest(BaseModel):
|
class CheckoutRequest(BaseModel):
|
||||||
cadence: Literal["monthly", "annual"]
|
cadence: Literal["monthly", "annual"]
|
||||||
|
# Optional override; when omitted we sniff from request headers.
|
||||||
|
# Honoured only for first-time checkouts (Stripe locks currency
|
||||||
|
# to the customer at creation).
|
||||||
|
currency: Optional[Literal["gbp", "usd", "eur"]] = None
|
||||||
|
|
||||||
|
|
||||||
class CheckoutResponse(BaseModel):
|
class CheckoutResponse(BaseModel):
|
||||||
|
|
@ -92,6 +143,7 @@ class CheckoutResponse(BaseModel):
|
||||||
@router.post("/api/stripe/checkout", response_model=CheckoutResponse)
|
@router.post("/api/stripe/checkout", response_model=CheckoutResponse)
|
||||||
async def create_checkout(
|
async def create_checkout(
|
||||||
body: CheckoutRequest,
|
body: CheckoutRequest,
|
||||||
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
cu: CurrentUser = Depends(require_auth),
|
cu: CurrentUser = Depends(require_auth),
|
||||||
) -> CheckoutResponse:
|
) -> CheckoutResponse:
|
||||||
|
|
@ -120,6 +172,13 @@ async def create_checkout(
|
||||||
# referral redemption flow ships.
|
# referral redemption flow ships.
|
||||||
"allow_promotion_codes": True,
|
"allow_promotion_codes": True,
|
||||||
}
|
}
|
||||||
|
# Multi-currency: for first-time buyers (no stripe_customer_id yet)
|
||||||
|
# we pass the detected/requested currency. Stripe picks the matching
|
||||||
|
# `currency_options` rate configured on the Price in the Dashboard,
|
||||||
|
# then locks that currency to the new customer record. Existing
|
||||||
|
# customers keep their original currency regardless.
|
||||||
|
if not user.stripe_customer_id:
|
||||||
|
create_kwargs["currency"] = body.currency or _sniff_currency(request)
|
||||||
# Per-cadence cooling-off treatment:
|
# Per-cadence cooling-off treatment:
|
||||||
#
|
#
|
||||||
# - Annual gets a 14-day free trial. No money moves during the
|
# - Annual gets a 14-day free trial. No money moves during the
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,7 @@ Four routes:
|
||||||
held in memory for one LLM
|
held in memory for one LLM
|
||||||
call, discarded on response.
|
call, discarded on response.
|
||||||
|
|
||||||
All routes require authentication (session cookie OR bearer token). The
|
All routes require authentication (session cookie OR bearer token).
|
||||||
old endpoints in `app/routers/api.py` (`/api/portfolios/upload`,
|
|
||||||
`/api/portfolio/{name}/summary`) remain live until step 10 of the Phase G
|
|
||||||
plan, when they're removed alongside the table drops.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -36,7 +33,7 @@ from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.config import get_settings
|
||||||
from app.db import get_session, utcnow
|
from app.db import get_session, utcnow
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
|
|
@ -341,10 +338,11 @@ async def parse_portfolio(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze", dependencies=[Depends(require_paid)])
|
@router.post("/analyze")
|
||||||
async def analyze_portfolio(
|
async def analyze_portfolio(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
principal: CurrentUser = Depends(require_paid),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate AI commentary for the supplied pie. The pie is held in
|
"""Generate AI commentary for the supplied pie. The pie is held in
|
||||||
memory only for the duration of the LLM call; nothing about holdings
|
memory only for the duration of the LLM call; nothing about holdings
|
||||||
|
|
@ -364,6 +362,11 @@ async def analyze_portfolio(
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="malformed JSON body")
|
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:
|
try:
|
||||||
req = portfolio_analysis.parse_request(payload)
|
req = portfolio_analysis.parse_request(payload)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,4 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# persist_pie removed in Phase G — the parsed pie is returned to the
|
|
||||||
# browser by /api/portfolio/parse and lives in localStorage. The server
|
|
||||||
# now keeps only the anonymous ticker_universe (see
|
|
||||||
# app/services/ticker_universe.py).
|
|
||||||
|
|
|
||||||
116
app/services/digest_email.py
Normal file
116
app/services/digest_email.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Daily/weekly digest email rendering.
|
||||||
|
|
||||||
|
Pure prose → HTML/text rendering. SMTP transport stays in
|
||||||
|
``email_service.send_email``; this module only assembles the message
|
||||||
|
body, subject, and a text-only fallback for clients without HTML
|
||||||
|
rendering.
|
||||||
|
|
||||||
|
Split from email_service.py during the Tier 2 cleanup pass — the
|
||||||
|
SMTP/OTP/welcome surface and the digest renderer changed at very
|
||||||
|
different cadences and made the file noisy to navigate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as _html_lib
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
from app import branding
|
||||||
|
|
||||||
|
|
||||||
|
_DIGEST_HTML_TEMPLATE = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>{brand} — {label}</title>
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: dark) {{
|
||||||
|
body {{ background:{D_bg} !important; }}
|
||||||
|
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
|
||||||
|
.h1, p, li {{ color:{D_text} !important; }}
|
||||||
|
.muted {{ color:{D_muted} !important; }}
|
||||||
|
a {{ color:{D_accent} !important; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text};">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
|
||||||
|
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
|
||||||
|
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
|
||||||
|
▰ {brand_upper} · {label_upper}
|
||||||
|
</div>
|
||||||
|
<div style="height:20px; line-height:20px; font-size:0;"> </div>
|
||||||
|
<div class="content" style="font-size:14px; line-height:1.65; color:{L_text};">
|
||||||
|
{content_html}
|
||||||
|
</div>
|
||||||
|
<div style="height:24px; line-height:24px; font-size:0;"> </div>
|
||||||
|
<div style="border-top:1px solid {L_border};"></div>
|
||||||
|
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
||||||
|
<div class="muted" style="font-size:11px; color:{L_muted};">
|
||||||
|
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
|
||||||
|
· <a href="{settings_url}" style="color:{L_accent};">Manage preferences</a>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html_to_text(html_body: str) -> str:
|
||||||
|
"""Best-effort HTML → plain text for the multipart fallback. We don't
|
||||||
|
need perfection — just readable prose for clients that won't render
|
||||||
|
HTML."""
|
||||||
|
text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body)
|
||||||
|
text = _re.sub(r"<[^>]+>", "", text)
|
||||||
|
text = _html_lib.unescape(text)
|
||||||
|
text = _re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def render_digest_email(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
date_str: str,
|
||||||
|
content_html: str,
|
||||||
|
unsubscribe_url: str,
|
||||||
|
settings_url: str,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
"""Returns (subject, text_body, html_body) for a digest email.
|
||||||
|
|
||||||
|
`kind` is "daily" or "weekly". Anything else raises ValueError."""
|
||||||
|
if kind == "daily":
|
||||||
|
label = "Daily"
|
||||||
|
subject = f"{branding.BRAND_NAME} · Daily — {date_str}"
|
||||||
|
elif kind == "weekly":
|
||||||
|
label = "Weekly recap"
|
||||||
|
subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown digest kind: {kind!r}")
|
||||||
|
|
||||||
|
html_body = _DIGEST_HTML_TEMPLATE.format(
|
||||||
|
brand=branding.BRAND_NAME,
|
||||||
|
brand_upper=branding.BRAND_NAME.upper(),
|
||||||
|
label=label,
|
||||||
|
label_upper=label.upper(),
|
||||||
|
FONT_MONO=branding.FONT_MONO,
|
||||||
|
content_html=content_html,
|
||||||
|
unsubscribe_url=unsubscribe_url,
|
||||||
|
settings_url=settings_url,
|
||||||
|
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
|
||||||
|
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
text_lines = [
|
||||||
|
f"{branding.BRAND_NAME} — {label}",
|
||||||
|
date_str,
|
||||||
|
"",
|
||||||
|
_strip_html_to_text(content_html),
|
||||||
|
"",
|
||||||
|
f"Unsubscribe: {unsubscribe_url}",
|
||||||
|
f"Manage preferences: {settings_url}",
|
||||||
|
]
|
||||||
|
text_body = "\n".join(text_lines)
|
||||||
|
return subject, text_body, html_body
|
||||||
|
|
@ -18,8 +18,6 @@ convenient for local dev that doesn't want a mail server configured.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html as _html_lib
|
|
||||||
import re as _re
|
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
|
|
@ -323,106 +321,3 @@ async def send_welcome_email(to: str) -> None:
|
||||||
subject, text, html = render_welcome_email()
|
subject, text, html = render_welcome_email()
|
||||||
await send_email(to, subject, text, html_body=html)
|
await send_email(to, subject, text, html_body=html)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Digest email rendering
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_DIGEST_HTML_TEMPLATE = """\
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="color-scheme" content="light dark">
|
|
||||||
<title>{brand} — {label}</title>
|
|
||||||
<style>
|
|
||||||
@media (prefers-color-scheme: dark) {{
|
|
||||||
body {{ background:{D_bg} !important; }}
|
|
||||||
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
|
|
||||||
.h1, p, li {{ color:{D_text} !important; }}
|
|
||||||
.muted {{ color:{D_muted} !important; }}
|
|
||||||
a {{ color:{D_accent} !important; }}
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text};">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
|
|
||||||
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
|
|
||||||
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
|
|
||||||
▰ {brand_upper} · {label_upper}
|
|
||||||
</div>
|
|
||||||
<div style="height:20px; line-height:20px; font-size:0;"> </div>
|
|
||||||
<div class="content" style="font-size:14px; line-height:1.65; color:{L_text};">
|
|
||||||
{content_html}
|
|
||||||
</div>
|
|
||||||
<div style="height:24px; line-height:24px; font-size:0;"> </div>
|
|
||||||
<div style="border-top:1px solid {L_border};"></div>
|
|
||||||
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
|
||||||
<div class="muted" style="font-size:11px; color:{L_muted};">
|
|
||||||
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
|
|
||||||
· <a href="{settings_url}" style="color:{L_accent};">Manage preferences</a>
|
|
||||||
</div>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_html_to_text(html_body: str) -> str:
|
|
||||||
"""Best-effort HTML → plain text for the multipart fallback. We don't
|
|
||||||
need perfection — just readable prose for clients that won't render
|
|
||||||
HTML."""
|
|
||||||
text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body)
|
|
||||||
text = _re.sub(r"<[^>]+>", "", text)
|
|
||||||
text = _html_lib.unescape(text)
|
|
||||||
text = _re.sub(r"\n{3,}", "\n\n", text)
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def render_digest_email(
|
|
||||||
*,
|
|
||||||
kind: str,
|
|
||||||
date_str: str,
|
|
||||||
content_html: str,
|
|
||||||
unsubscribe_url: str,
|
|
||||||
settings_url: str,
|
|
||||||
) -> tuple[str, str, str]:
|
|
||||||
"""Returns (subject, text_body, html_body) for a digest email.
|
|
||||||
|
|
||||||
`kind` is "daily" or "weekly". Anything else raises ValueError."""
|
|
||||||
if kind == "daily":
|
|
||||||
label = "Daily"
|
|
||||||
subject = f"{branding.BRAND_NAME} · Daily — {date_str}"
|
|
||||||
elif kind == "weekly":
|
|
||||||
label = "Weekly recap"
|
|
||||||
subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}"
|
|
||||||
else:
|
|
||||||
raise ValueError(f"unknown digest kind: {kind!r}")
|
|
||||||
|
|
||||||
html_body = _DIGEST_HTML_TEMPLATE.format(
|
|
||||||
brand=branding.BRAND_NAME,
|
|
||||||
brand_upper=branding.BRAND_NAME.upper(),
|
|
||||||
label=label,
|
|
||||||
label_upper=label.upper(),
|
|
||||||
FONT_MONO=branding.FONT_MONO,
|
|
||||||
content_html=content_html,
|
|
||||||
unsubscribe_url=unsubscribe_url,
|
|
||||||
settings_url=settings_url,
|
|
||||||
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
|
|
||||||
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
|
|
||||||
)
|
|
||||||
|
|
||||||
text_lines = [
|
|
||||||
f"{branding.BRAND_NAME} — {label}",
|
|
||||||
date_str,
|
|
||||||
"",
|
|
||||||
_strip_html_to_text(content_html),
|
|
||||||
"",
|
|
||||||
f"Unsubscribe: {unsubscribe_url}",
|
|
||||||
f"Manage preferences: {settings_url}",
|
|
||||||
]
|
|
||||||
text_body = "\n".join(text_lines)
|
|
||||||
return subject, text_body, html_body
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ The wrap markup is:
|
||||||
<span class="glossary" data-def="..." title="..." tabindex="0">VIX</span>
|
<span class="glossary" data-def="..." title="..." tabindex="0">VIX</span>
|
||||||
|
|
||||||
`title` gives a native fallback on touch devices that don't fire :hover.
|
`title` gives a native fallback on touch devices that don't fire :hover.
|
||||||
The CSS tooltip (see `.glossary:hover::after` in cassandra.css) uses
|
The CSS tooltip (see `.glossary` / `#glossary-tooltip` in dashboard.css)
|
||||||
`data-def` for richer formatting. Wrapping happens at most once per term
|
uses `data-def` for richer formatting. Wrapping happens at most once per term
|
||||||
per HTML fragment — repeated occurrences stay plain.
|
per HTML fragment — repeated occurrences stay plain.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
48
app/services/i18n.py
Normal file
48
app/services/i18n.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Language registry + prompt helpers for localized AI output.
|
||||||
|
|
||||||
|
Two surfaces consume this module:
|
||||||
|
- Per-user LLM call sites (portfolio analysis only at this stage) 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. The strategic-log and digest
|
||||||
|
translation fan-outs also consult it to decide which languages to
|
||||||
|
spend tokens on.
|
||||||
|
|
||||||
|
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 + digest translation fan-outs only consider
|
||||||
|
# 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 <Language>."``
|
||||||
|
keyed off ``LANGUAGES``.
|
||||||
|
"""
|
||||||
|
if not lang or lang == "en" or lang not in LANGUAGES:
|
||||||
|
return ""
|
||||||
|
return f"\n\nRespond in {LANGUAGES[lang]}."
|
||||||
|
|
@ -424,8 +424,10 @@ async def parse_with_llm(raw: bytes, session: AsyncSession) -> ParsedPie:
|
||||||
first_seen_at=now,
|
first_seen_at=now,
|
||||||
last_used_at=now,
|
last_used_at=now,
|
||||||
use_count=1,
|
use_count=1,
|
||||||
llm_model=llm_log.model,
|
model=llm_log.model,
|
||||||
llm_cost_usd=llm_log.cost_usd,
|
prompt_tokens=llm_log.prompt_tokens,
|
||||||
|
completion_tokens=llm_log.completion_tokens,
|
||||||
|
cost_usd=llm_log.cost_usd,
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return pie
|
return pie
|
||||||
|
|
|
||||||
620
app/services/llm_prompts.py
Normal file
620
app/services/llm_prompts.py
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
"""Prompt-engineering surface for AI surfaces.
|
||||||
|
|
||||||
|
This module assembles the system + user prompts the LLM ingests. It
|
||||||
|
has no I/O — pure string-building from typed inputs. Pair with
|
||||||
|
``app.services.openrouter`` (the transport layer) which actually
|
||||||
|
calls the model.
|
||||||
|
|
||||||
|
The two halves of LLM work — what to ask vs how to ask — change at
|
||||||
|
very different cadences. Prompt-version bumps (see PROMPT_VERSION
|
||||||
|
below) happen ~weekly; transport changes are rare.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Bump when the composed prompt changes meaningfully. Stored on every
|
||||||
|
# StrategicLog row so historical logs can be linked to the prompt that produced
|
||||||
|
# them.
|
||||||
|
#
|
||||||
|
# v6 (2026-05-17): TONE shrinks to NOVICE | INTERMEDIATE (PRO dropped). New
|
||||||
|
# educational stance baked into _CORE — explicit anti-TA, anti-gambling-mindset
|
||||||
|
# framing aimed at young investors entering the trading world. NOVICE retuned
|
||||||
|
# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE
|
||||||
|
# kept terse but with light-touch educational nudges. See tasks/todo.md.
|
||||||
|
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
|
||||||
|
# the model was hallucinating future times. The user prompt now carries the
|
||||||
|
# actual current UTC time so the model has accurate temporal context.
|
||||||
|
# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email.
|
||||||
|
PROMPT_VERSION = 9
|
||||||
|
|
||||||
|
|
||||||
|
# --- Core: invariant across tone/analysis settings ----------------------------
|
||||||
|
|
||||||
|
_CORE = """You are Cassandra, writing a single daily strategic markets log \
|
||||||
|
for one specific investor. Synthesis, not exposition.
|
||||||
|
|
||||||
|
# Lens
|
||||||
|
- Geopolitics → markets is the primary causal chain. For each sector move, \
|
||||||
|
ask: geopolitical, cyclical, or idiosyncratic. Label it.
|
||||||
|
- Divergences and contradictions are where the information is. Hunt for them.
|
||||||
|
- Absence of expected moves is signal. If the thesis predicted a reaction \
|
||||||
|
that didn't happen, that's more interesting than the reactions that did.
|
||||||
|
- Compare live readings against any reference snapshots provided.
|
||||||
|
|
||||||
|
# Multi-source news
|
||||||
|
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
|
||||||
|
cover the same event, read the gap in framing — that's the data.
|
||||||
|
- News matters only insofar as it changes a market read. Color without \
|
||||||
|
implications is filler.
|
||||||
|
|
||||||
|
# Structure
|
||||||
|
- One-line date header containing ONLY the date (e.g. `2026-05-18`) and \
|
||||||
|
optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \
|
||||||
|
**Never include a time-of-day clause like "(Updated 21:30 UTC)"** — \
|
||||||
|
generation time is recorded as metadata elsewhere. Inventing a future or \
|
||||||
|
arbitrary time in the header confuses readers.
|
||||||
|
- Immediately after the date header — with **nothing** in between — write a \
|
||||||
|
TL;DR. Format it as:
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
One concise paragraph of 2-3 sentences, **≤60 words total**, naming the \
|
||||||
|
single most important read or divergence of the day with concrete numbers. \
|
||||||
|
This is what a reader who only has 10 seconds sees. Don't waste it on the \
|
||||||
|
weather or generic context.
|
||||||
|
|
||||||
|
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
|
||||||
|
numbers in every paragraph. No section over ~150 words.
|
||||||
|
- One paragraph synthesising the news flow into a market read.
|
||||||
|
- End with a watch list: 3-5 specific items to track in the next week, \
|
||||||
|
each one sentence.
|
||||||
|
|
||||||
|
# Time-horizon discipline
|
||||||
|
- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \
|
||||||
|
2% as background noise; mention them only when they break or confirm a \
|
||||||
|
multi-week trend or are extreme outliers.
|
||||||
|
- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \
|
||||||
|
multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \
|
||||||
|
omit the paragraph.
|
||||||
|
- The watch list is for "structural tripwires over the next 1-3 months", not \
|
||||||
|
"things to watch tomorrow". Each watch item should name a level/threshold \
|
||||||
|
whose breach would change the regime, not a calendar-date event.
|
||||||
|
|
||||||
|
# Rational vs irrational framing (MANDATORY in every paragraph)
|
||||||
|
The reader's primary goal is to disconnect rational decisions from market \
|
||||||
|
irrationality. This is the single most important lens of the log — it MUST \
|
||||||
|
appear in every sector or theme paragraph, not just where it feels natural. \
|
||||||
|
For each paragraph, before writing it, ask yourself the two questions and \
|
||||||
|
then make both answers visible in the prose:
|
||||||
|
- The RATIONAL drivers — what the underlying factors justify: earnings, \
|
||||||
|
real-economy data, monetary policy, structural geopolitical shifts, \
|
||||||
|
valuation vs fundamentals.
|
||||||
|
- The IRRATIONAL drivers — what the crowd is doing regardless of fundamentals: \
|
||||||
|
positioning, narrative momentum, sentiment extremes, concentration, \
|
||||||
|
flow-driven moves, options gamma, credit complacency.
|
||||||
|
Then state the GAP: is price moving with the rational read, ahead of it, \
|
||||||
|
or against it? If they agree, say so briefly and move on. If they diverge \
|
||||||
|
— price moving on irrational drivers while fundamentals say otherwise, or \
|
||||||
|
vice versa — name the divergence explicitly. Those gaps are where the next \
|
||||||
|
regime change starts and are the whole point of this log.
|
||||||
|
A paragraph that names only price action or only fundamentals, without \
|
||||||
|
both lenses, is incomplete and must be rewritten.
|
||||||
|
|
||||||
|
# Discipline
|
||||||
|
- No emojis, no marketing language, no "concerning" or "unprecedented" \
|
||||||
|
without a specific number behind it.
|
||||||
|
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
|
||||||
|
- Distinguish "the thesis predicted X and X happened" from "the thesis \
|
||||||
|
predicted X and X did not happen". Both are useful; conflating them is not.
|
||||||
|
- Don't repeat the same point in different words across paragraphs.
|
||||||
|
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
|
||||||
|
to report whether reality is confirming, modifying, or refuting the thesis.
|
||||||
|
|
||||||
|
# Stance (educational, anti-TA, anti-gambling)
|
||||||
|
The target reader is most likely young, new to investing, and at risk of \
|
||||||
|
treating markets like a horse race they need to "read" via chart patterns. \
|
||||||
|
Cassandra is the corrective.
|
||||||
|
- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \
|
||||||
|
levels, Elliott waves, "support/resistance" — these are descriptions of past \
|
||||||
|
crowd behaviour, not predictions. Don't use them; don't legitimise them. If \
|
||||||
|
you mention a price level, frame it as a positioning fact (e.g. "the level \
|
||||||
|
where the latest tranche of buyers entered"), not a signal.
|
||||||
|
- **No gambling framing.** Markets are not a coin flip and not a horse race. \
|
||||||
|
Never present a position as a single decisive moment, a "now or never", or a \
|
||||||
|
bet to be won. Every read should follow the shape: *regime → implication → \
|
||||||
|
what would change the regime*.
|
||||||
|
- **Macro causality, every time.** Price moves get explained through \
|
||||||
|
fundamentals, geopolitics, monetary policy, and structural shifts — not \
|
||||||
|
chart shapes. Even short paragraphs need the cause, not just the effect.
|
||||||
|
|
||||||
|
# System temperature (closing line, mandatory)
|
||||||
|
Close the log with a single sentence on a line of its own, formatted exactly:
|
||||||
|
|
||||||
|
System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label]
|
||||||
|
|
||||||
|
This is the line a reader who only sees the watch list scrolls down to. Make \
|
||||||
|
it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \
|
||||||
|
yields), not vibes.
|
||||||
|
|
||||||
|
# Update mode (when an earlier log from today is provided)
|
||||||
|
If the user message includes a section labelled "Earlier log from today \
|
||||||
|
(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \
|
||||||
|
UPDATING it for the current data, not starting from scratch.
|
||||||
|
- Don't restate context that hasn't changed. Anchor on what's moved SINCE \
|
||||||
|
that timestamp: confirmations, refutations, new emergent patterns.
|
||||||
|
- The TL;DR should lead with the move since the earlier read when there \
|
||||||
|
was a meaningful intra-day change ("Since this morning's read, …") — \
|
||||||
|
otherwise stay regime-level.
|
||||||
|
- The watch list should evolve: drop items that triggered or settled, add \
|
||||||
|
items that emerged. Keep items still load-bearing.
|
||||||
|
- Preserve any insights from the earlier draft that remain valid; sharpen \
|
||||||
|
or revise the ones that don't. Avoid contradicting yourself silently — if \
|
||||||
|
you change a stance, name it briefly ("Earlier I read X; with Y now, the \
|
||||||
|
read shifts to Z")."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tone: audience-shaping block --------------------------------------------
|
||||||
|
|
||||||
|
_TONE: dict[str, str] = {
|
||||||
|
"NOVICE": """# Audience: novice — likely a young investor new to markets
|
||||||
|
This reader probably arrived from social media, treats charts as predictions, \
|
||||||
|
and is one bad week away from quitting. Your job is to **educate them out of \
|
||||||
|
the gambling mindset** without ever being preachy. Calm, patient, slightly \
|
||||||
|
teacherly. Never condescending.
|
||||||
|
|
||||||
|
- **Define jargon the first time it appears.** A short clause in parentheses \
|
||||||
|
is fine: "yield curve (the chart of borrowing costs across different \
|
||||||
|
maturities)", "ERP (equity risk premium — the extra return investors demand \
|
||||||
|
for owning stocks instead of safe bonds)", "basis point (one hundredth of a \
|
||||||
|
percent — 25bp = 0.25%)".
|
||||||
|
- **Avoid ticker shorthand without context.** Use "Apple (AAPL)" on first \
|
||||||
|
mention, then "Apple" or the ticker after.
|
||||||
|
- **Everyday phrasing over jargon** where the meaning survives: "the price \
|
||||||
|
of US government debt fell, pushing yields up" rather than "the long end \
|
||||||
|
backed up"; "investors are paying more for the same earnings" rather than \
|
||||||
|
"multiple expansion".
|
||||||
|
- **One analogy per concept, used sparingly.** Use them to bridge to \
|
||||||
|
something concrete the reader already understands — not to entertain.
|
||||||
|
|
||||||
|
# Educational teach-backs (NOVICE-specific, when warranted)
|
||||||
|
When the day's data makes a common misconception concrete, drop in ONE \
|
||||||
|
teach-back of one to two sentences. Don't force it. Don't moralise. Examples \
|
||||||
|
of moments to do this:
|
||||||
|
|
||||||
|
- Anyone treating chart patterns as predictions: \
|
||||||
|
"Patterns like head-and-shoulders describe what crowds did, not what they \
|
||||||
|
will do — they're stories told after the fact, not edges."
|
||||||
|
- Anyone fixated on day-to-day moves: \
|
||||||
|
"A 1% one-day move in a stock is roughly what you'd expect by chance. The \
|
||||||
|
multi-week trend is where the information lives."
|
||||||
|
- Anyone treating one ticker as a coin flip: \
|
||||||
|
"A single name's monthly move is mostly noise. The regime — what bonds, the \
|
||||||
|
dollar, and credit are doing together — tells you whether ANY stock is \
|
||||||
|
likely to drift up or down."
|
||||||
|
- Anyone trying to "time the bottom" or "buy the dip": \
|
||||||
|
"Catching the bottom is a different game from owning the next cycle. The \
|
||||||
|
first needs you to be right within days; the second needs you to be roughly \
|
||||||
|
right within years."
|
||||||
|
|
||||||
|
Limit yourself to one teach-back per log. Skip them entirely if the day's \
|
||||||
|
data doesn't naturally invite one.
|
||||||
|
|
||||||
|
# Length
|
||||||
|
Target ~700 words. Slightly more than INTERMEDIATE because explanations \
|
||||||
|
need breathing room.""",
|
||||||
|
|
||||||
|
"INTERMEDIATE": """# Audience: intermediate — reads the news, learning to \
|
||||||
|
connect macro to markets
|
||||||
|
Assume the reader knows market basics (yield curves, breakevens, HY OAS, \
|
||||||
|
sector ETFs, the difference between cyclical and defensive, what a basis \
|
||||||
|
point is). Use common terms without defining them, but stay clear of deep \
|
||||||
|
institutional shorthand ("the belly", "duration trade", "carry pickup", \
|
||||||
|
"the RV book", "off-the-run").
|
||||||
|
|
||||||
|
Light-touch educational nudges are welcome when the day's data warrants — \
|
||||||
|
e.g. "with rates this volatile, technical levels in equities are mostly \
|
||||||
|
distraction" — but keep them to a passing clause, not a paragraph. Don't \
|
||||||
|
moralise.
|
||||||
|
|
||||||
|
# Length
|
||||||
|
Target ~600 words. Lean and clear, no padding.""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy values map to the closest current value. Logs a warning so we can
|
||||||
|
# notice if some caller's config didn't get updated.
|
||||||
|
_TONE_ALIASES = {
|
||||||
|
"PRO": "INTERMEDIATE",
|
||||||
|
"PROFESSIONAL": "INTERMEDIATE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tone(tone: str) -> str:
|
||||||
|
"""Map a caller-supplied tone string to one of {NOVICE, INTERMEDIATE}.
|
||||||
|
|
||||||
|
Unknown tones fall back to INTERMEDIATE. The legacy PRO value is mapped
|
||||||
|
to INTERMEDIATE (audience pivot, see PROMPT_VERSION v6 notes)."""
|
||||||
|
upper = (tone or "").upper().strip()
|
||||||
|
if upper in _TONE:
|
||||||
|
return upper
|
||||||
|
if upper in _TONE_ALIASES:
|
||||||
|
return _TONE_ALIASES[upper]
|
||||||
|
return "INTERMEDIATE"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Analysis: forward-vs-backward focus -------------------------------------
|
||||||
|
|
||||||
|
_ANALYSIS: dict[str, str] = {
|
||||||
|
"DRY": """# Analysis style: dry
|
||||||
|
Report what happened. Identify divergences and contradictions. Compare to \
|
||||||
|
references. Do not speculate on what comes next. Forward-looking statements \
|
||||||
|
are limited to "what would invalidate the read" — never "we expect X to \
|
||||||
|
happen". The watch list contains items to monitor, not predictions.""",
|
||||||
|
|
||||||
|
"SPECULATIVE": """# Analysis style: speculative
|
||||||
|
Report what happened, then explicitly explore forward scenarios. For each \
|
||||||
|
significant sector or theme, sketch a 1-4 week scenario set: the base case \
|
||||||
|
(what the data suggests), a contrarian case (what would invalidate it), and \
|
||||||
|
what tape signal would tip you from one to the other. Be explicit about \
|
||||||
|
uncertainty — say "the base case is" not "X will happen". The watch list is \
|
||||||
|
the trip-wires that decide between scenarios.""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(tone: str, analysis: str) -> str:
|
||||||
|
"""Compose the system prompt from the chosen audience and analysis style."""
|
||||||
|
tone_block = _TONE[_resolve_tone(tone)]
|
||||||
|
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||||
|
return "\n\n".join([_CORE, tone_block, analysis_block])
|
||||||
|
|
||||||
|
|
||||||
|
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
|
||||||
|
# don't yet pass tone/analysis. New callers should call build_system_prompt().
|
||||||
|
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
|
||||||
|
|
||||||
|
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
|
||||||
|
You are NOT writing a daily log right now. The user is asking a specific
|
||||||
|
question via the chat sidebar.
|
||||||
|
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
|
||||||
|
- Typical response: 200-400 words. Longer only if the question genuinely
|
||||||
|
warrants it.
|
||||||
|
- Cite specific numbers and named headlines from the reference materials
|
||||||
|
below whenever relevant. If a number isn't in the context, don't invent it.
|
||||||
|
- If a question is outside the provided context (e.g. asking about a stock or
|
||||||
|
event not in the data), say so plainly rather than speculating from prior
|
||||||
|
knowledge.
|
||||||
|
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
|
||||||
|
- Keep the same audience and analysis discipline established above."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_system_prompt(tone: str, analysis: str) -> str:
|
||||||
|
"""A lean, focused system prompt for the per-indicator-group hourly
|
||||||
|
summary. INTERPRETATION not description — the reader has the table
|
||||||
|
next to this paragraph; they don't need numbers recited at them.
|
||||||
|
|
||||||
|
Output is JSON-mode: the model must emit a single object
|
||||||
|
{"read": "..."}. The wrapper makes scratchpad outside the field
|
||||||
|
physically impossible — the API enforces well-formed JSON, and the
|
||||||
|
only schema slot is the publishable read. Scratchpad inside the
|
||||||
|
field is caught by the reviewer agent (services/output_review)."""
|
||||||
|
tone_block = _TONE[_resolve_tone(tone)]
|
||||||
|
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||||
|
return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \
|
||||||
|
of ONE indicator group for a strategic markets dashboard.
|
||||||
|
|
||||||
|
# Output format (strict)
|
||||||
|
Return ONLY a single JSON object with exactly one field:
|
||||||
|
{{"read": "<your 2-3 sentence interpretation>"}}
|
||||||
|
Nothing outside that JSON object. No preamble. No markdown fences. \
|
||||||
|
No additional fields. The "read" string is what the user sees verbatim, \
|
||||||
|
so it must already be the finished, publishable text — never your thinking.
|
||||||
|
|
||||||
|
# What this is for
|
||||||
|
The reader is looking at the table of numbers right next to your text. \
|
||||||
|
They can see the values. They CANNOT see the meaning. Your job is to \
|
||||||
|
**explain what the data means**, not to recite it. Each sentence should be \
|
||||||
|
a regime-level interpretation, a fundamental driver identification, or a \
|
||||||
|
cross-indicator implication — not a description of moves.
|
||||||
|
|
||||||
|
# Rational vs irrational lens (required at this length too)
|
||||||
|
Even at 2-3 sentences, contrast what the underlying factors justify \
|
||||||
|
(rational: fundamentals, policy, valuation) with what the crowd is doing \
|
||||||
|
(irrational: positioning, narrative, flows) whenever the two diverge. If \
|
||||||
|
they don't diverge, say so in one clause. Never just describe the move \
|
||||||
|
without placing it on this axis.
|
||||||
|
|
||||||
|
# Hard constraints on the "read" string
|
||||||
|
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
|
||||||
|
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
|
||||||
|
"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \
|
||||||
|
at", "Based on", "Summary:", "The data shows", "First", "To address". No \
|
||||||
|
meta-commentary at all.
|
||||||
|
- No rhetorical questions, no "X? Actually Y?" self-corrections, no \
|
||||||
|
parenthetical asides that question your own numbers. The text is the \
|
||||||
|
finished read, not the thinking.
|
||||||
|
- Cite at most 2-3 specific numbers and ONLY when they anchor an \
|
||||||
|
interpretation. Don't list moves; explain them.
|
||||||
|
- Multi-week / multi-month horizon. 1-day moves under 2% are noise — skip.
|
||||||
|
- No buy/sell language. No predictions. No watch list. No TL;DR. No date \
|
||||||
|
header. No "system temperature" line — that belongs to the full daily log.
|
||||||
|
|
||||||
|
{tone_block}
|
||||||
|
|
||||||
|
{analysis_block}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
|
||||||
|
parts = [
|
||||||
|
f"# Group: {group_name}",
|
||||||
|
"Indicators (latest reading + 1d/1m/1y/since-anchor change):",
|
||||||
|
"```json",
|
||||||
|
json.dumps(quotes, indent=2, default=str)[:12000],
|
||||||
|
"```",
|
||||||
|
"\nWrite the 2-3 sentence read for this group now.",
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str:
|
||||||
|
"""System prompt for the cross-group aggregate read shown on the dashboard.
|
||||||
|
Wider lens than a per-group summary — synthesise across all groups.
|
||||||
|
|
||||||
|
Same JSON-mode contract as build_summary_system_prompt: output is
|
||||||
|
{"read": "..."} only; the field is the publishable text verbatim."""
|
||||||
|
tone_block = _TONE[_resolve_tone(tone)]
|
||||||
|
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||||
|
return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \
|
||||||
|
words, 2-4 sentences) for the dashboard header. The reader is glancing — \
|
||||||
|
give them the meaning of the whole tape, not a recap.
|
||||||
|
|
||||||
|
# Output format (strict)
|
||||||
|
Return ONLY a single JSON object with exactly one field:
|
||||||
|
{{"read": "<your 2-4 sentence cross-asset interpretation>"}}
|
||||||
|
Nothing outside that JSON object. No preamble. No markdown fences. \
|
||||||
|
No additional fields. The "read" string is what the user sees verbatim.
|
||||||
|
|
||||||
|
# What this is for
|
||||||
|
The reader can see every indicator on the dashboard below this paragraph. \
|
||||||
|
Your job is NOT to summarise the moves. It is to explain what the moves, \
|
||||||
|
**taken together as a system**, mean: which regime is being signalled, \
|
||||||
|
which divergences are load-bearing, what fundamental story the cross-asset \
|
||||||
|
behaviour tells.
|
||||||
|
|
||||||
|
# Rational vs irrational lens (required at this length too)
|
||||||
|
The cross-asset tape's value is in the gap between what the underlying \
|
||||||
|
factors justify (rational: fundamentals, policy, valuation) and what the \
|
||||||
|
crowd is actually doing (irrational: positioning, narrative momentum, \
|
||||||
|
flows). At least one of the 2-4 sentences must name this gap or, if the \
|
||||||
|
two cohere, explicitly say so.
|
||||||
|
|
||||||
|
# Hard constraints on the "read" string
|
||||||
|
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
|
||||||
|
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
|
||||||
|
"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \
|
||||||
|
"The data shows", "Across the board". No meta-commentary.
|
||||||
|
- No rhetorical questions, no "X? Actually Y?" self-corrections, no \
|
||||||
|
parenthetical asides that question your own numbers.
|
||||||
|
- Identify the single most important **cross-asset implication**: e.g. \
|
||||||
|
"rates and credit disagree", "equities outrun fundamentals", "geopolitical \
|
||||||
|
risk premium is in commodities but not vol". Cite no more than 3 specific \
|
||||||
|
numbers, and only as anchors for the interpretation.
|
||||||
|
- Multi-week / multi-month horizon. 1-day moves under 2% are noise.
|
||||||
|
- No buy/sell language. No predictions of specific levels.
|
||||||
|
|
||||||
|
{tone_block}
|
||||||
|
|
||||||
|
{analysis_block}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str:
|
||||||
|
parts = [
|
||||||
|
"# All indicator groups (latest readings + change windows)",
|
||||||
|
"```json",
|
||||||
|
json.dumps(quotes_by_group, indent=2, default=str)[:20000],
|
||||||
|
"```",
|
||||||
|
"\nWrite the cross-asset aggregate read now.",
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chat_system_prompt(
|
||||||
|
tone: str,
|
||||||
|
analysis: str,
|
||||||
|
*,
|
||||||
|
log_content: str | None,
|
||||||
|
log_generated_at: datetime | None,
|
||||||
|
quotes_by_group: dict[str, list[dict]],
|
||||||
|
headlines: list[dict],
|
||||||
|
reference_line: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Composed system prompt for the /log chat sidebar. Carries the user's
|
||||||
|
chosen tone + analysis style and inlines the latest log + market data +
|
||||||
|
headlines as reference material the model can cite from."""
|
||||||
|
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
|
||||||
|
if reference_line:
|
||||||
|
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
|
||||||
|
if log_content:
|
||||||
|
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
|
||||||
|
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
|
||||||
|
parts.append("# Live market data")
|
||||||
|
parts.append(
|
||||||
|
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
|
||||||
|
)
|
||||||
|
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
|
||||||
|
for h in headlines[:50]:
|
||||||
|
parts.append(f"- [{h['source']}] {h['title']}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_prompt(
|
||||||
|
*,
|
||||||
|
today: datetime,
|
||||||
|
anchor: str | None,
|
||||||
|
quotes_by_group: dict[str, list[dict]],
|
||||||
|
headlines_by_bucket: dict[str, list[dict]],
|
||||||
|
reference_line: str | None = None,
|
||||||
|
previous_log: object | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Assemble the user message from already-fetched-and-persisted data.
|
||||||
|
If `previous_log` is a StrategicLog from earlier today, it's included
|
||||||
|
as 'Update mode' context — the model will revise rather than restart."""
|
||||||
|
parts = [
|
||||||
|
f"# Strategic log request — {today.strftime('%Y-%m-%d')}",
|
||||||
|
# Explicit current time so the model doesn't hallucinate one. The
|
||||||
|
# date header it writes MUST stay date-only (per system prompt).
|
||||||
|
f"Current time: {today.strftime('%Y-%m-%d %H:%M UTC')}",
|
||||||
|
]
|
||||||
|
if anchor:
|
||||||
|
parts.append(f"Anchor reference date: {anchor}")
|
||||||
|
if reference_line:
|
||||||
|
parts.append(
|
||||||
|
"\n## Reference snapshot (when the macro thesis was authored)"
|
||||||
|
f"\n{reference_line}\nCompare live readings against it."
|
||||||
|
)
|
||||||
|
|
||||||
|
if previous_log is not None:
|
||||||
|
gen = getattr(previous_log, "generated_at", None)
|
||||||
|
ts = gen.strftime("%H:%M UTC") if gen else "earlier today"
|
||||||
|
parts.append(
|
||||||
|
f"\n## Earlier log from today (generated {ts})\n"
|
||||||
|
"Treat this as YOUR OWN earlier draft for today. Update it for\n"
|
||||||
|
"the current data — don't restate unchanged context. See the\n"
|
||||||
|
"'Update mode' section of the system prompt for how to handle it.\n"
|
||||||
|
"```markdown\n"
|
||||||
|
f"{previous_log.content}\n"
|
||||||
|
"```"
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append("\n## Live market data (per group)")
|
||||||
|
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
|
||||||
|
parts.append("\n## News flow (last 24h, filtered by bucket)")
|
||||||
|
for label, items in headlines_by_bucket.items():
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
parts.append(f"\n### {label.upper()}")
|
||||||
|
for h in items[:30]:
|
||||||
|
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
|
||||||
|
|
||||||
|
task_line = (
|
||||||
|
"\n## Task\nWrite the daily strategic log in ~800 words, following "
|
||||||
|
"the discipline in the system prompt. No preamble; begin directly "
|
||||||
|
"with the date header."
|
||||||
|
)
|
||||||
|
if previous_log is not None:
|
||||||
|
task_line = (
|
||||||
|
"\n## Task\nUpdate the earlier log above for the current data. "
|
||||||
|
"Keep the same structure (date header, TL;DR, sections, watch "
|
||||||
|
"list, system temperature) but anchor on what has CHANGED since "
|
||||||
|
"the earlier draft's timestamp. ~800 words. No preamble."
|
||||||
|
)
|
||||||
|
parts.append(task_line)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _digest_tone_clause(tone: str) -> str:
|
||||||
|
if tone.upper() == "NOVICE":
|
||||||
|
return "Use plain English. Define any jargon on first use."
|
||||||
|
return "Write for a reader who already speaks markets fluently."
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily_digest_prompt(
|
||||||
|
*,
|
||||||
|
tone: str,
|
||||||
|
today,
|
||||||
|
quotes_by_group: dict,
|
||||||
|
headlines_by_bucket: dict,
|
||||||
|
reference_line: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""System + user prompt for the once-a-day editorial digest.
|
||||||
|
|
||||||
|
Different from the hourly log: the daily digest reflects on the past
|
||||||
|
24h and looks forward to the upcoming session. Longer, less
|
||||||
|
'live-blogging,' more contextual. Target ~600 words."""
|
||||||
|
system = (
|
||||||
|
"You write the daily editorial digest for Read the Markets. "
|
||||||
|
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
|
||||||
|
"Cover: (1) what mattered yesterday, (2) what to watch in today's "
|
||||||
|
"EU and US sessions, (3) one cross-asset thread connecting them. "
|
||||||
|
"No predictions of price level, no buy/sell language. Target ~600 "
|
||||||
|
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
|
||||||
|
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
|
||||||
|
)
|
||||||
|
user = _digest_user_prompt(
|
||||||
|
today=today, quotes_by_group=quotes_by_group,
|
||||||
|
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
|
||||||
|
)
|
||||||
|
return system, user
|
||||||
|
|
||||||
|
|
||||||
|
def build_weekly_digest_prompt(
|
||||||
|
*,
|
||||||
|
tone: str,
|
||||||
|
today,
|
||||||
|
quotes_by_group: dict,
|
||||||
|
headlines_by_bucket: dict,
|
||||||
|
reference_line: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""System + user prompt for the Sunday weekly recap + look-ahead.
|
||||||
|
|
||||||
|
Sent to ALL opt-in users (free and paid). Target ~900 words."""
|
||||||
|
system = (
|
||||||
|
"You write the Sunday weekly digest for Read the Markets. "
|
||||||
|
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
|
||||||
|
"Cover: (1) the week behind — what moved and why, "
|
||||||
|
"(2) the week ahead — releases, earnings, central-bank meetings, "
|
||||||
|
"(3) the cross-asset story to keep in mind. "
|
||||||
|
"No predictions of price level, no buy/sell language. Target ~900 "
|
||||||
|
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
|
||||||
|
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
|
||||||
|
)
|
||||||
|
user = _digest_user_prompt(
|
||||||
|
today=today, quotes_by_group=quotes_by_group,
|
||||||
|
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
|
||||||
|
)
|
||||||
|
return system, user
|
||||||
|
|
||||||
|
|
||||||
|
def _digest_user_prompt(
|
||||||
|
*,
|
||||||
|
today,
|
||||||
|
quotes_by_group: dict,
|
||||||
|
headlines_by_bucket: dict,
|
||||||
|
reference_line: str,
|
||||||
|
) -> str:
|
||||||
|
"""Shared user-message body used by both digest prompts. Same data
|
||||||
|
shape as the hourly user prompt; reformatted for the digest context."""
|
||||||
|
today_str = today.strftime("%A %d %B %Y") if hasattr(today, "strftime") else str(today)
|
||||||
|
lines = [f"TODAY (UTC): {today_str}", "", f"REFERENCE: {reference_line}", ""]
|
||||||
|
|
||||||
|
if headlines_by_bucket:
|
||||||
|
lines.append("HEADLINES BY CATEGORY")
|
||||||
|
for cat, items in headlines_by_bucket.items():
|
||||||
|
lines.append(f" [{cat}]")
|
||||||
|
for h in items[:30]:
|
||||||
|
when = h.get("when", "")
|
||||||
|
src = h.get("source", "")
|
||||||
|
title = h.get("title", "")
|
||||||
|
lines.append(f" {when} · {src} · {title}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if quotes_by_group:
|
||||||
|
lines.append("LATEST QUOTES BY GROUP")
|
||||||
|
for grp, items in quotes_by_group.items():
|
||||||
|
lines.append(f" [{grp}]")
|
||||||
|
for q in items[:30]:
|
||||||
|
sym = q.get("symbol", "")
|
||||||
|
price = q.get("price", "")
|
||||||
|
lbl = q.get("label", "")
|
||||||
|
ccy = q.get("currency", "")
|
||||||
|
lines.append(f" {sym} ({lbl}) — {price} {ccy}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Strategic-log generator — DB-fed, OpenRouter-backed.
|
"""LLM transport layer — OpenRouter / DeepSeek API calls.
|
||||||
|
|
||||||
Ported from /home/gg/ownCloud/Family/Finances/Wealth/strategic_log.py. The
|
Handles provider selection, retry + fallback machinery, and the monthly
|
||||||
system prompt is preserved verbatim (the voice we converged on). The user
|
budget-cap helpers. Prompt engineering lives in ``app.services.llm_prompts``;
|
||||||
prompt is now built from DB rows, not from subprocess JSON dumps.
|
this module only cares about *how* to reach the model, not *what to ask*.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -18,420 +18,31 @@ from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
# Bump when the composed prompt changes meaningfully. Stored on every
|
|
||||||
# StrategicLog row so historical logs can be linked to the prompt that produced
|
|
||||||
# them.
|
|
||||||
#
|
|
||||||
# v6 (2026-05-17): TONE shrinks to NOVICE | INTERMEDIATE (PRO dropped). New
|
|
||||||
# educational stance baked into _CORE — explicit anti-TA, anti-gambling-mindset
|
|
||||||
# framing aimed at young investors entering the trading world. NOVICE retuned
|
|
||||||
# to be pedagogical (defining terms, anti-pattern teach-backs); INTERMEDIATE
|
|
||||||
# kept terse but with light-touch educational nudges. See tasks/todo.md.
|
|
||||||
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
|
|
||||||
# the model was hallucinating future times. The user prompt now carries the
|
|
||||||
# actual current UTC time so the model has accurate temporal context.
|
|
||||||
# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email.
|
|
||||||
PROMPT_VERSION = 9
|
|
||||||
|
|
||||||
|
|
||||||
# --- Core: invariant across tone/analysis settings ----------------------------
|
# Per-model USD rates: (input_per_million, output_per_million).
|
||||||
|
# OpenRouter returns `usage.cost` directly; DeepSeek's native API does not.
|
||||||
_CORE = """You are Cassandra, writing a single daily strategic markets log \
|
# Used as a fallback when the upstream omits the cost field.
|
||||||
for one specific investor. Synthesis, not exposition.
|
_MODEL_PRICING_USD_PER_MILLION: dict[str, tuple[float, float]] = {
|
||||||
|
"deepseek-v4-flash": (0.07, 0.28),
|
||||||
# Lens
|
"deepseek/deepseek-v4-flash": (0.07, 0.28),
|
||||||
- Geopolitics → markets is the primary causal chain. For each sector move, \
|
"deepseek-chat": (0.27, 1.10),
|
||||||
ask: geopolitical, cyclical, or idiosyncratic. Label it.
|
"deepseek-reasoner": (0.55, 2.19),
|
||||||
- Divergences and contradictions are where the information is. Hunt for them.
|
|
||||||
- Absence of expected moves is signal. If the thesis predicted a reaction \
|
|
||||||
that didn't happen, that's more interesting than the reactions that did.
|
|
||||||
- Compare live readings against any reference snapshots provided.
|
|
||||||
|
|
||||||
# Multi-source news
|
|
||||||
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
|
|
||||||
cover the same event, read the gap in framing — that's the data.
|
|
||||||
- News matters only insofar as it changes a market read. Color without \
|
|
||||||
implications is filler.
|
|
||||||
|
|
||||||
# Structure
|
|
||||||
- One-line date header containing ONLY the date (e.g. `2026-05-18`) and \
|
|
||||||
optional anchor framing on the same line (e.g. "Week 11 since Hormuz"). \
|
|
||||||
**Never include a time-of-day clause like "(Updated 21:30 UTC)"** — \
|
|
||||||
generation time is recorded as metadata elsewhere. Inventing a future or \
|
|
||||||
arbitrary time in the header confuses readers.
|
|
||||||
- Immediately after the date header — with **nothing** in between — write a \
|
|
||||||
TL;DR. Format it as:
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
One concise paragraph of 2-3 sentences, **≤60 words total**, naming the \
|
|
||||||
single most important read or divergence of the day with concrete numbers. \
|
|
||||||
This is what a reader who only has 10 seconds sees. Don't waste it on the \
|
|
||||||
weather or generic context.
|
|
||||||
|
|
||||||
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
|
|
||||||
numbers in every paragraph. No section over ~150 words.
|
|
||||||
- One paragraph synthesising the news flow into a market read.
|
|
||||||
- End with a watch list: 3-5 specific items to track in the next week, \
|
|
||||||
each one sentence.
|
|
||||||
|
|
||||||
# Time-horizon discipline
|
|
||||||
- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \
|
|
||||||
2% as background noise; mention them only when they break or confirm a \
|
|
||||||
multi-week trend or are extreme outliers.
|
|
||||||
- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \
|
|
||||||
multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \
|
|
||||||
omit the paragraph.
|
|
||||||
- The watch list is for "structural tripwires over the next 1-3 months", not \
|
|
||||||
"things to watch tomorrow". Each watch item should name a level/threshold \
|
|
||||||
whose breach would change the regime, not a calendar-date event.
|
|
||||||
|
|
||||||
# Rational vs irrational framing (MANDATORY in every paragraph)
|
|
||||||
The reader's primary goal is to disconnect rational decisions from market \
|
|
||||||
irrationality. This is the single most important lens of the log — it MUST \
|
|
||||||
appear in every sector or theme paragraph, not just where it feels natural. \
|
|
||||||
For each paragraph, before writing it, ask yourself the two questions and \
|
|
||||||
then make both answers visible in the prose:
|
|
||||||
- The RATIONAL drivers — what the underlying factors justify: earnings, \
|
|
||||||
real-economy data, monetary policy, structural geopolitical shifts, \
|
|
||||||
valuation vs fundamentals.
|
|
||||||
- The IRRATIONAL drivers — what the crowd is doing regardless of fundamentals: \
|
|
||||||
positioning, narrative momentum, sentiment extremes, concentration, \
|
|
||||||
flow-driven moves, options gamma, credit complacency.
|
|
||||||
Then state the GAP: is price moving with the rational read, ahead of it, \
|
|
||||||
or against it? If they agree, say so briefly and move on. If they diverge \
|
|
||||||
— price moving on irrational drivers while fundamentals say otherwise, or \
|
|
||||||
vice versa — name the divergence explicitly. Those gaps are where the next \
|
|
||||||
regime change starts and are the whole point of this log.
|
|
||||||
A paragraph that names only price action or only fundamentals, without \
|
|
||||||
both lenses, is incomplete and must be rewritten.
|
|
||||||
|
|
||||||
# Discipline
|
|
||||||
- No emojis, no marketing language, no "concerning" or "unprecedented" \
|
|
||||||
without a specific number behind it.
|
|
||||||
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
|
|
||||||
- Distinguish "the thesis predicted X and X happened" from "the thesis \
|
|
||||||
predicted X and X did not happen". Both are useful; conflating them is not.
|
|
||||||
- Don't repeat the same point in different words across paragraphs.
|
|
||||||
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
|
|
||||||
to report whether reality is confirming, modifying, or refuting the thesis.
|
|
||||||
|
|
||||||
# Stance (educational, anti-TA, anti-gambling)
|
|
||||||
The target reader is most likely young, new to investing, and at risk of \
|
|
||||||
treating markets like a horse race they need to "read" via chart patterns. \
|
|
||||||
Cassandra is the corrective.
|
|
||||||
- **No technical analysis.** Head-and-shoulders, RSI thresholds, Fibonacci \
|
|
||||||
levels, Elliott waves, "support/resistance" — these are descriptions of past \
|
|
||||||
crowd behaviour, not predictions. Don't use them; don't legitimise them. If \
|
|
||||||
you mention a price level, frame it as a positioning fact (e.g. "the level \
|
|
||||||
where the latest tranche of buyers entered"), not a signal.
|
|
||||||
- **No gambling framing.** Markets are not a coin flip and not a horse race. \
|
|
||||||
Never present a position as a single decisive moment, a "now or never", or a \
|
|
||||||
bet to be won. Every read should follow the shape: *regime → implication → \
|
|
||||||
what would change the regime*.
|
|
||||||
- **Macro causality, every time.** Price moves get explained through \
|
|
||||||
fundamentals, geopolitics, monetary policy, and structural shifts — not \
|
|
||||||
chart shapes. Even short paragraphs need the cause, not just the effect.
|
|
||||||
|
|
||||||
# System temperature (closing line, mandatory)
|
|
||||||
Close the log with a single sentence on a line of its own, formatted exactly:
|
|
||||||
|
|
||||||
System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label]
|
|
||||||
|
|
||||||
This is the line a reader who only sees the watch list scrolls down to. Make \
|
|
||||||
it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \
|
|
||||||
yields), not vibes.
|
|
||||||
|
|
||||||
# Update mode (when an earlier log from today is provided)
|
|
||||||
If the user message includes a section labelled "Earlier log from today \
|
|
||||||
(generated HH:MM UTC)", treat that as YOUR OWN earlier draft. You are \
|
|
||||||
UPDATING it for the current data, not starting from scratch.
|
|
||||||
- Don't restate context that hasn't changed. Anchor on what's moved SINCE \
|
|
||||||
that timestamp: confirmations, refutations, new emergent patterns.
|
|
||||||
- The TL;DR should lead with the move since the earlier read when there \
|
|
||||||
was a meaningful intra-day change ("Since this morning's read, …") — \
|
|
||||||
otherwise stay regime-level.
|
|
||||||
- The watch list should evolve: drop items that triggered or settled, add \
|
|
||||||
items that emerged. Keep items still load-bearing.
|
|
||||||
- Preserve any insights from the earlier draft that remain valid; sharpen \
|
|
||||||
or revise the ones that don't. Avoid contradicting yourself silently — if \
|
|
||||||
you change a stance, name it briefly ("Earlier I read X; with Y now, the \
|
|
||||||
read shifts to Z")."""
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tone: audience-shaping block --------------------------------------------
|
|
||||||
|
|
||||||
_TONE: dict[str, str] = {
|
|
||||||
"NOVICE": """# Audience: novice — likely a young investor new to markets
|
|
||||||
This reader probably arrived from social media, treats charts as predictions, \
|
|
||||||
and is one bad week away from quitting. Your job is to **educate them out of \
|
|
||||||
the gambling mindset** without ever being preachy. Calm, patient, slightly \
|
|
||||||
teacherly. Never condescending.
|
|
||||||
|
|
||||||
- **Define jargon the first time it appears.** A short clause in parentheses \
|
|
||||||
is fine: "yield curve (the chart of borrowing costs across different \
|
|
||||||
maturities)", "ERP (equity risk premium — the extra return investors demand \
|
|
||||||
for owning stocks instead of safe bonds)", "basis point (one hundredth of a \
|
|
||||||
percent — 25bp = 0.25%)".
|
|
||||||
- **Avoid ticker shorthand without context.** Use "Apple (AAPL)" on first \
|
|
||||||
mention, then "Apple" or the ticker after.
|
|
||||||
- **Everyday phrasing over jargon** where the meaning survives: "the price \
|
|
||||||
of US government debt fell, pushing yields up" rather than "the long end \
|
|
||||||
backed up"; "investors are paying more for the same earnings" rather than \
|
|
||||||
"multiple expansion".
|
|
||||||
- **One analogy per concept, used sparingly.** Use them to bridge to \
|
|
||||||
something concrete the reader already understands — not to entertain.
|
|
||||||
|
|
||||||
# Educational teach-backs (NOVICE-specific, when warranted)
|
|
||||||
When the day's data makes a common misconception concrete, drop in ONE \
|
|
||||||
teach-back of one to two sentences. Don't force it. Don't moralise. Examples \
|
|
||||||
of moments to do this:
|
|
||||||
|
|
||||||
- Anyone treating chart patterns as predictions: \
|
|
||||||
"Patterns like head-and-shoulders describe what crowds did, not what they \
|
|
||||||
will do — they're stories told after the fact, not edges."
|
|
||||||
- Anyone fixated on day-to-day moves: \
|
|
||||||
"A 1% one-day move in a stock is roughly what you'd expect by chance. The \
|
|
||||||
multi-week trend is where the information lives."
|
|
||||||
- Anyone treating one ticker as a coin flip: \
|
|
||||||
"A single name's monthly move is mostly noise. The regime — what bonds, the \
|
|
||||||
dollar, and credit are doing together — tells you whether ANY stock is \
|
|
||||||
likely to drift up or down."
|
|
||||||
- Anyone trying to "time the bottom" or "buy the dip": \
|
|
||||||
"Catching the bottom is a different game from owning the next cycle. The \
|
|
||||||
first needs you to be right within days; the second needs you to be roughly \
|
|
||||||
right within years."
|
|
||||||
|
|
||||||
Limit yourself to one teach-back per log. Skip them entirely if the day's \
|
|
||||||
data doesn't naturally invite one.
|
|
||||||
|
|
||||||
# Length
|
|
||||||
Target ~700 words. Slightly more than INTERMEDIATE because explanations \
|
|
||||||
need breathing room.""",
|
|
||||||
|
|
||||||
"INTERMEDIATE": """# Audience: intermediate — reads the news, learning to \
|
|
||||||
connect macro to markets
|
|
||||||
Assume the reader knows market basics (yield curves, breakevens, HY OAS, \
|
|
||||||
sector ETFs, the difference between cyclical and defensive, what a basis \
|
|
||||||
point is). Use common terms without defining them, but stay clear of deep \
|
|
||||||
institutional shorthand ("the belly", "duration trade", "carry pickup", \
|
|
||||||
"the RV book", "off-the-run").
|
|
||||||
|
|
||||||
Light-touch educational nudges are welcome when the day's data warrants — \
|
|
||||||
e.g. "with rates this volatile, technical levels in equities are mostly \
|
|
||||||
distraction" — but keep them to a passing clause, not a paragraph. Don't \
|
|
||||||
moralise.
|
|
||||||
|
|
||||||
# Length
|
|
||||||
Target ~600 words. Lean and clear, no padding.""",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Legacy values map to the closest current value. Logs a warning so we can
|
def _estimate_cost_usd(model: str, prompt_tokens, completion_tokens) -> float | None:
|
||||||
# notice if some caller's config didn't get updated.
|
"""Compute cost from token counts when the upstream didn't return one.
|
||||||
_TONE_ALIASES = {
|
|
||||||
"PRO": "INTERMEDIATE",
|
|
||||||
"PROFESSIONAL": "INTERMEDIATE",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Returns None if either token count is missing or the model isn't in
|
||||||
def _resolve_tone(tone: str) -> str:
|
the pricing table — caller falls back to whatever value the upstream
|
||||||
"""Map a caller-supplied tone string to one of {NOVICE, INTERMEDIATE}.
|
did (or didn't) return.
|
||||||
|
"""
|
||||||
Unknown tones fall back to INTERMEDIATE. The legacy PRO value is mapped
|
rates = _MODEL_PRICING_USD_PER_MILLION.get(model)
|
||||||
to INTERMEDIATE (audience pivot, see PROMPT_VERSION v6 notes)."""
|
if rates is None or prompt_tokens is None or completion_tokens is None:
|
||||||
upper = (tone or "").upper().strip()
|
return None
|
||||||
if upper in _TONE:
|
in_rate, out_rate = rates
|
||||||
return upper
|
return (prompt_tokens * in_rate + completion_tokens * out_rate) / 1_000_000.0
|
||||||
if upper in _TONE_ALIASES:
|
|
||||||
return _TONE_ALIASES[upper]
|
|
||||||
return "INTERMEDIATE"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Analysis: forward-vs-backward focus -------------------------------------
|
|
||||||
|
|
||||||
_ANALYSIS: dict[str, str] = {
|
|
||||||
"DRY": """# Analysis style: dry
|
|
||||||
Report what happened. Identify divergences and contradictions. Compare to \
|
|
||||||
references. Do not speculate on what comes next. Forward-looking statements \
|
|
||||||
are limited to "what would invalidate the read" — never "we expect X to \
|
|
||||||
happen". The watch list contains items to monitor, not predictions.""",
|
|
||||||
|
|
||||||
"SPECULATIVE": """# Analysis style: speculative
|
|
||||||
Report what happened, then explicitly explore forward scenarios. For each \
|
|
||||||
significant sector or theme, sketch a 1-4 week scenario set: the base case \
|
|
||||||
(what the data suggests), a contrarian case (what would invalidate it), and \
|
|
||||||
what tape signal would tip you from one to the other. Be explicit about \
|
|
||||||
uncertainty — say "the base case is" not "X will happen". The watch list is \
|
|
||||||
the trip-wires that decide between scenarios.""",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_system_prompt(tone: str, analysis: str) -> str:
|
|
||||||
"""Compose the system prompt from the chosen audience and analysis style."""
|
|
||||||
tone_block = _TONE[_resolve_tone(tone)]
|
|
||||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
|
||||||
return "\n\n".join([_CORE, tone_block, analysis_block])
|
|
||||||
|
|
||||||
|
|
||||||
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
|
|
||||||
# don't yet pass tone/analysis. New callers should call build_system_prompt().
|
|
||||||
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
|
|
||||||
|
|
||||||
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
|
|
||||||
You are NOT writing a daily log right now. The user is asking a specific
|
|
||||||
question via the chat sidebar.
|
|
||||||
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
|
|
||||||
- Typical response: 200-400 words. Longer only if the question genuinely
|
|
||||||
warrants it.
|
|
||||||
- Cite specific numbers and named headlines from the reference materials
|
|
||||||
below whenever relevant. If a number isn't in the context, don't invent it.
|
|
||||||
- If a question is outside the provided context (e.g. asking about a stock or
|
|
||||||
event not in the data), say so plainly rather than speculating from prior
|
|
||||||
knowledge.
|
|
||||||
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
|
|
||||||
- Keep the same audience and analysis discipline established above."""
|
|
||||||
|
|
||||||
|
|
||||||
def build_summary_system_prompt(tone: str, analysis: str) -> str:
|
|
||||||
"""A lean, focused system prompt for the per-indicator-group hourly
|
|
||||||
summary. INTERPRETATION not description — the reader has the table
|
|
||||||
next to this paragraph; they don't need numbers recited at them."""
|
|
||||||
tone_block = _TONE[_resolve_tone(tone)]
|
|
||||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
|
||||||
return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \
|
|
||||||
of ONE indicator group for a strategic markets dashboard.
|
|
||||||
|
|
||||||
# What this is for
|
|
||||||
The reader is looking at the table of numbers right next to your text. \
|
|
||||||
They can see the values. They CANNOT see the meaning. Your job is to \
|
|
||||||
**explain what the data means**, not to recite it. Each sentence should be \
|
|
||||||
a regime-level interpretation, a fundamental driver identification, or a \
|
|
||||||
cross-indicator implication — not a description of moves.
|
|
||||||
|
|
||||||
# Rational vs irrational lens (required at this length too)
|
|
||||||
Even at 2-3 sentences, contrast what the underlying factors justify \
|
|
||||||
(rational: fundamentals, policy, valuation) with what the crowd is doing \
|
|
||||||
(irrational: positioning, narrative, flows) whenever the two diverge. If \
|
|
||||||
they don't diverge, say so in one clause. Never just describe the move \
|
|
||||||
without placing it on this axis.
|
|
||||||
|
|
||||||
# Hard constraints
|
|
||||||
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
|
|
||||||
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
|
|
||||||
"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \
|
|
||||||
at", "Based on", "Summary:", "The data shows", "First", "To address". No \
|
|
||||||
meta-commentary at all.
|
|
||||||
- Cite at most 2-3 specific numbers and ONLY when they anchor an \
|
|
||||||
interpretation. Don't list moves; explain them.
|
|
||||||
- Multi-week / multi-month horizon. 1-day moves under 2% are noise — skip.
|
|
||||||
- No buy/sell language. No predictions. No watch list. No TL;DR. No date \
|
|
||||||
header. No "system temperature" line — that belongs to the full daily log.
|
|
||||||
- Output the read directly. Do NOT include phrases like "Example", "Good \
|
|
||||||
example", "Bad example", "Reference", or any meta-framing of your output.
|
|
||||||
|
|
||||||
{tone_block}
|
|
||||||
|
|
||||||
{analysis_block}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str:
|
|
||||||
parts = [
|
|
||||||
f"# Group: {group_name}",
|
|
||||||
"Indicators (latest reading + 1d/1m/1y/since-anchor change):",
|
|
||||||
"```json",
|
|
||||||
json.dumps(quotes, indent=2, default=str)[:12000],
|
|
||||||
"```",
|
|
||||||
"\nWrite the 2-3 sentence read for this group now.",
|
|
||||||
]
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str:
|
|
||||||
"""System prompt for the cross-group aggregate read shown on the dashboard.
|
|
||||||
Wider lens than a per-group summary — synthesise across all groups."""
|
|
||||||
tone_block = _TONE[_resolve_tone(tone)]
|
|
||||||
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
|
||||||
return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \
|
|
||||||
words, 2-4 sentences) for the dashboard header. The reader is glancing — \
|
|
||||||
give them the meaning of the whole tape, not a recap.
|
|
||||||
|
|
||||||
# What this is for
|
|
||||||
The reader can see every indicator on the dashboard below this paragraph. \
|
|
||||||
Your job is NOT to summarise the moves. It is to explain what the moves, \
|
|
||||||
**taken together as a system**, mean: which regime is being signalled, \
|
|
||||||
which divergences are load-bearing, what fundamental story the cross-asset \
|
|
||||||
behaviour tells.
|
|
||||||
|
|
||||||
# Rational vs irrational lens (required at this length too)
|
|
||||||
The cross-asset tape's value is in the gap between what the underlying \
|
|
||||||
factors justify (rational: fundamentals, policy, valuation) and what the \
|
|
||||||
crowd is actually doing (irrational: positioning, narrative momentum, \
|
|
||||||
flows). At least one of the 2-4 sentences must name this gap or, if the \
|
|
||||||
two cohere, explicitly say so.
|
|
||||||
|
|
||||||
# Hard constraints
|
|
||||||
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
|
|
||||||
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
|
|
||||||
"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \
|
|
||||||
"The data shows", "Across the board". No meta-commentary.
|
|
||||||
- Identify the single most important **cross-asset implication**: e.g. \
|
|
||||||
"rates and credit disagree", "equities outrun fundamentals", "geopolitical \
|
|
||||||
risk premium is in commodities but not vol". Cite no more than 3 specific \
|
|
||||||
numbers, and only as anchors for the interpretation.
|
|
||||||
- Multi-week / multi-month horizon. 1-day moves under 2% are noise.
|
|
||||||
- No buy/sell language. No predictions of specific levels.
|
|
||||||
- Output the read directly. Do NOT include phrases like "Example", "Good \
|
|
||||||
example", "Bad example", "Reference", or any meta-framing of your output.
|
|
||||||
|
|
||||||
{tone_block}
|
|
||||||
|
|
||||||
{analysis_block}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str:
|
|
||||||
parts = [
|
|
||||||
"# All indicator groups (latest readings + change windows)",
|
|
||||||
"```json",
|
|
||||||
json.dumps(quotes_by_group, indent=2, default=str)[:20000],
|
|
||||||
"```",
|
|
||||||
"\nWrite the cross-asset aggregate read now.",
|
|
||||||
]
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def build_chat_system_prompt(
|
|
||||||
tone: str,
|
|
||||||
analysis: str,
|
|
||||||
*,
|
|
||||||
log_content: str | None,
|
|
||||||
log_generated_at: datetime | None,
|
|
||||||
quotes_by_group: dict[str, list[dict]],
|
|
||||||
headlines: list[dict],
|
|
||||||
reference_line: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Composed system prompt for the /log chat sidebar. Carries the user's
|
|
||||||
chosen tone + analysis style and inlines the latest log + market data +
|
|
||||||
headlines as reference material the model can cite from."""
|
|
||||||
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
|
|
||||||
if reference_line:
|
|
||||||
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
|
|
||||||
if log_content:
|
|
||||||
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
|
|
||||||
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
|
|
||||||
parts.append("# Live market data")
|
|
||||||
parts.append(
|
|
||||||
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
|
|
||||||
)
|
|
||||||
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
|
|
||||||
for h in headlines[:50]:
|
|
||||||
parts.append(f"- [{h['source']}] {h['title']}")
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -443,172 +54,6 @@ class LogResult:
|
||||||
cost_usd: float | None
|
cost_usd: float | None
|
||||||
|
|
||||||
|
|
||||||
def build_user_prompt(
|
|
||||||
*,
|
|
||||||
today: datetime,
|
|
||||||
anchor: str | None,
|
|
||||||
quotes_by_group: dict[str, list[dict]],
|
|
||||||
headlines_by_bucket: dict[str, list[dict]],
|
|
||||||
reference_line: str | None = None,
|
|
||||||
previous_log: object | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Assemble the user message from already-fetched-and-persisted data.
|
|
||||||
If `previous_log` is a StrategicLog from earlier today, it's included
|
|
||||||
as 'Update mode' context — the model will revise rather than restart."""
|
|
||||||
parts = [
|
|
||||||
f"# Strategic log request — {today.strftime('%Y-%m-%d')}",
|
|
||||||
# Explicit current time so the model doesn't hallucinate one. The
|
|
||||||
# date header it writes MUST stay date-only (per system prompt).
|
|
||||||
f"Current time: {today.strftime('%Y-%m-%d %H:%M UTC')}",
|
|
||||||
]
|
|
||||||
if anchor:
|
|
||||||
parts.append(f"Anchor reference date: {anchor}")
|
|
||||||
if reference_line:
|
|
||||||
parts.append(
|
|
||||||
"\n## Reference snapshot (when the macro thesis was authored)"
|
|
||||||
f"\n{reference_line}\nCompare live readings against it."
|
|
||||||
)
|
|
||||||
|
|
||||||
if previous_log is not None:
|
|
||||||
gen = getattr(previous_log, "generated_at", None)
|
|
||||||
ts = gen.strftime("%H:%M UTC") if gen else "earlier today"
|
|
||||||
parts.append(
|
|
||||||
f"\n## Earlier log from today (generated {ts})\n"
|
|
||||||
"Treat this as YOUR OWN earlier draft for today. Update it for\n"
|
|
||||||
"the current data — don't restate unchanged context. See the\n"
|
|
||||||
"'Update mode' section of the system prompt for how to handle it.\n"
|
|
||||||
"```markdown\n"
|
|
||||||
f"{previous_log.content}\n"
|
|
||||||
"```"
|
|
||||||
)
|
|
||||||
|
|
||||||
parts.append("\n## Live market data (per group)")
|
|
||||||
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
|
|
||||||
parts.append("\n## News flow (last 24h, filtered by bucket)")
|
|
||||||
for label, items in headlines_by_bucket.items():
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
parts.append(f"\n### {label.upper()}")
|
|
||||||
for h in items[:30]:
|
|
||||||
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
|
|
||||||
|
|
||||||
task_line = (
|
|
||||||
"\n## Task\nWrite the daily strategic log in ~800 words, following "
|
|
||||||
"the discipline in the system prompt. No preamble; begin directly "
|
|
||||||
"with the date header."
|
|
||||||
)
|
|
||||||
if previous_log is not None:
|
|
||||||
task_line = (
|
|
||||||
"\n## Task\nUpdate the earlier log above for the current data. "
|
|
||||||
"Keep the same structure (date header, TL;DR, sections, watch "
|
|
||||||
"list, system temperature) but anchor on what has CHANGED since "
|
|
||||||
"the earlier draft's timestamp. ~800 words. No preamble."
|
|
||||||
)
|
|
||||||
parts.append(task_line)
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _digest_tone_clause(tone: str) -> str:
|
|
||||||
if tone.upper() == "NOVICE":
|
|
||||||
return "Use plain English. Define any jargon on first use."
|
|
||||||
return "Write for a reader who already speaks markets fluently."
|
|
||||||
|
|
||||||
|
|
||||||
def build_daily_digest_prompt(
|
|
||||||
*,
|
|
||||||
tone: str,
|
|
||||||
today,
|
|
||||||
quotes_by_group: dict,
|
|
||||||
headlines_by_bucket: dict,
|
|
||||||
reference_line: str,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""System + user prompt for the once-a-day editorial digest.
|
|
||||||
|
|
||||||
Different from the hourly log: the daily digest reflects on the past
|
|
||||||
24h and looks forward to the upcoming session. Longer, less
|
|
||||||
'live-blogging,' more contextual. Target ~600 words."""
|
|
||||||
system = (
|
|
||||||
"You write the daily editorial digest for Read the Markets. "
|
|
||||||
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
|
|
||||||
"Cover: (1) what mattered yesterday, (2) what to watch in today's "
|
|
||||||
"EU and US sessions, (3) one cross-asset thread connecting them. "
|
|
||||||
"No predictions of price level, no buy/sell language. Target ~600 "
|
|
||||||
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
|
|
||||||
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
|
|
||||||
)
|
|
||||||
user = _digest_user_prompt(
|
|
||||||
today=today, quotes_by_group=quotes_by_group,
|
|
||||||
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
|
|
||||||
)
|
|
||||||
return system, user
|
|
||||||
|
|
||||||
|
|
||||||
def build_weekly_digest_prompt(
|
|
||||||
*,
|
|
||||||
tone: str,
|
|
||||||
today,
|
|
||||||
quotes_by_group: dict,
|
|
||||||
headlines_by_bucket: dict,
|
|
||||||
reference_line: str,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""System + user prompt for the Sunday weekly recap + look-ahead.
|
|
||||||
|
|
||||||
Sent to ALL opt-in users (free and paid). Target ~900 words."""
|
|
||||||
system = (
|
|
||||||
"You write the Sunday weekly digest for Read the Markets. "
|
|
||||||
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
|
|
||||||
"Cover: (1) the week behind — what moved and why, "
|
|
||||||
"(2) the week ahead — releases, earnings, central-bank meetings, "
|
|
||||||
"(3) the cross-asset story to keep in mind. "
|
|
||||||
"No predictions of price level, no buy/sell language. Target ~900 "
|
|
||||||
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
|
|
||||||
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
|
|
||||||
)
|
|
||||||
user = _digest_user_prompt(
|
|
||||||
today=today, quotes_by_group=quotes_by_group,
|
|
||||||
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
|
|
||||||
)
|
|
||||||
return system, user
|
|
||||||
|
|
||||||
|
|
||||||
def _digest_user_prompt(
|
|
||||||
*,
|
|
||||||
today,
|
|
||||||
quotes_by_group: dict,
|
|
||||||
headlines_by_bucket: dict,
|
|
||||||
reference_line: str,
|
|
||||||
) -> str:
|
|
||||||
"""Shared user-message body used by both digest prompts. Same data
|
|
||||||
shape as the hourly user prompt; reformatted for the digest context."""
|
|
||||||
today_str = today.strftime("%A %d %B %Y") if hasattr(today, "strftime") else str(today)
|
|
||||||
lines = [f"TODAY (UTC): {today_str}", "", f"REFERENCE: {reference_line}", ""]
|
|
||||||
|
|
||||||
if headlines_by_bucket:
|
|
||||||
lines.append("HEADLINES BY CATEGORY")
|
|
||||||
for cat, items in headlines_by_bucket.items():
|
|
||||||
lines.append(f" [{cat}]")
|
|
||||||
for h in items[:30]:
|
|
||||||
when = h.get("when", "")
|
|
||||||
src = h.get("source", "")
|
|
||||||
title = h.get("title", "")
|
|
||||||
lines.append(f" {when} · {src} · {title}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if quotes_by_group:
|
|
||||||
lines.append("LATEST QUOTES BY GROUP")
|
|
||||||
for grp, items in quotes_by_group.items():
|
|
||||||
lines.append(f" [{grp}]")
|
|
||||||
for q in items[:30]:
|
|
||||||
sym = q.get("symbol", "")
|
|
||||||
price = q.get("price", "")
|
|
||||||
lbl = q.get("label", "")
|
|
||||||
ccy = q.get("currency", "")
|
|
||||||
lines.append(f" {sym} ({lbl}) — {price} {ccy}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_chain() -> list[str]:
|
def _provider_chain() -> list[str]:
|
||||||
"""Ordered list of providers to try: primary, then fallback (unless
|
"""Ordered list of providers to try: primary, then fallback (unless
|
||||||
the fallback is unset, the same as primary, or has no API key)."""
|
the fallback is unset, the same as primary, or has no API key)."""
|
||||||
|
|
@ -691,10 +136,15 @@ async def _call_provider(
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
model: str | None,
|
model: str | None,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
|
response_format: dict | None = None,
|
||||||
) -> LogResult:
|
) -> LogResult:
|
||||||
"""One provider call with tenacity retries on transport/HTTP errors.
|
"""One provider call with tenacity retries on transport/HTTP errors.
|
||||||
Lives inside the retry decorator so retries happen within a provider,
|
Lives inside the retry decorator so retries happen within a provider,
|
||||||
not across the fallback chain."""
|
not across the fallback chain.
|
||||||
|
|
||||||
|
`response_format` is forwarded to the provider verbatim — DeepSeek and
|
||||||
|
OpenRouter both accept the OpenAI-shaped {"type": "json_object"} for
|
||||||
|
JSON-mode generation. None means free-form text."""
|
||||||
url, api_key, default_model, extra_headers = _endpoint_for(provider)
|
url, api_key, default_model, extra_headers = _endpoint_for(provider)
|
||||||
used_model = model or default_model
|
used_model = model or default_model
|
||||||
headers = {
|
headers = {
|
||||||
|
|
@ -702,18 +152,22 @@ async def _call_provider(
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**extra_headers,
|
**extra_headers,
|
||||||
}
|
}
|
||||||
r = await client.post(
|
body: dict = {"model": used_model, "messages": messages, "max_tokens": max_tokens}
|
||||||
url,
|
if response_format is not None:
|
||||||
headers=headers,
|
body["response_format"] = response_format
|
||||||
json={"model": used_model, "messages": messages, "max_tokens": max_tokens},
|
r = await client.post(url, headers=headers, json=body, timeout=180)
|
||||||
timeout=180,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
msg = data["choices"][0]["message"]
|
msg = data["choices"][0]["message"]
|
||||||
# Some providers return null content + populated `reasoning` for thinking
|
# The `content` field is the model's user-facing answer. The optional
|
||||||
# models, or null content when finish_reason=length cut off the response.
|
# `reasoning` field is the model's internal chain-of-thought — never
|
||||||
content = msg.get("content") or msg.get("reasoning")
|
# safe to publish; it contains raw scratchpad ("Let's see…",
|
||||||
|
# mid-sentence question marks, planning notes). If `content` is empty
|
||||||
|
# (provider issue, finish_reason=length cutoff, or the model spent
|
||||||
|
# its budget on thinking), treat that as a generation failure and
|
||||||
|
# raise so the caller can retry or skip the row. Do NOT fall back to
|
||||||
|
# reasoning — see the 2026-05-29 valuation-read leak.
|
||||||
|
content = msg.get("content")
|
||||||
if not content:
|
if not content:
|
||||||
finish = data["choices"][0].get("finish_reason")
|
finish = data["choices"][0].get("finish_reason")
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -721,13 +175,21 @@ async def _call_provider(
|
||||||
f"provider={provider}, model={used_model}, max_tokens={max_tokens})"
|
f"provider={provider}, model={used_model}, max_tokens={max_tokens})"
|
||||||
)
|
)
|
||||||
usage = data.get("usage") or {}
|
usage = data.get("usage") or {}
|
||||||
|
prompt_tokens = usage.get("prompt_tokens")
|
||||||
|
completion_tokens = usage.get("completion_tokens")
|
||||||
|
# OpenRouter populates `usage.cost`; DeepSeek's native API doesn't —
|
||||||
|
# estimate from tokens × per-model rates so the cost ledger stays
|
||||||
|
# populated regardless of which provider answered.
|
||||||
|
cost_usd = usage.get("cost") or usage.get("total_cost")
|
||||||
|
if cost_usd is None:
|
||||||
|
cost_usd = _estimate_cost_usd(used_model, prompt_tokens, completion_tokens)
|
||||||
return LogResult(
|
return LogResult(
|
||||||
content=content,
|
content=content,
|
||||||
# Record provider+model so admin can see which path produced this row.
|
# Record provider+model so admin can see which path produced this row.
|
||||||
model=f"{provider}/{used_model}",
|
model=f"{provider}/{used_model}",
|
||||||
prompt_tokens=usage.get("prompt_tokens"),
|
prompt_tokens=prompt_tokens,
|
||||||
completion_tokens=usage.get("completion_tokens"),
|
completion_tokens=completion_tokens,
|
||||||
cost_usd=usage.get("cost") or usage.get("total_cost"),
|
cost_usd=cost_usd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -736,6 +198,8 @@ async def call_llm(
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4000,
|
max_tokens: int = 4000,
|
||||||
|
response_format: dict | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
) -> LogResult:
|
) -> LogResult:
|
||||||
"""Provider-aware chat completion with fallback. Tries primary
|
"""Provider-aware chat completion with fallback. Tries primary
|
||||||
(LLM_PROVIDER) first; if it raises after retries, falls through to
|
(LLM_PROVIDER) first; if it raises after retries, falls through to
|
||||||
|
|
@ -744,7 +208,19 @@ async def call_llm(
|
||||||
The returned LogResult.model is prefixed with the provider that
|
The returned LogResult.model is prefixed with the provider that
|
||||||
actually answered (e.g. ``deepseek/deepseek-v4-flash`` or
|
actually answered (e.g. ``deepseek/deepseek-v4-flash`` or
|
||||||
``openrouter/deepseek/deepseek-v4-flash``) — useful admin metadata
|
``openrouter/deepseek/deepseek-v4-flash``) — useful admin metadata
|
||||||
even though we hide it from the user-facing UI."""
|
even though we hide it from the user-facing UI.
|
||||||
|
|
||||||
|
Pass response_format={"type": "json_object"} to force JSON-mode
|
||||||
|
output (the model still needs to be instructed in the system prompt
|
||||||
|
to emit valid JSON — this flag enforces, not asks).
|
||||||
|
|
||||||
|
Pass `provider` (e.g. "openrouter") to skip the configured chain
|
||||||
|
and pin the call to a specific provider. Used by the reviewer agent
|
||||||
|
to force routing through OpenRouter so it can address a non-DeepSeek
|
||||||
|
model that doesn't pre-think before emitting JSON."""
|
||||||
|
if provider is not None:
|
||||||
|
chain = [provider]
|
||||||
|
else:
|
||||||
chain = _provider_chain()
|
chain = _provider_chain()
|
||||||
if not chain:
|
if not chain:
|
||||||
raise RuntimeError("No LLM provider configured (no API key set)")
|
raise RuntimeError("No LLM provider configured (no API key set)")
|
||||||
|
|
@ -754,6 +230,7 @@ async def call_llm(
|
||||||
try:
|
try:
|
||||||
result = await _call_provider(
|
result = await _call_provider(
|
||||||
client, provider, messages, model, max_tokens,
|
client, provider, messages, model, max_tokens,
|
||||||
|
response_format=response_format,
|
||||||
)
|
)
|
||||||
if i > 0:
|
if i > 0:
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
|
|
@ -775,10 +252,6 @@ async def call_llm(
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
# Back-compat alias for any straggling import sites.
|
|
||||||
call_openrouter = call_llm
|
|
||||||
|
|
||||||
|
|
||||||
def month_window() -> tuple[datetime, datetime]:
|
def month_window() -> tuple[datetime, datetime]:
|
||||||
"""[start, now] in UTC for the current calendar month."""
|
"""[start, now] in UTC for the current calendar month."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
|
||||||
162
app/services/output_review.py
Normal file
162
app/services/output_review.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Second-pass reviewer agent for AI-generated reads.
|
||||||
|
|
||||||
|
The per-group and aggregate indicator summaries are generated in JSON
|
||||||
|
mode and the publishable text comes out of a single "read" field, but a
|
||||||
|
misbehaving model can still slip chain-of-thought INSIDE the field
|
||||||
|
("Let's see…", "X? Actually Y?", multi-question parentheticals). This
|
||||||
|
module makes a small second LLM call that judges the candidate read as
|
||||||
|
clean / unclean. Cost is ~$0.0001 per check; latency ~1-2 s in the
|
||||||
|
hourly job. No user-facing latency.
|
||||||
|
|
||||||
|
The reviewer is deliberately a tiny, JSON-shaped classifier — same
|
||||||
|
JSON-mode mechanism as the generator, so the verdict can't be lost in
|
||||||
|
prose. If parsing fails or the call errors, the row is rejected
|
||||||
|
(fail-safe: the previously cached good summary stays visible).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.logging import get_logger
|
||||||
|
from app.services.openrouter import call_llm
|
||||||
|
|
||||||
|
log = get_logger("output_review")
|
||||||
|
|
||||||
|
|
||||||
|
# The reviewer runs through OpenRouter against a small, non-thinking
|
||||||
|
# model. DeepSeek-V4-flash (our generator default) emits internal
|
||||||
|
# chain-of-thought before its JSON output even when the prompt forbids
|
||||||
|
# it, which truncates the JSON at any reasonable max_tokens cap and
|
||||||
|
# breaks the parser. Anthropic's Haiku family answers structured-output
|
||||||
|
# tasks tersely and deterministically — no chain-of-thought tax. Cost
|
||||||
|
# is ~$0.0001-$0.0003 per review depending on candidate length.
|
||||||
|
DEFAULT_REVIEWER_MODEL = "anthropic/claude-haiku-4.5"
|
||||||
|
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """\
|
||||||
|
You are a strict editor for a financial-markets dashboard. The author
|
||||||
|
was asked to produce editorial commentary on public market data for
|
||||||
|
human readers. You receive the proposed text — it may be a one-line
|
||||||
|
read, a multi-paragraph daily log, a portfolio analysis, a chat
|
||||||
|
reply, or an email digest — and decide if it is publishable as-is.
|
||||||
|
|
||||||
|
Mark CLEAN only if the text reads like finished editorial commentary
|
||||||
|
a reader could see on a public dashboard without confusion.
|
||||||
|
|
||||||
|
Mark UNCLEAN if the text contains ANY of:
|
||||||
|
- Chain-of-thought / scratchpad markers — the author thinking on the
|
||||||
|
page rather than presenting finished commentary. Phrases like
|
||||||
|
"Let me", "Let's see", "we need to", "actually" (correcting itself),
|
||||||
|
"wait", "hmm", "or rather", "I should". Rhetorical questions used
|
||||||
|
as structure are fine; questions that the author then answers in
|
||||||
|
front of the reader (self-questioning) are not.
|
||||||
|
- Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?",
|
||||||
|
"is it X or Y?", any place where the author appears to be working
|
||||||
|
out the answer in front of the reader.
|
||||||
|
- Meta-commentary about the task, output format, word limits, or
|
||||||
|
instructions — e.g. "as required by the constraints", "the prompt
|
||||||
|
asks", "let me address each".
|
||||||
|
- Partial / truncated content. Starts mid-word, mid-number, mid-clause,
|
||||||
|
ends mid-thought.
|
||||||
|
- Visible internal numbers without clear meaning ("change 1y +5.9%?"),
|
||||||
|
raw column names ("as_of 2026-01-01"), or any debug-like fragments.
|
||||||
|
- FINANCIAL ADVICE or any phrasing that recommends an action the
|
||||||
|
reader should take. This service is editorial commentary on public
|
||||||
|
data, not investment advice; the operator is not licensed to give
|
||||||
|
it. Reject any of:
|
||||||
|
* Buy/sell/hold/accumulate/trim/exit/enter/rotate language.
|
||||||
|
* Allocation guidance ("overweight", "underweight",
|
||||||
|
"X% in bonds", "increase exposure to").
|
||||||
|
* Price targets or specific level predictions ("will reach $X",
|
||||||
|
"target Y", "expect Z by year-end").
|
||||||
|
* Personalised framing ("you should", "investors should",
|
||||||
|
"consider buying", "we recommend").
|
||||||
|
DESCRIPTIVE / INTERPRETIVE language about market state is fine —
|
||||||
|
"valuations are stretched", "real yields are restrictive", "rates
|
||||||
|
and credit disagree". The test: does the text describe a STATE, or
|
||||||
|
does it suggest an ACTION? States are fine; actions are not.
|
||||||
|
- Anything else other than the finished, publishable commentary.
|
||||||
|
|
||||||
|
Return ONLY a JSON object with this exact shape:
|
||||||
|
{"clean": true | false, "reason": "<≤20 words, plain text>"}
|
||||||
|
No preamble, no markdown fences, no other fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Verdict:
|
||||||
|
clean: bool
|
||||||
|
reason: str
|
||||||
|
cost_usd: float | None # cost of the review call itself, for the ledger
|
||||||
|
|
||||||
|
|
||||||
|
async def review_read(client: httpx.AsyncClient, candidate: str) -> Verdict:
|
||||||
|
"""Ask the LLM whether `candidate` is a publishable read.
|
||||||
|
|
||||||
|
Returns Verdict(clean, reason, cost). Any error — provider failure,
|
||||||
|
JSON parse failure, missing field, wrong type — yields a CONSERVATIVE
|
||||||
|
verdict (clean=False) so the caller drops the candidate. The
|
||||||
|
previously cached good summary stays visible on the dashboard."""
|
||||||
|
if not candidate or not candidate.strip():
|
||||||
|
return Verdict(clean=False, reason="empty candidate", cost_usd=0.0)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||||
|
# Sent as a fenced user turn so the model can't confuse the
|
||||||
|
# candidate with instructions, even if the candidate happens to
|
||||||
|
# contain prompt-like prose.
|
||||||
|
{"role": "user", "content": f"Candidate read:\n```\n{candidate}\n```"},
|
||||||
|
]
|
||||||
|
settings = get_settings()
|
||||||
|
reviewer_model = getattr(settings, "REVIEWER_MODEL", None) or DEFAULT_REVIEWER_MODEL
|
||||||
|
try:
|
||||||
|
result = await call_llm(
|
||||||
|
client, messages,
|
||||||
|
# Pin to OpenRouter so a non-DeepSeek model like Haiku is
|
||||||
|
# actually reachable; the default provider chain would try
|
||||||
|
# DeepSeek native first and 404 on the Anthropic model name.
|
||||||
|
provider="openrouter",
|
||||||
|
model=reviewer_model,
|
||||||
|
# 300 tokens is well above the ~30-token JSON verdict.
|
||||||
|
# Haiku doesn't pad with hidden reasoning the way DeepSeek
|
||||||
|
# does, so we don't need the 800-token headroom required to
|
||||||
|
# absorb the generator's chain-of-thought.
|
||||||
|
max_tokens=300,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("review.call_failed", error=str(e)[:200])
|
||||||
|
return Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}",
|
||||||
|
cost_usd=None)
|
||||||
|
|
||||||
|
# Haiku (and several other models) occasionally wrap their JSON
|
||||||
|
# output in a markdown code fence even with response_format set —
|
||||||
|
# ```json\n{...}\n``` — so strip a single leading/trailing fence
|
||||||
|
# before parsing. We do this defensively for any model; it's a
|
||||||
|
# no-op for callers that already emit bare JSON.
|
||||||
|
raw = result.content.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
first_nl = raw.find("\n")
|
||||||
|
if first_nl != -1:
|
||||||
|
raw = raw[first_nl + 1:]
|
||||||
|
if raw.rstrip().endswith("```"):
|
||||||
|
raw = raw.rstrip()[:-3].rstrip()
|
||||||
|
raw = raw.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log.warning("review.parse_failed", preview=result.content[:200])
|
||||||
|
return Verdict(clean=False, reason="reviewer returned non-JSON",
|
||||||
|
cost_usd=result.cost_usd)
|
||||||
|
|
||||||
|
clean = parsed.get("clean")
|
||||||
|
reason = parsed.get("reason") or ""
|
||||||
|
if not isinstance(clean, bool):
|
||||||
|
return Verdict(clean=False, reason="reviewer omitted bool 'clean'",
|
||||||
|
cost_usd=result.cost_usd)
|
||||||
|
return Verdict(clean=clean, reason=str(reason)[:200], cost_usd=result.cost_usd)
|
||||||
|
|
@ -31,10 +31,12 @@ 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.llm_prompts import build_system_prompt
|
||||||
|
from app.services.output_review import review_read
|
||||||
from app.services.openrouter import (
|
from app.services.openrouter import (
|
||||||
LogResult,
|
LogResult,
|
||||||
active_model,
|
active_model,
|
||||||
build_system_prompt,
|
|
||||||
call_llm,
|
call_llm,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,6 +76,7 @@ class AnalysisRequest:
|
||||||
anchor: str | None = None
|
anchor: str | None = None
|
||||||
tone: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO
|
tone: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO
|
||||||
analysis: str = "SPECULATIVE" # DRY | SPECULATIVE
|
analysis: str = "SPECULATIVE" # DRY | SPECULATIVE
|
||||||
|
lang: str = "en"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -163,10 +166,13 @@ def parse_request(payload: dict) -> AnalysisRequest:
|
||||||
anchor = _sanitise_text(payload.get("anchor") or "", 32) or None
|
anchor = _sanitise_text(payload.get("anchor") or "", 32) or None
|
||||||
tone = _sanitise_text(payload.get("tone", "INTERMEDIATE"), 16) or "INTERMEDIATE"
|
tone = _sanitise_text(payload.get("tone", "INTERMEDIATE"), 16) or "INTERMEDIATE"
|
||||||
analysis = _sanitise_text(payload.get("analysis", "SPECULATIVE"), 16) or "SPECULATIVE"
|
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(
|
return AnalysisRequest(
|
||||||
positions=positions, prices=prices, base_currency=base_currency,
|
positions=positions, prices=prices, base_currency=base_currency,
|
||||||
anchor=anchor, tone=tone, analysis=analysis,
|
anchor=anchor, tone=tone, analysis=analysis, lang=lang,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -276,7 +282,7 @@ 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
|
system = 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')}",
|
||||||
|
|
@ -317,6 +323,8 @@ async def analyse(
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
system, user = build_prompt(req)
|
system, user = build_prompt(req)
|
||||||
|
|
||||||
|
review_cost = 0.0
|
||||||
|
review_reason: str | None = None
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
llm: LogResult = await call_llm(
|
llm: LogResult = await call_llm(
|
||||||
|
|
@ -335,15 +343,31 @@ async def analyse(
|
||||||
llm = None
|
llm = None
|
||||||
log.error("portfolio_analysis.failed", error=error_msg)
|
log.error("portfolio_analysis.failed", error=error_msg)
|
||||||
|
|
||||||
|
# Reviewer gate. This is the highest-risk surface — the model is
|
||||||
|
# commenting on a real user's holdings, so any drift into
|
||||||
|
# buy/sell or allocation language is a regulatory hazard. Drop
|
||||||
|
# the response on a reject and surface a retry-able error to the
|
||||||
|
# caller; no analysis is ever persisted server-side anyway.
|
||||||
|
if llm is not None:
|
||||||
|
verdict = await review_read(client, llm.content)
|
||||||
|
review_cost = verdict.cost_usd or 0.0
|
||||||
|
if not verdict.clean:
|
||||||
|
status = "leaked"
|
||||||
|
error_msg = f"reviewer rejected: {verdict.reason}"
|
||||||
|
review_reason = verdict.reason
|
||||||
|
log.warning("portfolio_analysis.reviewer_rejected",
|
||||||
|
reason=verdict.reason, preview=llm.content[:120])
|
||||||
|
|
||||||
|
full_cost = ((llm.cost_usd or 0.0) + review_cost) if llm else None
|
||||||
# Ledger row — NO portfolio data, just metadata. Same row whether the
|
# Ledger row — NO portfolio data, just metadata. Same row whether the
|
||||||
# call succeeded or failed, so cost-cap and rate-limit logic can
|
# call succeeded, failed, or was rejected by the reviewer, so
|
||||||
# observe the attempt.
|
# cost-cap and rate-limit logic can observe the attempt.
|
||||||
session.add(AICall(
|
session.add(AICall(
|
||||||
called_at=utcnow(),
|
called_at=utcnow(),
|
||||||
model=llm.model if llm else active_model(),
|
model=llm.model if llm else active_model(),
|
||||||
prompt_tokens=llm.prompt_tokens if llm else None,
|
prompt_tokens=llm.prompt_tokens if llm else None,
|
||||||
completion_tokens=llm.completion_tokens if llm else None,
|
completion_tokens=llm.completion_tokens if llm else None,
|
||||||
cost_usd=llm.cost_usd if llm else None,
|
cost_usd=full_cost,
|
||||||
status=status,
|
status=status,
|
||||||
error=error_msg,
|
error=error_msg,
|
||||||
))
|
))
|
||||||
|
|
@ -351,19 +375,26 @@ async def analyse(
|
||||||
|
|
||||||
if llm is None:
|
if llm is None:
|
||||||
raise RuntimeError(error_msg or "portfolio analysis failed")
|
raise RuntimeError(error_msg or "portfolio analysis failed")
|
||||||
|
if review_reason is not None:
|
||||||
|
# Reviewer rejected the candidate. Treat as a generation failure
|
||||||
|
# at the API layer so the user sees a retry-able error rather
|
||||||
|
# than potentially non-compliant advice.
|
||||||
|
raise RuntimeError(
|
||||||
|
"AI analysis couldn't be generated cleanly — please try again."
|
||||||
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"portfolio_analysis.ok",
|
"portfolio_analysis.ok",
|
||||||
n_positions=len(req.positions),
|
n_positions=len(req.positions),
|
||||||
prompt_tokens=llm.prompt_tokens,
|
prompt_tokens=llm.prompt_tokens,
|
||||||
completion_tokens=llm.completion_tokens,
|
completion_tokens=llm.completion_tokens,
|
||||||
cost_usd=llm.cost_usd,
|
cost_usd=full_cost,
|
||||||
)
|
)
|
||||||
return AnalysisResult(
|
return AnalysisResult(
|
||||||
content=llm.content,
|
content=llm.content,
|
||||||
model=llm.model,
|
model=llm.model,
|
||||||
prompt_tokens=llm.prompt_tokens,
|
prompt_tokens=llm.prompt_tokens,
|
||||||
completion_tokens=llm.completion_tokens,
|
completion_tokens=llm.completion_tokens,
|
||||||
cost_usd=llm.cost_usd,
|
cost_usd=full_cost,
|
||||||
generated_at=datetime.now(timezone.utc),
|
generated_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
88
app/services/translation.py
Normal file
88
app/services/translation.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""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},
|
||||||
|
]
|
||||||
|
# Italian / Spanish / French / German typically expand the token count
|
||||||
|
# 15-25 % over English (longer words, more sub-word splits). Our
|
||||||
|
# strategic-log generator runs up to its own 4000-token cap, so a 4000
|
||||||
|
# cap here would silently truncate any near-cap source. 8000 gives
|
||||||
|
# ample headroom for every language we currently support and costs
|
||||||
|
# nothing extra unless the model actually emits more tokens.
|
||||||
|
result = await call_llm(client, messages, max_tokens=8000)
|
||||||
|
|
||||||
|
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
|
||||||
149
app/static/css/auth.css
Normal file
149
app/static/css/auth.css
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
/* Cassandra — auth pages: login, sign-up, OTP verify (standalone, no app chrome). */
|
||||||
|
|
||||||
|
/* --- Auth pages (login / signup, standalone — no app chrome) -------- */
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 360px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 28px 26px;
|
||||||
|
}
|
||||||
|
.auth-card__brand {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.auth-card__brand::before { content: "▰ "; opacity: 0.6; }
|
||||||
|
.auth-card__hint {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 2px 0 18px;
|
||||||
|
}
|
||||||
|
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.auth-card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.auth-card input[type="email"],
|
||||||
|
.auth-card input[type="password"],
|
||||||
|
.auth-card input[type="text"] {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* The 6-digit OTP input wants to be visually loud — it's the only
|
||||||
|
thing the user is doing on that page. Bigger, more spacing, taller. */
|
||||||
|
.auth-card input[name="code"] {
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
letter-spacing: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-card input:focus { border-color: var(--accent); }
|
||||||
|
.auth-card button {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-card button:hover { background: var(--accent); color: var(--bg); }
|
||||||
|
.auth-card__alt {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-error {
|
||||||
|
border-left: 3px solid var(--negative);
|
||||||
|
background: color-mix(in srgb, var(--negative) 6%, transparent);
|
||||||
|
color: var(--negative);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.auth-info {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.auth-info--invited {
|
||||||
|
/* Slightly warmer / friendlier shading for the referral banner. */
|
||||||
|
border-left-color: var(--positive);
|
||||||
|
background: color-mix(in srgb, var(--positive) 7%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.auth-info--invited strong { color: var(--positive); font-weight: 600; }
|
||||||
|
.auth-card__lede {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.auth-card__lede strong { color: var(--text); font-weight: normal; }
|
||||||
|
.auth-card__resend {
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--muted) !important;
|
||||||
|
border: 1px dashed var(--border) !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
.auth-card__resend:hover {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* The card is already width:360px;max-width:100% so it fills the
|
||||||
|
screen — just tighten internal padding to free up vertical space
|
||||||
|
for the keyboard on iOS Safari (which eats half the viewport). */
|
||||||
|
.auth-card { padding: 20px 18px; }
|
||||||
|
.auth-card__brand { font-size: 14px; }
|
||||||
|
.auth-card__lede { font-size: 12px; }
|
||||||
|
.auth-card input,
|
||||||
|
.auth-card button[type="submit"] {
|
||||||
|
font-size: 14px; /* avoids iOS Safari zoom-on-focus */
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
275
app/static/css/dashboard.css
Normal file
275
app/static/css/dashboard.css
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
/* Cassandra — dashboard-specific widgets: market chips, aggregate read
|
||||||
|
* header, indicator summary, glossary tooltips, group tabs, badges. */
|
||||||
|
|
||||||
|
/* --- Dashboard top header (markets + aggregate read) ----------------- */
|
||||||
|
|
||||||
|
.dash-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.mkt {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px 6px;
|
||||||
|
}
|
||||||
|
.mkt__dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
grid-row: 1 / span 2; grid-column: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.mkt--open .mkt__dot { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
|
||||||
|
.mkt--closed .mkt__dot { background: var(--dim); }
|
||||||
|
.mkt__name {
|
||||||
|
grid-row: 1; grid-column: 2;
|
||||||
|
color: var(--text); font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.mkt__state {
|
||||||
|
grid-row: 1; grid-column: 3;
|
||||||
|
font-size: 9.5px; letter-spacing: 0.08em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.mkt--open .mkt__state { color: var(--positive); }
|
||||||
|
.mkt--closed .mkt__state { color: var(--dim); }
|
||||||
|
.mkt__index {
|
||||||
|
grid-row: 2; grid-column: 2;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mkt__index-label { color: var(--dim); }
|
||||||
|
.mkt__index-price { color: var(--text); }
|
||||||
|
.mkt__index-change.pos { color: var(--positive); }
|
||||||
|
.mkt__index-change.neg { color: var(--negative); }
|
||||||
|
.mkt__index-change.neu { color: var(--muted); }
|
||||||
|
.mkt__index--empty { color: var(--dim); font-size: 10px; }
|
||||||
|
.mkt__when {
|
||||||
|
grid-row: 2; grid-column: 3;
|
||||||
|
color: var(--muted); font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-header__read {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.dash-header__read-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dash-header__read-body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.dash-header__read--pending { color: var(--dim); font-style: italic; }
|
||||||
|
.dash-header__read--pending .dash-header__read-body { color: var(--dim); font-size: 12px; }
|
||||||
|
|
||||||
|
/* --- Indicator group summary (above the table) ----------------------- */
|
||||||
|
|
||||||
|
.ind-summary {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||||
|
}
|
||||||
|
.ind-summary__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ind-summary__label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ind-summary__label::before { content: "▸ "; }
|
||||||
|
.ind-summary__when {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ind-summary__body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ind-summary--pending { color: var(--dim); font-style: italic; }
|
||||||
|
.ind-summary--pending .ind-summary__body { color: var(--dim); font-size: 12px; }
|
||||||
|
|
||||||
|
/* --- Glossary tooltips (Novice mode) --------------------------------- */
|
||||||
|
/* The term gets a dotted underline. The actual tooltip is a single shared
|
||||||
|
element (#glossary-tooltip) positioned by JS so it can flip on viewport
|
||||||
|
edges and never clip behind sticky bars (which sit at z-index 50). */
|
||||||
|
|
||||||
|
.glossary {
|
||||||
|
border-bottom: 1px dotted var(--accent);
|
||||||
|
cursor: help;
|
||||||
|
/* Same colour as surrounding text — only the underline signals "tooltip
|
||||||
|
available", keeping the paragraph visually quiet. */
|
||||||
|
}
|
||||||
|
.glossary:focus { outline: 1px dotted var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
#glossary-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200; /* Above sticky bars (z-index 50). */
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: normal;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 90ms ease;
|
||||||
|
}
|
||||||
|
#glossary-tooltip[data-visible="1"] { opacity: 1; }
|
||||||
|
#glossary-tooltip[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* --- Group tabs ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.group-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.group-tabs button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.group-tabs button:hover { color: var(--text); }
|
||||||
|
.group-tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Badges (tone / analysis indicators) ------------------------------ */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
margin-right: 4px;
|
||||||
|
background: transparent;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* Tone axis — green→accent→amber as audience density rises */
|
||||||
|
.badge--tone-novice { color: var(--positive); }
|
||||||
|
.badge--tone-intermediate { color: var(--accent); }
|
||||||
|
.badge--tone-pro { color: var(--alert); }
|
||||||
|
|
||||||
|
/* Analysis axis — dry is muted, speculative is accent */
|
||||||
|
.badge--analysis-dry { color: var(--muted); }
|
||||||
|
.badge--analysis-speculative { color: var(--accent); }
|
||||||
|
|
||||||
|
.badge--ver { color: var(--dim); }
|
||||||
|
.badge--ok { color: var(--positive); border-color: var(--positive); }
|
||||||
|
|
||||||
|
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
|
||||||
|
|
||||||
|
/* BETA indicator pill in the app header — see app/templates/base.html. */
|
||||||
|
.beta-chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--bg);
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* Hide secondary indicator-table columns: Label, Ccy, 1y, anchor,
|
||||||
|
as-of. The cells are tagged with .mobile-hide in indicators.html;
|
||||||
|
this rule keeps display intent in CSS while letting the template
|
||||||
|
handle the conditional anchor column. Symbol / Price / 1d / 1m
|
||||||
|
remain — the four numbers a phone user actually wants. */
|
||||||
|
.dense .mobile-hide { display: none; }
|
||||||
|
|
||||||
|
/* Tighter cell padding so the four remaining columns fit
|
||||||
|
comfortably on a 360px viewport. */
|
||||||
|
.dense th, .dense td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
/* Symbol column gets a touch more breathing room — it's the
|
||||||
|
identifying anchor. */
|
||||||
|
.dense td.label { font-weight: 600; }
|
||||||
|
|
||||||
|
/* Group tabs: wrap onto multiple rows instead of horizontal
|
||||||
|
scrolling so the user can see every group at a glance. The
|
||||||
|
border-bottom moves to each row so wrapped rows are still
|
||||||
|
visually delimited. */
|
||||||
|
.group-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: visible;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.group-tabs button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aggregate-read summary header tightens — stack the label above
|
||||||
|
the timestamp to avoid wrapping at awkward points. */
|
||||||
|
.ind-summary__head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ind-summary__body { font-size: 12px; }
|
||||||
|
}
|
||||||
488
app/static/css/layout.css
Normal file
488
app/static/css/layout.css
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
/* Cassandra — structural layout: html/body, app shell, header, main grid,
|
||||||
|
* sticky markets bar, scrollbar. */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
/* Prevents the off-screen fixed mobile drawer (translateX(100%))
|
||||||
|
from forcing horizontal scroll on Safari iOS, and provides a
|
||||||
|
safety net for any cell/grid that would otherwise overflow. */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* --- Layout ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
/* Grid items default to min-content min-width which can blow past
|
||||||
|
the viewport when a descendant table or flex row is wide. min-width:0
|
||||||
|
lets the cell shrink below intrinsic min-content, and max-width:100vw
|
||||||
|
caps the whole shell against the viewport so we never need to rely on
|
||||||
|
overflow:hidden clipping. */
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
/* Three-column grid: brand+BETA pinned left, nav truly centered in
|
||||||
|
the middle column regardless of side widths, header-right pinned
|
||||||
|
right. The mobile-drawer wrapper is display:contents on desktop so
|
||||||
|
its children (nav, .header-right) become direct grid items and
|
||||||
|
land in columns 2 and 3 by source order. */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.app-header .header-left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.app-header nav { justify-self: center; }
|
||||||
|
.app-header .brand {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.app-header .brand:hover { color: var(--text); }
|
||||||
|
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
|
||||||
|
.app-header nav a {
|
||||||
|
margin-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.app-header nav a:first-child { margin-left: 0; }
|
||||||
|
.app-header nav a.active { color: var(--text); }
|
||||||
|
.app-header .meta { color: var(--muted); font-size: 11px; }
|
||||||
|
|
||||||
|
/* On desktop the mobile-drawer wrapper has no layout effect — its
|
||||||
|
* children (nav, header-right) flow as if it weren't there. On mobile
|
||||||
|
* the @media block at the bottom converts it to a fixed slide-out. */
|
||||||
|
.mobile-drawer { display: contents; }
|
||||||
|
|
||||||
|
.app-header .header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger button — only visible at ≤480px (rule in the mobile block).
|
||||||
|
* Three thin bars; uses the same border/muted treatment as the other
|
||||||
|
* header buttons so the visual rhythm matches. */
|
||||||
|
.drawer-toggle {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
width: 36px;
|
||||||
|
height: 32px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.drawer-toggle:hover { border-color: var(--accent); }
|
||||||
|
.drawer-toggle__bar {
|
||||||
|
display: block;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--muted);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.drawer-toggle:hover .drawer-toggle__bar { background: var(--accent); }
|
||||||
|
|
||||||
|
.drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 90;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease-out;
|
||||||
|
}
|
||||||
|
body.drawer-open .drawer-backdrop { opacity: 1; }
|
||||||
|
/* Segmented toggles — tone (Novice | Intermediate), theme (Light | Dark)
|
||||||
|
* and language (EN | IT) share one visual rhythm so the three controls
|
||||||
|
* read as a single cluster in the header. By default only the currently
|
||||||
|
* active option is rendered; hover or keyboard focus reveals both so the
|
||||||
|
* user can pick the other. Touch devices (which can't hover) show both
|
||||||
|
* options at all times; the @media (hover: hover) gate handles that. */
|
||||||
|
.tone-toggle,
|
||||||
|
.theme-toggle,
|
||||||
|
.lang-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.tone-toggle button,
|
||||||
|
.theme-toggle button,
|
||||||
|
.lang-toggle button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
/* Fixed min-width so the active-only width matches the expanded width
|
||||||
|
of a single button — prevents the layout jumping as the user
|
||||||
|
mouses over and the second option appears. */
|
||||||
|
min-width: 5.5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tone-toggle button + button,
|
||||||
|
.theme-toggle button + button,
|
||||||
|
.lang-toggle button + button { border-left: 1px solid var(--border); }
|
||||||
|
.tone-toggle button:hover,
|
||||||
|
.theme-toggle button:hover,
|
||||||
|
.lang-toggle button:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Active-option highlighting (data-* attribute on the container is
|
||||||
|
* authored by JS on load and on every change). */
|
||||||
|
.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"],
|
||||||
|
.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"],
|
||||||
|
.theme-toggle[data-theme="light"] button[data-value="light"],
|
||||||
|
.theme-toggle[data-theme="dark"] button[data-value="dark"],
|
||||||
|
.lang-toggle[data-lang="en"] button[data-value="en"],
|
||||||
|
.lang-toggle[data-lang="it"] button[data-value="it"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse-when-idle behaviour: on hover-capable devices each toggle
|
||||||
|
* shows only its active option. Hover or keyboard focus reveals the
|
||||||
|
* other option STACKED ABSOLUTELY BELOW so the toggle's in-flow size
|
||||||
|
* never changes — neighbouring controls don't shift when the user
|
||||||
|
* mouses over one of them. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.tone-toggle,
|
||||||
|
.theme-toggle,
|
||||||
|
.lang-toggle {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide every option by default. The active option's higher-specificity
|
||||||
|
rule below puts it back into the static flow. */
|
||||||
|
.tone-toggle button,
|
||||||
|
.theme-toggle button,
|
||||||
|
.lang-toggle button { display: none; }
|
||||||
|
|
||||||
|
/* Hover / focus: render every option as an absolutely-positioned
|
||||||
|
button immediately under the container. The active-button rule
|
||||||
|
immediately below wins on specificity and pins it back into the
|
||||||
|
static flow at the top — only the non-active option(s) actually
|
||||||
|
end up absolutely-positioned, so the popup grows downward only. */
|
||||||
|
.tone-toggle:hover button,
|
||||||
|
.tone-toggle:focus-within button,
|
||||||
|
.theme-toggle:hover button,
|
||||||
|
.theme-toggle:focus-within button,
|
||||||
|
.lang-toggle:hover button,
|
||||||
|
.lang-toggle:focus-within button {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: -1px; /* share the container's bottom border */
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
z-index: 60; /* above the markets bar (z-50) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active option stays in static flow at the top of the container
|
||||||
|
even while hovered. Two-attribute specificity (.X[data=Y] btn[data=Y])
|
||||||
|
beats the .X:hover button rule above. */
|
||||||
|
.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"],
|
||||||
|
.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"],
|
||||||
|
.theme-toggle[data-theme="light"] button[data-value="light"],
|
||||||
|
.theme-toggle[data-theme="dark"] button[data-value="dark"],
|
||||||
|
.lang-toggle[data-lang="en"] button[data-value="en"],
|
||||||
|
.lang-toggle[data-lang="it"] button[data-value="it"] {
|
||||||
|
display: block;
|
||||||
|
position: static;
|
||||||
|
margin-top: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"header header"
|
||||||
|
"indicators log"
|
||||||
|
"portfolio log"
|
||||||
|
"news news";
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.app-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas: "header" "indicators" "portfolio" "log" "news";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#dash-header-container { grid-area: header; }
|
||||||
|
#indicators-panel { grid-area: indicators; }
|
||||||
|
#portfolio-panel { grid-area: portfolio; }
|
||||||
|
#log-panel {
|
||||||
|
grid-area: log;
|
||||||
|
/* Stretch (default align-self) so the log panel's border reaches the
|
||||||
|
bottom of the portfolio next to it — the two right-hand panels
|
||||||
|
align cleanly. The log body itself sits at the top of the panel;
|
||||||
|
any height beyond its content is empty padding inside the box. */
|
||||||
|
}
|
||||||
|
#news-panel { grid-area: news; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Sticky bottom markets bar — uses the same .mkt chip styling as the
|
||||||
|
old dashboard header, extended with each market's headline index. */
|
||||||
|
.markets-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.markets-bar__inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.markets-bar .mkt {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scrollbar -------------------------------------------------------- */
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* Revert to flex on mobile so the drawer-toggle can pin to the right
|
||||||
|
via margin-left:auto and the off-screen drawer doesn't try to claim
|
||||||
|
a grid column. */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
/* When the drawer is open the header (which contains the drawer)
|
||||||
|
needs to draw above the backdrop. The header is a sticky element
|
||||||
|
with its own stacking context at z-index 50, so the drawer's
|
||||||
|
local z-index 100 is clamped to z-50 in the root context — the
|
||||||
|
backdrop at z-90 then sits OVER it. Raise the whole header above
|
||||||
|
the backdrop while the drawer is open. */
|
||||||
|
body.drawer-open .app-header { z-index: 110; }
|
||||||
|
.app-header .brand {
|
||||||
|
font-size: 12px;
|
||||||
|
/* Shrink the leading glyph but don't remove it — keeps brand identity. */
|
||||||
|
}
|
||||||
|
.beta-chip { display: none; }
|
||||||
|
|
||||||
|
/* Show the hamburger; the rest of the header widgets collapse into
|
||||||
|
the drawer (the .mobile-drawer block below). */
|
||||||
|
.drawer-toggle { display: flex; margin-left: auto; }
|
||||||
|
|
||||||
|
/* The drawer wrapper: full-height slide-out from the right. The
|
||||||
|
content inside (nav + header-right) becomes a vertical stack
|
||||||
|
with comfortable touch targets. */
|
||||||
|
.mobile-drawer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(82vw, 320px);
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.18);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 180ms ease-out;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 56px 18px 24px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
body.drawer-open .mobile-drawer { transform: translateX(0); }
|
||||||
|
|
||||||
|
/* Vertical nav inside the drawer — links become big-tap rows, no
|
||||||
|
leading margin like the desktop horizontal nav. */
|
||||||
|
.mobile-drawer nav { display: flex; flex-direction: column; }
|
||||||
|
.mobile-drawer nav a {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 12px 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.mobile-drawer nav a.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header-right widgets vertically stacked inside the drawer. */
|
||||||
|
.mobile-drawer .header-right {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.mobile-drawer .tone-toggle,
|
||||||
|
.mobile-drawer .theme-toggle,
|
||||||
|
.mobile-drawer .lang-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
/* Inside the drawer all options stay visible — undoes the
|
||||||
|
hover-collapse from the @media (hover: hover) block above. Also
|
||||||
|
splits the row evenly and bumps the button padding for thumb taps. */
|
||||||
|
.mobile-drawer .tone-toggle button,
|
||||||
|
.mobile-drawer .theme-toggle button,
|
||||||
|
.mobile-drawer .lang-toggle button {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The user-menu's dropdown becomes redundant inside the drawer —
|
||||||
|
surface its links flat as a list, and hide the chip button. */
|
||||||
|
.mobile-drawer .user-menu { width: 100%; }
|
||||||
|
.mobile-drawer .user-chip { display: none; }
|
||||||
|
.mobile-drawer .user-menu__panel {
|
||||||
|
display: block !important; /* override the hidden attribute */
|
||||||
|
position: static;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.mobile-drawer .user-menu__panel[hidden] { display: block !important; }
|
||||||
|
.mobile-drawer .user-menu__item {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.mobile-drawer .meta {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 18px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The drawer container itself sits above the topbar in z-stacking;
|
||||||
|
we still want the close button accessible while it's open, so push
|
||||||
|
a close target into the top-right corner of the drawer via a
|
||||||
|
repurposed pseudo-element. (Simpler than adding new markup.) */
|
||||||
|
.mobile-drawer::before {
|
||||||
|
content: "✕";
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: none; /* tap handled by the backdrop / hamburger */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body-level layout: tighten main padding too — saves another 16px
|
||||||
|
of horizontal real estate which the indicator table and chat
|
||||||
|
bubbles all benefit from. Also force min-width:0 on the grid
|
||||||
|
container and every grid item, otherwise a wide table inside
|
||||||
|
a panel forces the whole grid (and the page) wider than the
|
||||||
|
viewport. This is the single most important mobile fix. */
|
||||||
|
.app-main {
|
||||||
|
padding: 10px 8px;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
.app-main > * { min-width: 0; }
|
||||||
|
|
||||||
|
/* Markets bar: compact each chip so the full set fits the viewport
|
||||||
|
without horizontal scrolling. We drop:
|
||||||
|
- state word ("open" / "closed") — the dot already conveys that
|
||||||
|
- index label (e.g. "SPX") — implied by the market code
|
||||||
|
- index price — keep the change% which is the actionable number
|
||||||
|
- until-time — too detailed for a glance
|
||||||
|
Remaining: dot + market code + change%. The grid keeps auto-fit
|
||||||
|
but the minimum drops from 220px to 0 so it always fits. */
|
||||||
|
.markets-bar__inner {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.markets-bar .mkt {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
padding: 5px 6px;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
/* Re-flow the chip's grid so it's a single row of three: dot,
|
||||||
|
code, change. The 2-row layout (which had state/when on row 2)
|
||||||
|
is dropped along with the elements that lived there. */
|
||||||
|
.markets-bar .mkt .mkt__dot {
|
||||||
|
grid-row: 1; grid-column: 1;
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
}
|
||||||
|
.markets-bar .mkt .mkt__name {
|
||||||
|
grid-row: 1; grid-column: 2;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.markets-bar .mkt .mkt__index {
|
||||||
|
grid-row: 1; grid-column: 3;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
/* Strip the now-redundant content. The elements still render but
|
||||||
|
occupy no space so the chip stays narrow. */
|
||||||
|
.markets-bar .mkt__state,
|
||||||
|
.markets-bar .mkt__when,
|
||||||
|
.markets-bar .mkt__index-label,
|
||||||
|
.markets-bar .mkt__index-price { display: none; }
|
||||||
|
}
|
||||||
330
app/static/css/log-chat.css
Normal file
330
app/static/css/log-chat.css
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
/* Cassandra — log panel, log page layout, calendar widget, chat sidebar. */
|
||||||
|
|
||||||
|
/* --- Log panel -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 28px clamp(20px, 4vw, 56px) 32px;
|
||||||
|
font-size: 15.5px;
|
||||||
|
line-height: 1.72;
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 76ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
/* No max-height cap here — the dashboard's log panel now stretches in
|
||||||
|
the grid to match the left column's bottom (see #log-panel in
|
||||||
|
layout.css). A constrained max-height was producing an awkward
|
||||||
|
inner scrollbar AND leaving dead space below it inside the panel.
|
||||||
|
With the cap gone the content sits at the panel's top, the panel
|
||||||
|
grows or shrinks with the grid, and the regular page scroll
|
||||||
|
handles very long logs. */
|
||||||
|
}
|
||||||
|
.log-content p { margin: 0 0 1.1em; }
|
||||||
|
.log-content h1, .log-content h2, .log-content h3, .log-content h4 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 1.8em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.log-content h1:first-child,
|
||||||
|
.log-content h2:first-child,
|
||||||
|
.log-content h3:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* TL;DR callout — model is instructed to put it first, so style the first
|
||||||
|
* heading + paragraph block as a callout. */
|
||||||
|
.log-content h3:first-of-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.log-content h3:first-of-type + p {
|
||||||
|
font-size: 16.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 4px 14px 12px;
|
||||||
|
margin: 0 0 1.8em;
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.log-content strong { color: var(--text); font-weight: 700; }
|
||||||
|
.log-content em { color: var(--muted); font-style: italic; }
|
||||||
|
.log-content ul, .log-content ol { padding-left: 1.4em; margin: 0 0 1.1em; }
|
||||||
|
.log-content li { margin-bottom: 0.4em; }
|
||||||
|
.log-content hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Log page (calendar + log + chat sidebar) ------------------------- */
|
||||||
|
|
||||||
|
.log-page__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr 320px;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.log-page__body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.log-page__cal, .log-page__content, .log-page__chat { background: var(--surface); }
|
||||||
|
.log-page__cal { padding: 10px; }
|
||||||
|
.log-page__content { min-height: 60vh; }
|
||||||
|
.log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; }
|
||||||
|
.log-page__chat--locked { opacity: 0.92; }
|
||||||
|
.chat-locked {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 8px 4px;
|
||||||
|
}
|
||||||
|
.chat-locked p { margin: 0; max-width: 280px; }
|
||||||
|
.chat-locked strong { color: var(--text); display: block; margin-bottom: 6px; }
|
||||||
|
|
||||||
|
/* --- Calendar widget --------------------------------------------------- */
|
||||||
|
|
||||||
|
.cal__nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cal__title { color: var(--accent); font-weight: 700; }
|
||||||
|
.cal__btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cal__btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.cal__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cal__h {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--dim);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 3px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cal__d {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 0;
|
||||||
|
text-align: center;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.cal__d--empty { background: var(--bg); cursor: default; }
|
||||||
|
.cal__d--has-log {
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cal__d--has-log::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 3px; height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.cal__d--has-log:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
.cal__d--today { color: var(--warning); }
|
||||||
|
.cal__d--selected {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cal__d--selected::after { background: var(--bg); }
|
||||||
|
|
||||||
|
/* --- Chat sidebar ----------------------------------------------------- */
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 6px 4px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.chat-title::before { content: "▸ "; }
|
||||||
|
.chat-hint { color: var(--dim); font-size: 10px; margin-top: 2px; }
|
||||||
|
|
||||||
|
.chat-thread {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13.5px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.chat-msg--system {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.chat-msg--user {
|
||||||
|
background: var(--user-bubble-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
align-self: flex-end;
|
||||||
|
max-width: 92%;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.chat-msg--user::before {
|
||||||
|
content: "you › ";
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg--assistant { background: var(--surface-2); color: var(--text); }
|
||||||
|
.chat-msg--assistant::before {
|
||||||
|
content: "cassandra › ";
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg--pending { color: var(--dim); font-style: italic; }
|
||||||
|
.chat-msg--error { color: var(--negative); border-color: var(--negative); }
|
||||||
|
|
||||||
|
.chat-msg p { margin: 0.4em 0; }
|
||||||
|
.chat-msg p:first-child { margin-top: 0; }
|
||||||
|
.chat-msg p:last-child { margin-bottom: 0; }
|
||||||
|
.chat-msg h2, .chat-msg h3, .chat-msg h4 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 0.8em 0 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.chat-msg strong { color: var(--text); font-weight: 700; }
|
||||||
|
.chat-msg em { color: var(--muted); font-style: italic; }
|
||||||
|
|
||||||
|
.chat-form {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.chat-form textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 36px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.chat-form textarea:focus { border-color: var(--accent); }
|
||||||
|
.chat-form button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-form button:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
|
||||||
|
.chat-form button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* Trim horizontal padding so the markdown column uses the screen
|
||||||
|
width. The existing 1100px rule already capped the column at
|
||||||
|
76ch; we just shave the surrounding gutter. */
|
||||||
|
.log-content { padding: 0 4px; font-size: 13.5px; }
|
||||||
|
.log-content h2 { font-size: 16px; }
|
||||||
|
.log-content h3 { font-size: 14px; }
|
||||||
|
|
||||||
|
/* Chat bubbles edge-to-edge so the conversation reads like a
|
||||||
|
mobile messenger thread. */
|
||||||
|
.chat-msg {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.chat-msg--user { margin-right: 0; }
|
||||||
|
.chat-msg--assistant { margin-left: 0; }
|
||||||
|
|
||||||
|
/* Chat input row stacks: textarea full-width, button below. */
|
||||||
|
.chat-form {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.chat-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: 14px; /* avoids iOS Safari zoom-on-focus */
|
||||||
|
}
|
||||||
|
.chat-form button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header { padding: 8px 10px; }
|
||||||
|
.chat-title { font-size: 12px; }
|
||||||
|
.chat-hint { font-size: 10px; }
|
||||||
|
}
|
||||||
123
app/static/css/news.css
Normal file
123
app/static/css/news.css
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/* Cassandra — news panel: rows, tag chips, filter pills. */
|
||||||
|
|
||||||
|
/* --- News ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.news-row {
|
||||||
|
padding: 4px 12px;
|
||||||
|
display: grid;
|
||||||
|
/* age | source | title | tags-on-right | utc-time */
|
||||||
|
grid-template-columns: 50px 130px minmax(0, 1fr) minmax(0, auto) 110px;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.news-row { grid-template-columns: 50px 100px 1fr; }
|
||||||
|
.news-row .local,
|
||||||
|
.news-row__tags { display: none; }
|
||||||
|
}
|
||||||
|
.news-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||||
|
.news-row .age { color: var(--dim); text-align: right; }
|
||||||
|
.news-row .source { color: var(--muted); font-size: 11px; }
|
||||||
|
.news-row .title { color: var(--text); }
|
||||||
|
.news-row .title:hover { color: var(--accent); }
|
||||||
|
.news-row .local {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* News tag chips on each row + the top-bar pill toggles */
|
||||||
|
.news-row__tags {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 3px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.tag-chip {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.news-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.news-tag:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.news-tag[data-state="include"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.news-tag[data-state="exclude"] {
|
||||||
|
color: var(--negative);
|
||||||
|
border-color: var(--negative);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.news-tag--clear { color: var(--dim); border-style: dashed; }
|
||||||
|
.news-tag--clear:hover { color: var(--negative); border-color: var(--negative); }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* The 720px rule already collapsed to age | source | title and
|
||||||
|
hid the right-side tag chips. At ≤480 we drop the source column
|
||||||
|
too and let the title flow under the age, with source as a small
|
||||||
|
line below the title — saves another ~100px of horizontal room. */
|
||||||
|
.news-row {
|
||||||
|
grid-template-columns: 50px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.news-row .source {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
.news-row .title {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag filter strip wraps onto multiple rows on a phone. */
|
||||||
|
.news-tags {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
.news-tag {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/static/css/panels.css
Normal file
131
app/static/css/panels.css
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* Cassandra — panel chrome, tables, status LEDs, utility colour classes. */
|
||||||
|
|
||||||
|
/* --- Panels ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||||
|
}
|
||||||
|
.panel-header .title { color: var(--text); font-weight: 700; }
|
||||||
|
.panel-header .title::before { content: "■ "; color: var(--accent); }
|
||||||
|
.panel-header .meta { color: var(--dim); }
|
||||||
|
.panel-body { padding: 6px 0; }
|
||||||
|
.panel-body--scroll { max-height: 70vh; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* --- Tables ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
table.dense {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.dense th, table.dense td {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.dense th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
table.dense th.num,
|
||||||
|
table.dense td.num { text-align: right; }
|
||||||
|
table.dense td.label { color: var(--text); }
|
||||||
|
table.dense td.label.has-tip,
|
||||||
|
table.dense td[title] {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
.pf-name.has-tip {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted color-mix(in srgb, var(--accent) 50%, transparent);
|
||||||
|
}
|
||||||
|
table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||||
|
|
||||||
|
.pos { color: var(--positive); }
|
||||||
|
.neg { color: var(--negative); }
|
||||||
|
.neu { color: var(--muted); }
|
||||||
|
|
||||||
|
/* --- Status LEDs ------------------------------------------------------ */
|
||||||
|
|
||||||
|
.led { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.led.ok { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
|
||||||
|
.led.warn { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
|
||||||
|
.led.err { background: var(--negative); box-shadow: 0 0 6px var(--negative); }
|
||||||
|
.led.idle { background: var(--dim); }
|
||||||
|
|
||||||
|
/* --- Empty / loading state ------------------------------------------- */
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.htmx-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--dim);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.htmx-request .htmx-indicator { opacity: 1; }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* Force panels and their bodies to honour the parent grid cell
|
||||||
|
width, even when descendants (tables, code blocks, long URLs)
|
||||||
|
have intrinsic widths that exceed the viewport. min-width:0 is
|
||||||
|
the magic that lets flex/grid items shrink past min-content;
|
||||||
|
max-width:100% caps the box itself. */
|
||||||
|
.panel { min-width: 0; max-width: 100%; }
|
||||||
|
.panel-body { min-width: 0; max-width: 100%; }
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.panel-header .title { font-size: 12px; }
|
||||||
|
.panel-header .meta { font-size: 10px; }
|
||||||
|
.panel-body { padding: 4px 6px; }
|
||||||
|
/* Scroll panels lose some vertical room on small screens so the
|
||||||
|
stacked layout doesn't push log/news off the fold. */
|
||||||
|
.panel-body--scroll { max-height: 60vh; }
|
||||||
|
|
||||||
|
/* Tables: dropping white-space:nowrap lets long Symbol / Label cells
|
||||||
|
wrap to a second line instead of forcing the table wider than the
|
||||||
|
panel. Numeric cells stay nowrap since "−12.34%" wrapping would be
|
||||||
|
unreadable. */
|
||||||
|
table.dense { table-layout: auto; }
|
||||||
|
table.dense th, table.dense td { white-space: normal; }
|
||||||
|
table.dense .num { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Final safety net: if a descendant still insists on being wider
|
||||||
|
than the panel (e.g. a wide pre/code block in the AI output),
|
||||||
|
scroll it horizontally inside the panel rather than blowing the
|
||||||
|
whole page out. */
|
||||||
|
.panel-body pre,
|
||||||
|
.panel-body code { max-width: 100%; overflow-x: auto; }
|
||||||
|
}
|
||||||
406
app/static/css/portfolio.css
Normal file
406
app/static/css/portfolio.css
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
/* Cassandra — portfolio panel styles: overall stats, actions, inline edit
|
||||||
|
* mode (add composer, delete rows), analysis accordion. */
|
||||||
|
|
||||||
|
/* --- Portfolio overall ----------------------------------------------- */
|
||||||
|
|
||||||
|
.pf-overall {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||||
|
}
|
||||||
|
.pf-overall__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pf-name {
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pf-name::before { content: "◆ "; opacity: 0.6; }
|
||||||
|
.pf-as-of { color: var(--dim); font-size: 11px; }
|
||||||
|
.pf-overall__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pf-overall__grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
.pf-stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.pf-stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.pf-stat-value.pos { color: var(--positive); }
|
||||||
|
.pf-stat-value.neg { color: var(--negative); }
|
||||||
|
.pf-stat-value.neu { color: var(--muted); }
|
||||||
|
.pf-ccy { color: var(--dim); font-size: 11px; margin-left: 2px; }
|
||||||
|
.pf-pct { color: var(--dim); font-size: 11px; margin-left: 4px; }
|
||||||
|
.pf-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||||
|
.pf-pill {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 6px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.pf-warn {
|
||||||
|
border-left: 3px solid var(--alert);
|
||||||
|
background: color-mix(in srgb, var(--alert) 6%, transparent);
|
||||||
|
color: var(--alert);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.pf-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.pf-actions button {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pf-actions button:hover { border-color: var(--accent); }
|
||||||
|
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.pf-secondary { color: var(--muted); }
|
||||||
|
.pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
|
||||||
|
|
||||||
|
.pf-analysis {
|
||||||
|
margin-top: 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pf-analysis__details { padding: 0; }
|
||||||
|
.pf-analysis__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none; /* hide native marker in Firefox */
|
||||||
|
}
|
||||||
|
.pf-analysis__head::-webkit-details-marker { display: none; }
|
||||||
|
.pf-analysis__head-left::before {
|
||||||
|
content: "▸ ";
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
color: var(--accent);
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
||||||
|
.pf-analysis__head:hover { color: var(--accent); }
|
||||||
|
.pf-analysis__head:hover .pf-analysis__head-left::before { color: var(--accent); }
|
||||||
|
.pf-analysis__details[open] .pf-analysis__head {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pf-analysis__body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dashboard portfolio edit mode -----------------------------
|
||||||
|
*
|
||||||
|
* Inline composer that sits above the portfolio table. Aesthetic:
|
||||||
|
* terminal-style command line, no boxed-form chrome, ghost controls,
|
||||||
|
* tinted-neutral palette pulled from --border / --dim / --muted, accent
|
||||||
|
* is theme-aware (deep teal in light, electric cyan in dark).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* The portfolio panel header gains two extra children (the EDIT / Done
|
||||||
|
* pills). The global `.panel-header` uses `space-between`, which works
|
||||||
|
* for headers with only title+meta but collapses meta into title once
|
||||||
|
* any later child has `margin-left: auto`. Switch this header to a
|
||||||
|
* gap-based flow; meta now sits 12px from the title, edit pill at the
|
||||||
|
* far right via its own auto-margin. */
|
||||||
|
#portfolio-panel .panel-header {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EDIT / Done toggle buttons in the panel header. */
|
||||||
|
.pf-edit-btn,
|
||||||
|
.pf-done-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: color 120ms ease-out, border-color 120ms ease-out;
|
||||||
|
}
|
||||||
|
.pf-edit-btn:hover, .pf-done-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
/* The JS toggles these via the `hidden` attribute. `display: inline-flex`
|
||||||
|
* above otherwise wins over the UA's `[hidden] { display: none }`. */
|
||||||
|
.pf-edit-btn[hidden], .pf-done-btn[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* × button per row — hidden by default, visible only in edit mode. */
|
||||||
|
.pf-row-del-cell { width: 20px; text-align: center; }
|
||||||
|
.pf-row-del {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 120ms ease-out;
|
||||||
|
}
|
||||||
|
#portfolio-panel.pf-editing .pf-row-del { display: inline; }
|
||||||
|
#portfolio-panel.pf-editing .pf-row-del:hover { color: var(--negative); }
|
||||||
|
|
||||||
|
/* ---------- Inline add-position composer ----------------------------- */
|
||||||
|
|
||||||
|
.pf-add {
|
||||||
|
padding: 6px 12px 8px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.pf-add__line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pf-add__prompt {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.pf-add__div {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
.pf-add__at {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 11px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.pf-add__line input[type="text"],
|
||||||
|
.pf-add__line input[type="number"],
|
||||||
|
.pf-add__line input[type="date"] {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.pf-add__line input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
.pf-add__line input::placeholder {
|
||||||
|
color: var(--dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.pf-add__ticker {
|
||||||
|
width: 80px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pf-add__num--qty { width: 56px; text-align: right; }
|
||||||
|
.pf-add__num--cost { width: 76px; text-align: right; }
|
||||||
|
.pf-add__date { width: 128px; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* Tiny pill that shows after a successful validate: "172.40 USD". */
|
||||||
|
.pf-add-currency {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 24px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.pf-add-currency:empty { display: none; }
|
||||||
|
|
||||||
|
/* Calendar-icon button — ghost, square, terminal-feel. */
|
||||||
|
.pf-add__icon {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--dim);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 120ms ease-out, border-color 120ms ease-out;
|
||||||
|
}
|
||||||
|
.pf-add__icon:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.pf-add__icon:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit button — a square accent-bordered plus glyph. Visually
|
||||||
|
* heavier than the ghost calendar icon (larger size, accent border)
|
||||||
|
* so the primary action reads as primary. Lights up to solid accent
|
||||||
|
* on hover/focus when enabled. */
|
||||||
|
.pf-add__submit {
|
||||||
|
margin-left: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 120ms ease-out, color 120ms ease-out,
|
||||||
|
border-color 120ms ease-out, transform 120ms ease-out;
|
||||||
|
}
|
||||||
|
.pf-add__submit:hover:not(:disabled),
|
||||||
|
.pf-add__submit:focus-visible:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.pf-add__submit:active:not(:disabled) {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
.pf-add__submit:disabled {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--dim);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators next to the ticker + below the row. */
|
||||||
|
.pf-add-status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pf-add-status:empty { display: none; }
|
||||||
|
.pf-add-status--pending { color: var(--dim); font-style: italic; }
|
||||||
|
.pf-add-status--ok { color: var(--positive); }
|
||||||
|
.pf-add-status--err { color: var(--negative); }
|
||||||
|
|
||||||
|
/* Secondary line below the main row — only takes space when a child has
|
||||||
|
* content. Holds the date-lookup status and the duplicate warning. */
|
||||||
|
.pf-add__notes {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pf-add__notes:has(:empty:only-child) { display: none; }
|
||||||
|
.pf-add-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
.pf-add-warning:empty { display: none; }
|
||||||
|
|
||||||
|
/* Quietly explains the controls. Shown only when the form is visible,
|
||||||
|
* which is to say only in edit mode. */
|
||||||
|
.pf-add__hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.pf-add__hint kbd {
|
||||||
|
font-family: inherit;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* The existing 640px breakpoint already moves the overall grid to
|
||||||
|
2 cols. At ≤480 we keep 2 cols but tighten gap so the stat
|
||||||
|
values don't crowd the labels next to them. */
|
||||||
|
.pf-overall__grid { gap: 4px 12px; }
|
||||||
|
.pf-stat-value { font-size: 14px; }
|
||||||
|
|
||||||
|
/* Action buttons wrap to multiple rows instead of squishing onto
|
||||||
|
one. flex-wrap was already set above; ensure each button has a
|
||||||
|
comfortable tap target. */
|
||||||
|
.pf-actions { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pf-actions button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pill row stays wrapped; just give pills a small min-width so
|
||||||
|
two-character tags (USD, EUR) don't hug each other awkwardly. */
|
||||||
|
.pf-pill { padding: 3px 7px; }
|
||||||
|
|
||||||
|
/* The inline composer's input gets the full width — the desktop's
|
||||||
|
intrinsic-width sizing leaves it tiny on a phone. */
|
||||||
|
.pf-add__line { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pf-add__line input, .pf-add__line textarea { width: 100%; }
|
||||||
|
}
|
||||||
743
app/static/css/public.css
Normal file
743
app/static/css/public.css
Normal file
|
|
@ -0,0 +1,743 @@
|
||||||
|
/* Cassandra — public pages: landing, pricing, about, terms, privacy,
|
||||||
|
* disclaimer. Shared by all templates extending public_base.html.
|
||||||
|
* Visual language matches the app shell but without dashboard chrome. */
|
||||||
|
|
||||||
|
.public-page { background: var(--bg); }
|
||||||
|
|
||||||
|
.public-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 22px 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.public-header__brand {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.public-header__brand::before { content: "▰ "; opacity: 0.6; }
|
||||||
|
.public-header__brand:hover { color: var(--text); }
|
||||||
|
.public-header__nav { display: flex; align-items: center; gap: 22px; }
|
||||||
|
.public-header__nav a {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.public-header__nav a:hover,
|
||||||
|
.public-header__nav a.active { color: var(--text); }
|
||||||
|
.public-header__cta {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.public-header__cta:hover { background: var(--accent); color: var(--bg) !important; }
|
||||||
|
|
||||||
|
.public-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 48px 0 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 28px 0 36px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.public-footer__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.public-footer__brand strong { color: var(--text); margin-right: 10px; }
|
||||||
|
.public-footer__tagline { color: var(--muted); }
|
||||||
|
.public-footer__links { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
|
.public-footer__links a { color: var(--muted); text-decoration: none; }
|
||||||
|
.public-footer__links a:hover { color: var(--accent); }
|
||||||
|
.public-footer__meta { color: var(--dim); font-size: 11px; }
|
||||||
|
|
||||||
|
/* --- Hero (landing) -------------------------------------------------- */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 32px 0 48px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.hero__brand {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.hero__headline {
|
||||||
|
font-size: clamp(28px, 5vw, 44px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 12px 0 14px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.hero__subhead {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 640px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
/* Shared button shape — was previously scoped to .hero__ctas, which made
|
||||||
|
the pricing-card CTAs render as bare anchors. */
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* Block variant: full-width within parent, slightly taller — used inside
|
||||||
|
tier cards so each CTA spans the card and reads as the obvious action. */
|
||||||
|
.btn-block { display: block; width: 100%; padding: 12px 22px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* Qualify with `a` so we beat `a { color: var(--accent) }` and any
|
||||||
|
:link/:visited UA defaults. Without `a.btn-primary` the cascade can
|
||||||
|
resolve in favour of the visited-link color on some browsers and the
|
||||||
|
label disappears against the accent background. */
|
||||||
|
a.btn-primary,
|
||||||
|
a.btn-primary:link,
|
||||||
|
a.btn-primary:visited {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
a.btn-primary:hover { background: transparent; color: var(--accent); }
|
||||||
|
a.btn-secondary,
|
||||||
|
a.btn-secondary:link,
|
||||||
|
a.btn-secondary:visited {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* --- Feature blocks (landing) --------------------------------------- */
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin: 8px 0 56px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 22px 22px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
/* Flex column so the screenshot thumbnail can dock to the bottom via
|
||||||
|
margin-top:auto — that's what lines the three thumbnails up across
|
||||||
|
cards regardless of body-text length. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.feature-card__tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.feature-card__title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.feature-card__body {
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
/* Grow to fill the flex column so the thumbnail below docks to the
|
||||||
|
bottom of the card. With grid-stretched equal-height cards, this is
|
||||||
|
what aligns the thumbnails across the three cards. */
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Section primitives reused across pricing/about/legal ---------- */
|
||||||
|
|
||||||
|
.public-section {
|
||||||
|
margin: 0 0 56px;
|
||||||
|
}
|
||||||
|
.public-section__head {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.public-section h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
}
|
||||||
|
.public-section p,
|
||||||
|
.public-section li {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.public-section p { margin: 0 0 14px; }
|
||||||
|
.public-section ul {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.public-section li { margin-bottom: 6px; }
|
||||||
|
.public-section a { color: var(--accent); }
|
||||||
|
|
||||||
|
.public-section--callout {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 16px 22px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
.public-section--warning {
|
||||||
|
border-left-color: var(--negative);
|
||||||
|
background: color-mix(in srgb, var(--negative) 6%, var(--bg));
|
||||||
|
}
|
||||||
|
.public-section--warning a { color: var(--text); }
|
||||||
|
|
||||||
|
/* --- "What this is not" strip on landing --------------------------- */
|
||||||
|
|
||||||
|
.not-strip {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0 0 56px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.not-strip strong { color: var(--text); }
|
||||||
|
.not-strip ul { display: flex; flex-wrap: wrap; gap: 18px 28px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||||
|
.not-strip li { color: var(--muted); font-size: 13px; }
|
||||||
|
.not-strip li::before { content: "✕ "; color: var(--negative); font-weight: 700; margin-right: 4px; }
|
||||||
|
|
||||||
|
/* --- Pricing comparison -------------------------------------------- */
|
||||||
|
|
||||||
|
.tier-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin: 8px 0 40px;
|
||||||
|
}
|
||||||
|
.tier-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 28px 26px 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tier-card--featured {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent) inset,
|
||||||
|
0 12px 32px rgba(15, 23, 42, 0.10);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .tier-card--featured {
|
||||||
|
box-shadow: 0 0 0 1px var(--accent) inset,
|
||||||
|
0 12px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.tier-card__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -11px;
|
||||||
|
left: 24px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* Tier name — the actual heading, not the small uppercase chip it used
|
||||||
|
to be. Pairs with .tier-card__tagline for a one-line value framing. */
|
||||||
|
.tier-card__name {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.tier-card__tagline {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.tier-card__price {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.tier-card__price-unit {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.tier-card__price-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.tier-card__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
.tier-card__list-head {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.tier-card ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.tier-card li {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.55;
|
||||||
|
padding: 8px 0 8px 22px;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tier-card li:last-child { border-bottom: 0; }
|
||||||
|
.tier-card li::before {
|
||||||
|
content: "✓";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
color: var(--positive);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tier-card__cta { margin-top: 18px; }
|
||||||
|
/* Consent block above the Subscribe buttons (paid card, logged-in
|
||||||
|
free user). The Subscribe buttons render disabled; ticking the box
|
||||||
|
is what enables them. Wording covers ToS agreement (both cadences)
|
||||||
|
+ the Reg 36 CCR 2013 waiver (monthly only). */
|
||||||
|
.tier-card__consent {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tier-card__consent input[type="checkbox"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tier-card__consent a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.tier-card__consent strong { color: var(--text); }
|
||||||
|
|
||||||
|
.tier-card__more {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Side-by-side feature comparison table. Lives below the cards and
|
||||||
|
makes the deltas readable at a glance — the cards sell, the table
|
||||||
|
confirms. */
|
||||||
|
.compare-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.compare-table th,
|
||||||
|
.compare-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.compare-table thead th {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.compare-table th[scope="row"] {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
width: 38%;
|
||||||
|
}
|
||||||
|
.compare-table td.compare-table__free { color: var(--muted); }
|
||||||
|
.compare-table td.compare-table__paid { color: var(--text); font-weight: 500; }
|
||||||
|
.compare-table td.compare-table__paid strong { color: var(--accent); font-weight: 600; }
|
||||||
|
.compare-table td.compare-table__none { color: var(--dim); }
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.compare-table th[scope="row"] { width: 50%; }
|
||||||
|
.compare-table th, .compare-table td { padding: 10px 8px; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Landing-page screenshots: hero shot, thumbnails, gallery, lightbox --- */
|
||||||
|
|
||||||
|
/* All clickable screenshots are <button>s — reset the default chrome so they
|
||||||
|
read as image cards, not form controls. */
|
||||||
|
.shot {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 120ms ease, transform 120ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.18),
|
||||||
|
0 2px 6px rgba(15, 23, 42, 0.10);
|
||||||
|
}
|
||||||
|
.shot:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22),
|
||||||
|
0 4px 10px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
.shot:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent),
|
||||||
|
0 6px 22px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
/* Dark mode: the soft slate shadow disappears against the near-black bg.
|
||||||
|
Use a deeper, slightly accent-tinted glow so the cards still lift. */
|
||||||
|
[data-theme="dark"] .shot {
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.55),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .shot:hover {
|
||||||
|
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.65),
|
||||||
|
0 0 0 1px rgba(0, 217, 255, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero screenshot — sits just below the headline CTAs, full landing width. */
|
||||||
|
.shot-hero {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto 56px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.shot--hero .shot__zoom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--bg);
|
||||||
|
background: var(--accent);
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail at the bottom of each feature card. */
|
||||||
|
.feature-card__shot {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.feature-card__shot img {
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "More views" strip — flex so we can drop in 2-3 extra shots later. */
|
||||||
|
.shots-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.shots-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.shot__caption {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.shot__caption strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox. <dialog> handles the modal mechanics. */
|
||||||
|
.shot-modal {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
max-width: min(96vw, 1400px);
|
||||||
|
max-height: 94vh;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.shot-modal::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.78);
|
||||||
|
}
|
||||||
|
.shot-modal img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.shot-modal p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 22px 18px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.shot-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.shot-modal__close:hover,
|
||||||
|
.shot-modal__close:focus-visible {
|
||||||
|
color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Invite-a-friend callout (pricing) ----------------------------- */
|
||||||
|
|
||||||
|
.invite-callout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin: 0 0 40px;
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--accent) 12%, var(--surface)),
|
||||||
|
var(--surface));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.invite-callout__icon {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: saturate(1.1);
|
||||||
|
}
|
||||||
|
.invite-callout__body { flex: 1; min-width: 0; }
|
||||||
|
.invite-callout__eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.invite-callout__headline {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.invite-callout__headline strong { color: var(--accent); font-weight: 700; }
|
||||||
|
.invite-callout__sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.invite-callout .btn-secondary { flex-shrink: 0; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.invite-callout { flex-direction: column; align-items: flex-start; gap: 14px; }
|
||||||
|
.invite-callout .btn-secondary { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic text-only modal — reuse for any "click for the details" pattern. */
|
||||||
|
.text-modal {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
max-width: min(92vw, 560px);
|
||||||
|
max-height: 88vh;
|
||||||
|
padding: 28px 28px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.text-modal::backdrop { background: rgba(0, 0, 0, 0.65); }
|
||||||
|
.text-modal__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding-right: 36px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.text-modal__head {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
}
|
||||||
|
.text-modal p {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.text-modal__list {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.text-modal__list li {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.text-modal code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.text-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.text-modal__close:hover,
|
||||||
|
.text-modal__close:focus-visible {
|
||||||
|
color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* Hero headline already uses clamp(); shrink its lower bound so a
|
||||||
|
two-line headline doesn't push the CTAs below the fold on a
|
||||||
|
360px screen. */
|
||||||
|
.hero__headline { font-size: clamp(22px, 6vw, 32px); }
|
||||||
|
.hero__subhead { font-size: 14px; }
|
||||||
|
|
||||||
|
/* CTAs stack full-width on phones — easier tap targets. */
|
||||||
|
.hero__ctas { flex-direction: column; align-items: stretch; }
|
||||||
|
.btn-primary, .btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier cards (pricing page) stack on phones. */
|
||||||
|
.tier-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.tier-card { padding: 18px; }
|
||||||
|
|
||||||
|
/* Tighten public-page outer padding. */
|
||||||
|
.public-shell { padding: 16px 12px; }
|
||||||
|
}
|
||||||
416
app/static/css/settings.css
Normal file
416
app/static/css/settings.css
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
/* Cassandra — settings page: rows, selects, dropzone, invite block,
|
||||||
|
* user menu dropdown, import preview, action buttons. */
|
||||||
|
|
||||||
|
/* Settings-page action button — same visual language as .pf-actions
|
||||||
|
button so buttons across /settings (Manage subscription, future
|
||||||
|
actions) read as one family. Standalone class (not nested under a
|
||||||
|
parent) so it can be dropped onto any button anywhere on the page. */
|
||||||
|
.settings-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.settings-btn:hover { border-color: var(--accent); }
|
||||||
|
.settings-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Icon-button variant for inline row actions (e.g. Manage subscription
|
||||||
|
gear in the Tier row). Square hit area, accent on hover, tooltip via
|
||||||
|
title attribute. */
|
||||||
|
.settings-icon-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 80ms linear, border-color 80ms linear, background 80ms linear;
|
||||||
|
}
|
||||||
|
.settings-icon-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.settings-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.settings-icon-btn svg { display: block; }
|
||||||
|
|
||||||
|
/* --- Settings page --------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.settings-row__label {
|
||||||
|
width: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.settings-row__value { color: var(--text); }
|
||||||
|
.settings-row__hint {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal-aesthetic <select> used in the Settings page. Native
|
||||||
|
* browser chrome stripped; we render a small chevron via crossed
|
||||||
|
* linear-gradients so the control matches the rest of the panel. */
|
||||||
|
.settings-select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 4px 28px 4px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, var(--dim) 50%),
|
||||||
|
linear-gradient(-45deg, transparent 50%, var(--dim) 50%);
|
||||||
|
background-position: calc(100% - 13px) 50%, calc(100% - 9px) 50%;
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
transition: border-color 120ms ease-out, color 120ms ease-out;
|
||||||
|
}
|
||||||
|
.settings-select:hover,
|
||||||
|
.settings-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.settings-select option { color: var(--text); background: var(--surface); }
|
||||||
|
.settings-select option:disabled { color: var(--dim); }
|
||||||
|
|
||||||
|
.settings-status {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.settings-status:empty { display: none; }
|
||||||
|
|
||||||
|
/* Sections are <details> elements — collapsed by default to keep the
|
||||||
|
settings page scannable. Click the summary to expand. */
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 14px;
|
||||||
|
border-top: 1px solid var(--surface-2);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.settings-section__head {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
/* Suppress the native disclosure marker (Webkit + Firefox). */
|
||||||
|
.settings-section__head::-webkit-details-marker { display: none; }
|
||||||
|
.settings-section__head::marker { content: ""; }
|
||||||
|
.settings-section__head::before {
|
||||||
|
content: "▸";
|
||||||
|
color: var(--accent);
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 120ms ease-out;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.settings-section[open] > .settings-section__head::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.settings-section[open] > .settings-section__head { margin-bottom: 10px; }
|
||||||
|
.settings-section__head:hover { color: var(--text); }
|
||||||
|
.settings-section__head:hover::before { color: var(--text); }
|
||||||
|
.settings-section__lede {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.settings-section__lede strong { color: var(--positive); font-weight: 600; }
|
||||||
|
|
||||||
|
.invite-block {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.invite-block__label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.invite-block__label:not(:first-child) { margin-top: 12px; }
|
||||||
|
.invite-block__code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.invite-block__link {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.invite-block__link input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.invite-block__link button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 0;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.invite-block__link button:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.invite-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.invite-stats > div {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.invite-stats__label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.invite-stats__value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import preview action row — two stacked buttons with an explainer. */
|
||||||
|
.import-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.import-choice { flex: 1 1 240px; min-width: 220px; }
|
||||||
|
.import-choice button { width: 100%; }
|
||||||
|
.import-choice .settings-row__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User chip in header — now a button that toggles a dropdown menu. */
|
||||||
|
.user-menu { position: relative; margin-left: 8px; }
|
||||||
|
.user-chip {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-chip:hover { color: var(--accent); }
|
||||||
|
.user-menu__caret { margin-left: 4px; opacity: 0.6; }
|
||||||
|
.user-menu__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 200;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.user-menu__item {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.user-menu__item:hover { background: var(--surface-2); color: var(--accent); }
|
||||||
|
|
||||||
|
/* --- Upload / import drag-drop zone (settings page) ------------------ */
|
||||||
|
|
||||||
|
.dz {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 36px 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.dz:hover, .dz--over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, var(--surface-2));
|
||||||
|
}
|
||||||
|
.dz__icon {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: -2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.dz__label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.dz__hint { color: var(--muted); font-size: 11.5px; margin-top: 4px; }
|
||||||
|
.dz__hint a { color: var(--accent); }
|
||||||
|
.dz__filename { margin-top: 10px; color: var(--accent); font-size: 12px; font-family: var(--font-mono); min-height: 1em; }
|
||||||
|
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.result--err { border-left-color: var(--negative); background: color-mix(in srgb, var(--negative) 5%, transparent); }
|
||||||
|
.result__head {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.result--err .result__head { color: var(--negative); }
|
||||||
|
.result__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px 18px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.result__grid .k {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.result__grid .v { font-size: 17px; color: var(--text); font-variant-numeric: tabular-nums; margin-top: 2px; }
|
||||||
|
.result__grid .v.pos { color: var(--positive); }
|
||||||
|
.result__grid .v.neg { color: var(--negative); }
|
||||||
|
.result__row { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||||
|
.result__warn { color: var(--alert); font-size: 12px; margin-top: 4px; }
|
||||||
|
.result__warn code { background: rgba(0,0,0,0.15); padding: 1px 4px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* --- Modal text inputs (cloud-sync PIN modal, etc.) ---------------- */
|
||||||
|
/* Same visual treatment as auth-card so prompts read as a coherent
|
||||||
|
family. Replaces the inline `style="padding:8px"` that left these
|
||||||
|
inputs feeling cramped. */
|
||||||
|
.modal-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.modal-input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Mobile (≤480px) -------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
/* Form rows stack: label above value instead of side-by-side. The
|
||||||
|
desktop layout uses a fixed 110px label column that pinches the
|
||||||
|
value column unbearably on a phone. */
|
||||||
|
.settings-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.settings-row__label {
|
||||||
|
width: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.settings-select {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px; /* avoids iOS Safari zoom-on-focus */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two-column import picker becomes single column. */
|
||||||
|
.import-choice {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons get a full-width tap target. */
|
||||||
|
.settings-btn { width: 100%; padding: 10px; }
|
||||||
|
.settings-icon-btn { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
44
app/static/css/tokens.css
Normal file
44
app/static/css/tokens.css
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/* Cassandra — design tokens: palette, dark-theme overrides, font stacks.
|
||||||
|
* Must load first so all other files can var(--foo). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Light theme (default) */
|
||||||
|
--bg: #f5f3ec; /* warm off-white, easier on the eyes than pure white */
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #efece3;
|
||||||
|
--border: #d6d3cb;
|
||||||
|
--text: #1c1f25;
|
||||||
|
--muted: #545b69;
|
||||||
|
--dim: #8a8f9a;
|
||||||
|
--accent: #0e7490; /* deep teal — still terminal-feel on light */
|
||||||
|
--positive: #166534;
|
||||||
|
--negative: #b91c1c;
|
||||||
|
--alert: #c2410c;
|
||||||
|
--warning: #a16207;
|
||||||
|
--user-bubble-bg: rgba(14, 116, 144, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0a0e14;
|
||||||
|
--surface: #11151c;
|
||||||
|
--surface-2: #161b25;
|
||||||
|
--border: #2a3142;
|
||||||
|
--text: #d4dae8; /* lifted from #c0caf5 for readability */
|
||||||
|
--muted: #8189a1; /* lifted from #565f89 — was unreadably dim */
|
||||||
|
--dim: #565f89;
|
||||||
|
--accent: #00d9ff;
|
||||||
|
--positive: #50fa7b;
|
||||||
|
--negative: #ff5b5b;
|
||||||
|
--alert: #ff8a4a;
|
||||||
|
--warning: #f1fa8c;
|
||||||
|
--user-bubble-bg: rgba(0, 217, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font stacks. Mono for terminal feel; sans for reading. */
|
||||||
|
:root {
|
||||||
|
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', ui-monospace, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -37,7 +37,8 @@
|
||||||
append('user', text);
|
append('user', text);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
send.disabled = true;
|
send.disabled = true;
|
||||||
const thinking = append('assistant pending', '…');
|
const thinking = append('assistant', '…');
|
||||||
|
thinking.classList.add('chat-msg--pending');
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/chat', {
|
const r = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@
|
||||||
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
||||||
: '—';
|
: '—';
|
||||||
mount.innerHTML =
|
mount.innerHTML =
|
||||||
'<div class="pf-restore" style="padding:16px;">' +
|
'<div style="padding:16px;">' +
|
||||||
'<div class="result__head">▸ Restore from cloud</div>' +
|
'<div class="result__head">▸ Restore from cloud</div>' +
|
||||||
'<div class="result__row" style="margin-bottom:12px;">' +
|
'<div class="result__row" style="margin-bottom:12px;">' +
|
||||||
'A synced portfolio is available for this account (last synced ' +
|
'A synced portfolio is available for this account (last synced ' +
|
||||||
|
|
@ -303,8 +303,8 @@
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="label">' + esc(p.yahoo_ticker) + '</td>' +
|
'<td class="label">' + esc(p.yahoo_ticker) + '</td>' +
|
||||||
'<td>' + esc(p.name || '') + '</td>' +
|
'<td>' + esc(p.name || '') + '</td>' +
|
||||||
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
'<td class="num mobile-hide">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
||||||
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
|
'<td class="num neu mobile-hide">' + fmt(p.avg_cost) + '</td>' +
|
||||||
'<td class="num">' + lastDisplay + fxBadge + '</td>' +
|
'<td class="num">' + lastDisplay + fxBadge + '</td>' +
|
||||||
'<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
|
'<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
|
||||||
'<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
|
'<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
|
||||||
|
|
@ -365,7 +365,7 @@
|
||||||
'<table class="dense">' +
|
'<table class="dense">' +
|
||||||
'<thead><tr>' +
|
'<thead><tr>' +
|
||||||
'<th>Ticker</th><th>Name</th>' +
|
'<th>Ticker</th><th>Name</th>' +
|
||||||
'<th class="num">Qty</th><th class="num">Avg</th>' +
|
'<th class="num mobile-hide">Qty</th><th class="num mobile-hide">Avg</th>' +
|
||||||
'<th class="num">Last</th><th class="num">P/L</th>' +
|
'<th class="num">Last</th><th class="num">P/L</th>' +
|
||||||
'<th class="num">%</th>' +
|
'<th class="num">%</th>' +
|
||||||
'<th></th>' +
|
'<th></th>' +
|
||||||
|
|
@ -387,10 +387,12 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-hydrate any cached AI analysis so the 60s auto-refresh doesn't
|
// Re-hydrate any cached AI analysis so the 60s auto-refresh doesn't
|
||||||
// wipe it. Collapsed by default on hydration so the panel stays
|
// wipe it. Rendered expanded so the user keeps seeing the body they
|
||||||
// compact — click the header to expand.
|
// just generated — collapsing it under their cursor every minute
|
||||||
|
// reads as "the analysis disappeared". They can still click the
|
||||||
|
// header to collapse manually within a single refresh window.
|
||||||
if (pie.analysis && pie.analysis.content) {
|
if (pie.analysis && pie.analysis.content) {
|
||||||
showAnalysis(pie.analysis, { open: false });
|
showAnalysis(pie.analysis, { open: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
245
app/static/js/settings-import.js
Normal file
245
app/static/js/settings-import.js
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Server-side hint: did the user have paid privileges when the page
|
||||||
|
// rendered? Used to decide whether to offer the 'Import & sync' button.
|
||||||
|
// We still call CassandraSync.getStatus() at click time as the source
|
||||||
|
// of truth, but this lets us skip rendering a button we know is dead.
|
||||||
|
// Value is passed via data-paid attribute on #drop-zone.
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
} else { fn(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var P = window.CassandraPortfolio;
|
||||||
|
if (!P) return;
|
||||||
|
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
|
||||||
|
|
||||||
|
var dropZone = document.getElementById('drop-zone');
|
||||||
|
var fileInput = document.getElementById('file-input');
|
||||||
|
var browseLink = document.getElementById('browse-link');
|
||||||
|
var filenameEl = document.getElementById('dz-filename');
|
||||||
|
var previewEl = document.getElementById('import-preview');
|
||||||
|
var resultEl = document.getElementById('import-result');
|
||||||
|
if (!dropZone) return;
|
||||||
|
|
||||||
|
var IS_PAID = dropZone.dataset.paid === 'true';
|
||||||
|
|
||||||
|
var currentPie = null; // most recently parsed pie, awaiting commit
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
previewEl.hidden = true;
|
||||||
|
resultEl.className = 'result result--err';
|
||||||
|
resultEl.innerHTML =
|
||||||
|
'<div class="result__head">✕ Import failed</div>' +
|
||||||
|
'<div class="result__row">' + esc(msg) + '</div>';
|
||||||
|
resultEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(headline, sub) {
|
||||||
|
previewEl.hidden = true;
|
||||||
|
resultEl.className = 'result result--ok';
|
||||||
|
resultEl.innerHTML =
|
||||||
|
'<div class="result__head">' + esc(headline) + '</div>' +
|
||||||
|
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
|
||||||
|
'<div class="result__row" style="margin-top:14px;">' +
|
||||||
|
'<a href="/">Open dashboard →</a>' +
|
||||||
|
'</div>';
|
||||||
|
resultEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(pie) {
|
||||||
|
currentPie = pie;
|
||||||
|
resultEl.hidden = true;
|
||||||
|
|
||||||
|
var t = pie.totals || {};
|
||||||
|
var rows = (pie.positions || []).map(function (p) {
|
||||||
|
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
|
||||||
|
'<td>' + esc(p.name || '') + '</td>' +
|
||||||
|
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
||||||
|
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(invested) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var warnings = (pie.warnings || []).map(function (w) {
|
||||||
|
return '<div class="result__warn">' + esc(w) + '</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var syncBtn = IS_PAID
|
||||||
|
? ('<div class="import-choice">' +
|
||||||
|
'<button type="button" id="commit-sync">Import & sync to cloud</button>' +
|
||||||
|
'<div class="settings-row__hint">' +
|
||||||
|
'Also stores an <strong>encrypted</strong> copy on the server, ' +
|
||||||
|
'restorable on any device with your PIN. Only you can decrypt ' +
|
||||||
|
'it — losing the PIN means losing the backup.' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>')
|
||||||
|
: ('<div class="import-choice">' +
|
||||||
|
'<button type="button" disabled>Import & sync to cloud</button>' +
|
||||||
|
'<div class="settings-row__hint">' +
|
||||||
|
'Encrypted cloud backup is available on the paid tier.' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
|
||||||
|
previewEl.innerHTML =
|
||||||
|
'<div class="result result--ok" style="margin:0;">' +
|
||||||
|
'<div class="result__head">' +
|
||||||
|
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="result__grid">' +
|
||||||
|
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
|
||||||
|
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
|
||||||
|
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
|
||||||
|
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
warnings +
|
||||||
|
(rows
|
||||||
|
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
|
||||||
|
'<table class="dense">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th>Ticker</th><th>Name</th>' +
|
||||||
|
'<th class="num">Qty</th>' +
|
||||||
|
'<th class="num">Avg</th>' +
|
||||||
|
'<th class="num">Invested</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>'
|
||||||
|
: ''
|
||||||
|
) +
|
||||||
|
'<div class="import-actions">' +
|
||||||
|
'<div class="import-choice">' +
|
||||||
|
'<button type="button" id="commit-local">Import to this browser</button>' +
|
||||||
|
'<div class="settings-row__hint">' +
|
||||||
|
'Saved to this browser only. No server-side copy of your holdings.' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
syncBtn +
|
||||||
|
'<div style="flex-basis:100%;">' +
|
||||||
|
'<button type="button" id="commit-cancel" class="pf-secondary">' +
|
||||||
|
'Cancel</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
previewEl.hidden = false;
|
||||||
|
|
||||||
|
document.getElementById('commit-local').addEventListener('click', commitLocal);
|
||||||
|
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
|
||||||
|
var syncEl = document.getElementById('commit-sync');
|
||||||
|
if (syncEl) syncEl.addEventListener('click', commitSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitLocal() {
|
||||||
|
if (!currentPie) return;
|
||||||
|
P.savePie(currentPie);
|
||||||
|
showSuccess('▸ Imported to this browser.',
|
||||||
|
'Pie kept locally; no server-side copy.');
|
||||||
|
currentPie = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitSync() {
|
||||||
|
if (!currentPie) return;
|
||||||
|
// Save locally first so the cloud-sync flow uses the freshly-imported
|
||||||
|
// pie (the enable-PIN modal in this same page reads from localStorage).
|
||||||
|
P.savePie(currentPie);
|
||||||
|
var S = window.CassandraSync;
|
||||||
|
if (!S) { showError('Cloud sync module not loaded.'); return; }
|
||||||
|
|
||||||
|
var status;
|
||||||
|
try { status = await S.getStatus(); }
|
||||||
|
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
|
||||||
|
|
||||||
|
if (!status.paid) {
|
||||||
|
showError('Cloud sync requires the paid tier.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.exists) {
|
||||||
|
// Already enabled — try a direct push using the cached session
|
||||||
|
// key. If no key is cached (fresh browser session), this throws,
|
||||||
|
// and we fall back to the enable-PIN modal so the user can
|
||||||
|
// re-enter their PIN.
|
||||||
|
try {
|
||||||
|
await S.pushSync(currentPie, null);
|
||||||
|
showSuccess('▸ Imported and synced.',
|
||||||
|
'Encrypted copy updated on the server.');
|
||||||
|
currentPie = null;
|
||||||
|
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to modal so the user can re-auth with their PIN.
|
||||||
|
console.warn('direct push failed, falling back to PIN modal', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// !status.exists OR cached-key push failed → use the modal.
|
||||||
|
if (window.cassandraOpenSyncModal) {
|
||||||
|
window.cassandraOpenSyncModal({
|
||||||
|
onSuccess: function () {
|
||||||
|
showSuccess('▸ Imported and synced.',
|
||||||
|
'Cloud sync is now enabled and the pie is stored encrypted.');
|
||||||
|
currentPie = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showError('Cloud sync UI unavailable on this page. ' +
|
||||||
|
'Use the Cloud sync section below to enable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUploader() {
|
||||||
|
currentPie = null;
|
||||||
|
previewEl.hidden = true;
|
||||||
|
previewEl.innerHTML = '';
|
||||||
|
resultEl.hidden = true;
|
||||||
|
filenameEl.textContent = '';
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFile(file) {
|
||||||
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
|
||||||
|
previewEl.hidden = true;
|
||||||
|
resultEl.hidden = true;
|
||||||
|
try {
|
||||||
|
var pie = await P.parseCsv(file);
|
||||||
|
renderPreview(pie);
|
||||||
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||||
|
} catch (e) {
|
||||||
|
filenameEl.textContent = file.name + ' (failed)';
|
||||||
|
showError(e.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
if (fileInput.files[0]) parseFile(fileInput.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function (ev) {
|
||||||
|
dropZone.addEventListener(ev, function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
dropZone.classList.add('dz--over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function (ev) {
|
||||||
|
dropZone.addEventListener(ev, function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
dropZone.classList.remove('dz--over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', function (e) {
|
||||||
|
var f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
|
if (f) parseFile(f);
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('click', function (e) {
|
||||||
|
if (e.target.tagName !== 'A') fileInput.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
154
app/static/js/settings-sync.js
Normal file
154
app/static/js/settings-sync.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
||||||
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (!window.CassandraSync) return;
|
||||||
|
|
||||||
|
const statusEl = $('sync-status');
|
||||||
|
const actionsEl = $('sync-actions');
|
||||||
|
const feedbackEl = $('sync-feedback');
|
||||||
|
const modal = $('sync-modal');
|
||||||
|
const pin1 = $('sync-pin1');
|
||||||
|
const pin2 = $('sync-pin2');
|
||||||
|
const ack = $('sync-ack');
|
||||||
|
const errEl = $('sync-modal-err');
|
||||||
|
|
||||||
|
function setFeedback(msg, ok) {
|
||||||
|
feedbackEl.style.color = ok ? 'var(--positive)' : '';
|
||||||
|
feedbackEl.textContent = msg || '';
|
||||||
|
}
|
||||||
|
// External callers (the Import section above) can pass a callback
|
||||||
|
// that fires after a successful enable-and-push.
|
||||||
|
let pendingOnSuccess = null;
|
||||||
|
function openModal(opts) {
|
||||||
|
pendingOnSuccess = (opts && opts.onSuccess) || null;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
// Focus PIN field after the layout flush so the caret lands.
|
||||||
|
setTimeout(() => pin1.focus(), 0);
|
||||||
|
}
|
||||||
|
function closeModal() {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
pin1.value = ''; pin2.value = '';
|
||||||
|
ack.checked = false; errEl.hidden = true;
|
||||||
|
pendingOnSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('sync-modal-cancel').addEventListener('click', closeModal);
|
||||||
|
// Backdrop click + Esc key dismiss the modal.
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
|
if (e.target === modal) closeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('sync-modal-form').addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errEl.hidden = true;
|
||||||
|
if (pin1.value !== pin2.value) {
|
||||||
|
errEl.textContent = 'PINs do not match.';
|
||||||
|
errEl.hidden = false; return;
|
||||||
|
}
|
||||||
|
if (pin1.value.length < 4) {
|
||||||
|
errEl.textContent = 'PIN must be at least 4 characters.';
|
||||||
|
errEl.hidden = false; return;
|
||||||
|
}
|
||||||
|
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
|
||||||
|
if (!pie) {
|
||||||
|
errEl.textContent =
|
||||||
|
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
|
||||||
|
errEl.hidden = false; return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await window.CassandraSync.pushSync(pie, pin1.value);
|
||||||
|
const cb = pendingOnSuccess;
|
||||||
|
closeModal(); // clears pendingOnSuccess
|
||||||
|
await refresh();
|
||||||
|
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
|
||||||
|
}
|
||||||
|
} catch (e2) {
|
||||||
|
errEl.textContent = e2.message || 'Failed to enable sync.';
|
||||||
|
errEl.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
let status;
|
||||||
|
try { status = await window.CassandraSync.getStatus(); }
|
||||||
|
catch (e) {
|
||||||
|
statusEl.querySelector('.settings-row__value').innerHTML =
|
||||||
|
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valueEl = statusEl.querySelector('.settings-row__value');
|
||||||
|
actionsEl.innerHTML = '';
|
||||||
|
if (status.exists && status.orphaned) {
|
||||||
|
// The stored blob can no longer be decrypted (server key rotated
|
||||||
|
// since it was written). The data is permanently unrecoverable,
|
||||||
|
// so silently clean up the dead row and re-render in the
|
||||||
|
// standard "off" state — leaving a soft one-liner so the user
|
||||||
|
// knows why they need to re-import.
|
||||||
|
try { await window.CassandraSync.disableSync(); }
|
||||||
|
catch (e) { console.warn('auto-clear stale sync failed', e); }
|
||||||
|
setFeedback('Your previous cloud backup couldn’t be restored. Re-import your portfolio to enable cloud sync again.', true);
|
||||||
|
await refresh();
|
||||||
|
return;
|
||||||
|
} else if (status.exists) {
|
||||||
|
const when = status.updated_at
|
||||||
|
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
||||||
|
: '—';
|
||||||
|
valueEl.innerHTML =
|
||||||
|
'<span class="badge badge--ok">On</span> ' +
|
||||||
|
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
|
||||||
|
|
||||||
|
const disable = document.createElement('button');
|
||||||
|
disable.type = 'button';
|
||||||
|
disable.className = 'pf-secondary';
|
||||||
|
disable.textContent = 'Disable sync';
|
||||||
|
disable.addEventListener('click', async function () {
|
||||||
|
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
|
||||||
|
try {
|
||||||
|
await window.CassandraSync.disableSync();
|
||||||
|
await refresh();
|
||||||
|
setFeedback('Cloud sync disabled. Server copy removed.', true);
|
||||||
|
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(disable);
|
||||||
|
} else {
|
||||||
|
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
|
||||||
|
// Only offer 'Enable' when there's actually a pie to encrypt;
|
||||||
|
// otherwise the user would hit a dead-end at the modal.
|
||||||
|
const hasPie = !!localStorage.getItem('cassandra.pie');
|
||||||
|
if (!hasPie) {
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'settings-row__hint';
|
||||||
|
hint.innerHTML =
|
||||||
|
'Nothing to sync yet — ' +
|
||||||
|
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
|
||||||
|
actionsEl.appendChild(hint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enable = document.createElement('button');
|
||||||
|
enable.type = 'button';
|
||||||
|
enable.textContent = 'Enable cloud sync';
|
||||||
|
enable.addEventListener('click', openModal);
|
||||||
|
actionsEl.appendChild(enable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks for the Import section to drive this modal + status row.
|
||||||
|
window.cassandraOpenSyncModal = openModal;
|
||||||
|
window.cassandraRefreshSyncStatus = refresh;
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -36,8 +36,16 @@
|
||||||
} catch (e) { document.documentElement.dataset.theme = 'light'; }
|
} catch (e) { document.documentElement.dataset.theme = 'light'; }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/panels.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/dashboard.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/portfolio.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/log-chat.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/news.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<script src="{{ url_for('static', path='/js/htmx.min.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
<script>
|
<script>
|
||||||
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
|
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
|
||||||
// HTMX request so AI-generated panels resolve to the right cached
|
// HTMX request so AI-generated panels resolve to the right cached
|
||||||
|
|
@ -118,12 +126,34 @@
|
||||||
// Reflect the saved value in the toggle on load.
|
// Reflect the saved value in the toggle on load.
|
||||||
var pill = document.getElementById('tone-toggle');
|
var pill = document.getElementById('tone-toggle');
|
||||||
if (pill) pill.dataset.tone = currentTone();
|
if (pill) pill.dataset.tone = currentTone();
|
||||||
|
// Same for the theme toggle — pull the current theme that the
|
||||||
|
// top-of-page inline script already wrote to <html data-theme>.
|
||||||
|
var themePill = document.getElementById('theme-toggle');
|
||||||
|
if (themePill) themePill.dataset.theme = document.documentElement.dataset.theme || 'light';
|
||||||
|
// Sync the /log page's tone badge to the saved tone — server-side
|
||||||
|
// first render defaults to "pro", but a returning NOVICE user
|
||||||
|
// should see "novice" before any toggle interaction.
|
||||||
|
window.cassandraSyncToneBadge(currentTone());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync the optional #tone-badge (currently used on the /log page) to
|
||||||
|
// the supplied tone. NOVICE renders as "novice"; INTERMEDIATE renders
|
||||||
|
// as "pro" — matches the header toggle's display labels. Safe to call
|
||||||
|
// on pages that don't render the badge.
|
||||||
|
window.cassandraSyncToneBadge = function (tone) {
|
||||||
|
var badge = document.getElementById('tone-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
var label = (tone === 'NOVICE') ? 'novice' : 'pro';
|
||||||
|
badge.className = 'badge badge--tone-' + label;
|
||||||
|
var span = badge.querySelector('[data-tone-label]');
|
||||||
|
if (span) span.textContent = label;
|
||||||
|
};
|
||||||
|
|
||||||
window.cassandraSetTone = function (newTone) {
|
window.cassandraSetTone = function (newTone) {
|
||||||
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
|
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
|
||||||
var pill = document.getElementById('tone-toggle');
|
var pill = document.getElementById('tone-toggle');
|
||||||
if (pill) pill.dataset.tone = newTone;
|
if (pill) pill.dataset.tone = newTone;
|
||||||
|
window.cassandraSyncToneBadge(newTone);
|
||||||
// Trigger a re-fetch of every AI-driven HTMX target on the page.
|
// Trigger a re-fetch of every AI-driven HTMX target on the page.
|
||||||
// Easiest: dispatch a custom event that the relevant elements
|
// Easiest: dispatch a custom event that the relevant elements
|
||||||
// listen to. Simpler still: fire htmx.trigger on the well-known
|
// listen to. Simpler still: fire htmx.trigger on the well-known
|
||||||
|
|
@ -134,6 +164,78 @@
|
||||||
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
|
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.cassandraSetTheme = function (newTheme) {
|
||||||
|
document.documentElement.dataset.theme = newTheme;
|
||||||
|
var pill = document.getElementById('theme-toggle');
|
||||||
|
if (pill) pill.dataset.theme = newTheme;
|
||||||
|
try { localStorage.setItem('cassandra.theme', newTheme); } catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static-label i18n dictionary. AI-generated content is re-fetched via
|
||||||
|
// HTMX (server-side translation), but plain UI labels are baked into
|
||||||
|
// the HTML at render time. This dict + applyI18n() below let the
|
||||||
|
// language toggle swap labels live without a page refresh.
|
||||||
|
// Convention: data-i18n="key" sets textContent;
|
||||||
|
// data-i18n-placeholder="key" sets .placeholder.
|
||||||
|
// First-render correctness is handled by the template's user_lang
|
||||||
|
// conditional, so applyI18n only kicks in on subsequent toggles.
|
||||||
|
window.CASSANDRA_I18N = {
|
||||||
|
'chat.title': { en: 'Ask Cassandra',
|
||||||
|
it: 'Chiedi a Cassandra' },
|
||||||
|
'chat.hint': { en: 'grounded on the latest log + live data',
|
||||||
|
it: "basato sull'ultimo log + dati in tempo reale" },
|
||||||
|
'chat.lede': { en: "Ask about today's analysis. The model sees the latest strategic log, live market readings across all groups, and the last 24h of thesis-filtered headlines. Refresh wipes this conversation.",
|
||||||
|
it: "Fai domande sull'analisi di oggi. Il modello vede l'ultimo log strategico, le quotazioni di mercato in tempo reale per tutti i gruppi e le ultime 24h di titoli filtrati per tesi. Un refresh della pagina cancella questa conversazione." },
|
||||||
|
'chat.placeholder': { en: 'e.g. why is the defence sleeve flat through Hormuz?',
|
||||||
|
it: 'es. perché il comparto difesa è piatto nonostante Hormuz?' },
|
||||||
|
'chat.send': { en: 'Send',
|
||||||
|
it: 'Invia' },
|
||||||
|
};
|
||||||
|
window.cassandraApplyI18n = function (lang) {
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||||
|
var key = el.getAttribute('data-i18n');
|
||||||
|
var entry = window.CASSANDRA_I18N[key];
|
||||||
|
if (entry && entry[lang] != null) el.textContent = entry[lang];
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
||||||
|
var key = el.getAttribute('data-i18n-placeholder');
|
||||||
|
var entry = window.CASSANDRA_I18N[key];
|
||||||
|
if (entry && entry[lang] != null) el.placeholder = entry[lang];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cassandraSetLang = async function (newLang) {
|
||||||
|
var pill = document.getElementById('lang-toggle');
|
||||||
|
if (!pill) return;
|
||||||
|
var prev = pill.dataset.lang;
|
||||||
|
if (prev === newLang) return;
|
||||||
|
// Optimistic update — flip the pill immediately so the click feels
|
||||||
|
// responsive. Revert on PATCH failure.
|
||||||
|
pill.dataset.lang = newLang;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/settings/language', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({lang: newLang}),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
// Swap any static UI labels that have i18n bindings.
|
||||||
|
window.cassandraApplyI18n(newLang);
|
||||||
|
// Trigger HTMX-driven panels to re-fetch in the new language.
|
||||||
|
// Same shape as cassandraSetTone — every panel that listens to
|
||||||
|
// tone-changed also listens to lang-changed.
|
||||||
|
['#dash-header-container', '#log-panel .panel-body',
|
||||||
|
'#indicators-body', '#log-content'].forEach(function (sel) {
|
||||||
|
var el = document.querySelector(sel);
|
||||||
|
if (el && window.htmx) window.htmx.trigger(el, 'lang-changed');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
pill.dataset.lang = prev;
|
||||||
|
console.warn('language switch failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Render any <time datetime="..."> in the browser's local timezone.
|
// Render any <time datetime="..."> in the browser's local timezone.
|
||||||
|
|
@ -160,26 +262,61 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
|
{# Left group keeps brand + BETA chip pinned together as a single
|
||||||
|
layout cell so the chip can't drift away from the wordmark when
|
||||||
|
the header grows or shrinks. #}
|
||||||
|
<div class="header-left">
|
||||||
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
|
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
|
||||||
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome at hello@read.markets">BETA</span>{% endif %}
|
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome at hello@read.markets">BETA</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Mobile hamburger — shown only at ≤480px via CSS. #}
|
||||||
|
<button type="button" id="drawer-toggle" class="drawer-toggle"
|
||||||
|
aria-label="Open menu" aria-controls="mobile-drawer" aria-expanded="false">
|
||||||
|
<span class="drawer-toggle__bar"></span>
|
||||||
|
<span class="drawer-toggle__bar"></span>
|
||||||
|
<span class="drawer-toggle__bar"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Wrapper: display:contents on desktop (zero layout effect), fixed
|
||||||
|
slide-out panel on mobile. Holds nav + header-right widgets. #}
|
||||||
|
<div id="mobile-drawer" class="mobile-drawer">
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||||
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
||||||
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
{# The "Pro" label maps to the INTERMEDIATE tone server-side —
|
||||||
|
kept that way to avoid touching every stored user preference
|
||||||
|
and API contract. The mode itself (terse, no glossary
|
||||||
|
tooltips, assumes fluency) is unchanged; only the display
|
||||||
|
label changes. #}
|
||||||
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
||||||
role="group" aria-label="Explanation level">
|
role="group" aria-label="Explanation level">
|
||||||
<button type="button" data-value="NOVICE"
|
<button type="button" data-value="NOVICE"
|
||||||
onclick="cassandraSetTone('NOVICE')">Novice</button>
|
onclick="cassandraSetTone('NOVICE')">Novice</button>
|
||||||
<button type="button" data-value="INTERMEDIATE"
|
<button type="button" data-value="INTERMEDIATE"
|
||||||
onclick="cassandraSetTone('INTERMEDIATE')">Intermediate</button>
|
onclick="cassandraSetTone('INTERMEDIATE')">Pro</button>
|
||||||
|
</div>
|
||||||
|
<div id="theme-toggle" class="theme-toggle" data-theme="light"
|
||||||
|
role="group" aria-label="Theme">
|
||||||
|
<button type="button" data-value="light"
|
||||||
|
onclick="cassandraSetTheme('light')">Light</button>
|
||||||
|
<button type="button" data-value="dark"
|
||||||
|
onclick="cassandraSetTheme('dark')">Dark</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="theme-toggle" type="button" aria-label="Toggle theme"
|
|
||||||
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
|
||||||
<span class="theme-toggle__label"></span>
|
|
||||||
</button>
|
|
||||||
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
||||||
|
{% if cu and cu.user %}
|
||||||
|
<div id="lang-toggle" class="lang-toggle" data-lang="{{ cu.user.lang or 'en' }}"
|
||||||
|
role="group" aria-label="AI output language"
|
||||||
|
title="Language the AI uses for the log, digest and portfolio commentary">
|
||||||
|
<button type="button" data-value="en"
|
||||||
|
onclick="cassandraSetLang('en')">EN</button>
|
||||||
|
<button type="button" data-value="it"
|
||||||
|
onclick="cassandraSetLang('it')">IT</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if cu and (cu.user or cu.is_admin) %}
|
{% if cu and (cu.user or cu.is_admin) %}
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button type="button" id="user-menu-toggle" class="user-chip"
|
<button type="button" id="user-menu-toggle" class="user-chip"
|
||||||
|
|
@ -201,8 +338,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="meta">v0.1 · UTC</span>
|
<span class="meta">v0.1 · UTC</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{# Drawer backdrop. Hidden by default; CSS shows it when
|
||||||
|
body.drawer-open is set. Click closes the drawer. #}
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var btn = document.getElementById('user-menu-toggle');
|
var btn = document.getElementById('user-menu-toggle');
|
||||||
|
|
@ -223,6 +365,56 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mobile drawer (hamburger → right-side slide-out panel). The CSS
|
||||||
|
// gates visibility of #drawer-toggle to ≤480px, so on desktop this
|
||||||
|
// wiring is harmless — the click handler is attached but nobody
|
||||||
|
// can fire it.
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('drawer-toggle');
|
||||||
|
var drawer = document.getElementById('mobile-drawer');
|
||||||
|
var backdrop = document.getElementById('drawer-backdrop');
|
||||||
|
if (!btn || !drawer || !backdrop) return;
|
||||||
|
function open() {
|
||||||
|
document.body.classList.add('drawer-open');
|
||||||
|
backdrop.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
document.body.classList.remove('drawer-open');
|
||||||
|
backdrop.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (document.body.classList.contains('drawer-open')) close(); else open();
|
||||||
|
});
|
||||||
|
backdrop.addEventListener('click', close);
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && document.body.classList.contains('drawer-open')) close();
|
||||||
|
});
|
||||||
|
// Swipe-right inside the drawer closes — small native feel.
|
||||||
|
// Tracks pointer down then up; if the user moves >40px right
|
||||||
|
// and stays within 60° of horizontal, treat as a close gesture.
|
||||||
|
var startX = null, startY = null;
|
||||||
|
drawer.addEventListener('pointerdown', function (e) {
|
||||||
|
if (!document.body.classList.contains('drawer-open')) return;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
});
|
||||||
|
drawer.addEventListener('pointerup', function (e) {
|
||||||
|
if (startX === null) return;
|
||||||
|
var dx = e.clientX - startX;
|
||||||
|
var dy = Math.abs(e.clientY - startY);
|
||||||
|
startX = startY = null;
|
||||||
|
if (dx > 40 && dy < dx * 0.6) close();
|
||||||
|
});
|
||||||
|
// If a nav link inside the drawer is clicked, close after the
|
||||||
|
// navigation kicks off so the panel doesn't linger on the next page.
|
||||||
|
drawer.addEventListener('click', function (e) {
|
||||||
|
if (e.target.tagName === 'A') close();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
{% block main %}{% endblock %}
|
{% block main %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div id="dash-header-container"
|
<div id="dash-header-container"
|
||||||
style="grid-column: 1 / -1;"
|
style="grid-column: 1 / -1;"
|
||||||
hx-get="/api/summary/aggregate?as=html"
|
hx-get="/api/summary/aggregate?as=html"
|
||||||
hx-trigger="load, every 300s, tone-changed"
|
hx-trigger="load, every 300s, tone-changed, lang-changed"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="empty">loading aggregate read…</div>
|
<div class="empty">loading aggregate read…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<div id="indicators-body"
|
<div id="indicators-body"
|
||||||
class="panel-body panel-body--scroll"
|
class="panel-body panel-body--scroll"
|
||||||
hx-get="/api/indicators/{{ groups[0] }}?as=html"
|
hx-get="/api/indicators/{{ groups[0] }}?as=html"
|
||||||
hx-trigger="load, tone-changed"
|
hx-trigger="load, tone-changed, lang-changed"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="empty">loading…</div>
|
<div class="empty">loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,9 +102,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}" defer></script>
|
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
|
|
||||||
<section id="log-panel" class="panel">
|
<section id="log-panel" class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body"
|
<div class="panel-body"
|
||||||
hx-get="/api/log/latest?as=html"
|
hx-get="/api/log/latest?as=html"
|
||||||
hx-trigger="load, every 300s, tone-changed"
|
hx-trigger="load, every 300s, tone-changed, lang-changed"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="empty">awaiting first log…</div>
|
<div class="empty">awaiting first log…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@
|
||||||
|
|
||||||
<section class="shot-hero">
|
<section class="shot-hero">
|
||||||
<button class="shot shot--hero"
|
<button class="shot shot--hero"
|
||||||
data-full="{{ url_for('static', path='/images/dashboard.png') }}"
|
data-full="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
|
||||||
data-alt="Read the Markets dashboard"
|
data-alt="Read the Markets dashboard"
|
||||||
data-caption="The dashboard. An aggregate cross-asset read at the top, hand-picked indicator groups underneath. Reading level toggle (Novice / Intermediate) flips every AI-generated panel between plain-English and terse-pro framing.">
|
data-caption="The dashboard. An aggregate cross-asset read at the top, hand-picked indicator groups underneath. Reading level toggle (Novice / Intermediate) flips every AI-generated panel between plain-English and terse-pro framing.">
|
||||||
<img src="{{ url_for('static', path='/images/dashboard.png') }}"
|
<img src="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
|
||||||
alt="Dashboard preview" loading="lazy">
|
alt="Dashboard preview" loading="lazy">
|
||||||
<span class="shot__zoom" aria-hidden="true">Click to enlarge</span>
|
<span class="shot__zoom" aria-hidden="true">Click to enlarge</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -48,10 +48,10 @@
|
||||||
off-hours stay quiet.
|
off-hours stay quiet.
|
||||||
</p>
|
</p>
|
||||||
<button class="shot feature-card__shot"
|
<button class="shot feature-card__shot"
|
||||||
data-full="{{ url_for('static', path='/images/news-feed.png') }}"
|
data-full="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
|
||||||
data-alt="News feed with auto-tagged headlines"
|
data-alt="News feed with auto-tagged headlines"
|
||||||
data-caption="The news feed. Each headline carries one or more theme tags (rates, AI, energy, geopolitics, …) so you can keep the threads you care about and mute the ones you don't. Click a tag to include; shift-click to exclude.">
|
data-caption="The news feed. Each headline carries one or more theme tags (rates, AI, energy, geopolitics, …) so you can keep the threads you care about and mute the ones you don't. Click a tag to include; shift-click to exclude.">
|
||||||
<img src="{{ url_for('static', path='/images/news-feed.png') }}"
|
<img src="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
|
||||||
alt="News feed thumbnail" loading="lazy">
|
alt="News feed thumbnail" loading="lazy">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,10 +66,10 @@
|
||||||
in earnings, policy, valuation — not chart patterns.
|
in earnings, policy, valuation — not chart patterns.
|
||||||
</p>
|
</p>
|
||||||
<button class="shot feature-card__shot"
|
<button class="shot feature-card__shot"
|
||||||
data-full="{{ url_for('static', path='/images/indicators-read.png') }}"
|
data-full="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
|
||||||
data-alt="Indicators panel with AI commentary"
|
data-alt="Indicators panel with AI commentary"
|
||||||
data-caption="The indicators panel. Tabs across asset classes (equity, rates, commodities, FX, bonds, …); each tab carries a one-paragraph 'read' written by the model on top of the live prices. The numbers anchor the prose so the commentary is checkable, not floating.">
|
data-caption="The indicators panel. Tabs across asset classes (equity, rates, commodities, FX, bonds, …); each tab carries a one-paragraph 'read' written by the model on top of the live prices. The numbers anchor the prose so the commentary is checkable, not floating.">
|
||||||
<img src="{{ url_for('static', path='/images/indicators-read.png') }}"
|
<img src="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
|
||||||
alt="Indicators panel thumbnail" loading="lazy">
|
alt="Indicators panel thumbnail" loading="lazy">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,10 +87,10 @@
|
||||||
not a forecast and not advice on any investment decision.
|
not a forecast and not advice on any investment decision.
|
||||||
</p>
|
</p>
|
||||||
<button class="shot feature-card__shot"
|
<button class="shot feature-card__shot"
|
||||||
data-full="{{ url_for('static', path='/images/strategic-log.png') }}"
|
data-full="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
|
||||||
data-alt="Strategic log — the editorial AI read"
|
data-alt="Strategic log — the editorial AI read"
|
||||||
data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
|
data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
|
||||||
<img src="{{ url_for('static', path='/images/strategic-log.png') }}"
|
<img src="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
|
||||||
alt="Strategic log thumbnail" loading="lazy">
|
alt="Strategic log thumbnail" loading="lazy">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,10 +100,10 @@
|
||||||
<h2 class="public-section__head">More views</h2>
|
<h2 class="public-section__head">More views</h2>
|
||||||
<div class="shots-grid">
|
<div class="shots-grid">
|
||||||
<button class="shot"
|
<button class="shot"
|
||||||
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}"
|
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
|
||||||
data-alt="Ask follow-up questions against any past log"
|
data-alt="Ask follow-up questions against any past log"
|
||||||
data-caption="Ask follow-up questions against any past log. The chat panel inherits the log's full context, so you can pull on a thread without re-pasting headlines or re-explaining the setup.">
|
data-caption="Ask follow-up questions against any past log. The chat panel inherits the log's full context, so you can pull on a thread without re-pasting headlines or re-explaining the setup.">
|
||||||
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}"
|
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
|
||||||
alt="Chat-with-log thumbnail" loading="lazy">
|
alt="Chat-with-log thumbnail" loading="lazy">
|
||||||
<div class="shot__caption">
|
<div class="shot__caption">
|
||||||
<strong>Ask anything about a log</strong>
|
<strong>Ask anything about a log</strong>
|
||||||
|
|
@ -116,10 +116,10 @@
|
||||||
<section class="public-section">
|
<section class="public-section">
|
||||||
<p style="font-size: 13.5px; color: var(--muted);">
|
<p style="font-size: 13.5px; color: var(--muted);">
|
||||||
Paid users can also drop a portfolio CSV from their broker
|
Paid users can also drop a portfolio CSV from their broker
|
||||||
(Trading 212 today, more brokers planned) for an AI sense-check on
|
— Trading 212 natively, other formats auto-detected —
|
||||||
concentration, regime fit, and currency exposure. Holdings stay in
|
for an AI sense-check on concentration, regime fit, and currency
|
||||||
your browser by default; opt in to encrypted cloud sync to restore
|
exposure. Holdings stay in your browser by default; opt in to
|
||||||
on another device.
|
encrypted cloud sync to restore on another device.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
selected {{ selected_iso }}
|
selected {{ selected_iso }}
|
||||||
·
|
·
|
||||||
<span class="meta__hint">new logs use:</span>
|
{# Tone badge mirrors the header toggle. base.html's DOMContentLoaded
|
||||||
<span class="badge badge--tone-{{ current_tone | lower }}">tone {{ current_tone | lower }}</span>
|
hook and cassandraSetTone() both update this element so the label
|
||||||
<span class="badge badge--analysis-{{ current_analysis | lower }}">analysis {{ current_analysis | lower }}</span>
|
stays in step with the user's choice — no need to re-render the
|
||||||
|
page when the toggle flips. #}
|
||||||
|
<span id="tone-badge" class="badge badge--tone-pro">tone <span data-tone-label>pro</span></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -25,7 +27,7 @@
|
||||||
<article id="log-content"
|
<article id="log-content"
|
||||||
class="log-page__content"
|
class="log-page__content"
|
||||||
hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
|
hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
|
||||||
hx-trigger="load, tone-changed"
|
hx-trigger="load, tone-changed, lang-changed"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="empty">loading log…</div>
|
<div class="empty">loading log…</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -33,21 +35,18 @@
|
||||||
{% if paid %}
|
{% if paid %}
|
||||||
<aside id="chat-sidebar" class="log-page__chat">
|
<aside id="chat-sidebar" class="log-page__chat">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<span class="chat-title">Ask Cassandra</span>
|
<span class="chat-title" data-i18n="chat.title">{% if user_lang == 'it' %}Chiedi a Cassandra{% else %}Ask Cassandra{% endif %}</span>
|
||||||
<span class="chat-hint">grounded on the latest log + live data</span>
|
<span class="chat-hint" data-i18n="chat.hint">{% if user_lang == 'it' %}basato sull'ultimo log + dati in tempo reale{% else %}grounded on the latest log + live data{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="chat-thread" class="chat-thread">
|
<div id="chat-thread" class="chat-thread">
|
||||||
<div class="chat-msg chat-msg--system">
|
<div class="chat-msg chat-msg--system" data-i18n="chat.lede">{% if user_lang == 'it' %}Fai domande sull'analisi di oggi. Il modello vede l'ultimo log strategico, le quotazioni di mercato in tempo reale per tutti i gruppi e le ultime 24h di titoli filtrati per tesi. Un refresh della pagina cancella questa conversazione.{% else %}Ask about today's analysis. The model sees the latest strategic log, live market readings across all groups, and the last 24h of thesis-filtered headlines. Refresh wipes this conversation.{% endif %}</div>
|
||||||
Ask about today's analysis. The model sees the latest strategic log,
|
|
||||||
live market readings across all groups, and the last 24h of
|
|
||||||
thesis-filtered headlines. Refresh wipes this conversation.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="chat-form" class="chat-form" autocomplete="off">
|
<form id="chat-form" class="chat-form" autocomplete="off">
|
||||||
<textarea id="chat-input" rows="2"
|
<textarea id="chat-input" rows="2"
|
||||||
placeholder="e.g. why is the defence sleeve flat through Hormuz?"
|
data-i18n-placeholder="chat.placeholder"
|
||||||
|
placeholder="{% if user_lang == 'it' %}es. perché il comparto difesa è piatto nonostante Hormuz?{% else %}e.g. why is the defence sleeve flat through Hormuz?{% endif %}"
|
||||||
required></textarea>
|
required></textarea>
|
||||||
<button id="chat-send" type="submit">Send</button>
|
<button id="chat-send" type="submit" data-i18n="chat.send">{% if user_lang == 'it' %}Invia{% else %}Send{% endif %}</button>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -69,5 +68,5 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% if paid %}<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>{% endif %}
|
{% if paid %}<script src="{{ url_for('static', path='/js/chat.js') }}?v={{ ASSET_VERSION }}" defer></script>{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
catch (e) { document.documentElement.dataset.theme = 'light'; }
|
catch (e) { document.documentElement.dataset.theme = 'light'; }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-shell">
|
<div class="auth-shell">
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@
|
||||||
<table class="dense">
|
<table class="dense">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Symbol</th><th>Label</th>
|
<th>Symbol</th><th class="mobile-hide">Label</th>
|
||||||
<th class="num">Price</th><th>Ccy</th>
|
<th class="num">Price</th><th class="mobile-hide">Ccy</th>
|
||||||
<th class="num">1d</th><th class="num">1m</th><th class="num">1y</th>
|
<th class="num">1d</th><th class="num">1m</th><th class="num mobile-hide">1y</th>
|
||||||
{% if has_anchor %}<th class="num">anchor</th>{% endif %}
|
{% if has_anchor %}<th class="num mobile-hide">anchor</th>{% endif %}
|
||||||
<th>as-of</th>
|
<th class="mobile-hide">as-of</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -46,22 +46,22 @@
|
||||||
<td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}">
|
<td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}">
|
||||||
{{ short_sym }}
|
{{ short_sym }}
|
||||||
</td>
|
</td>
|
||||||
<td {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
|
<td class="mobile-hide" {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
|
||||||
<td class="num">{{ q.price | price }}</td>
|
<td class="num">{{ q.price | price }}</td>
|
||||||
<td class="neu">{{ q.currency or "" }}</td>
|
<td class="neu mobile-hide">{{ q.currency or "" }}</td>
|
||||||
{% for k in ["1d","1m","1y"] %}
|
{% for k in ["1d","1m","1y"] %}
|
||||||
{% set v = q.changes.get(k) if q.changes else None %}
|
{% set v = q.changes.get(k) if q.changes else None %}
|
||||||
<td class="num {% if v is none %}neu{% elif v >= 0 %}pos{% else %}neg{% endif %}">
|
<td class="num {% if k == '1y' %}mobile-hide {% endif %}{% if v is none %}neu{% elif v >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
{% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %}
|
{% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if has_anchor %}
|
{% if has_anchor %}
|
||||||
{% set va = q.changes.get('anchor') if q.changes else None %}
|
{% set va = q.changes.get('anchor') if q.changes else None %}
|
||||||
<td class="num {% if va is none %}neu{% elif va >= 0 %}pos{% else %}neg{% endif %}">
|
<td class="num mobile-hide {% if va is none %}neu{% elif va >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
{% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %}
|
{% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td class="neu">{{ q.as_of or "" }}</td>
|
<td class="neu mobile-hide">{{ q.as_of or "" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ticker</th>
|
<th>Ticker</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th class="num">Qty</th>
|
<th class="num mobile-hide">Qty</th>
|
||||||
<th class="num">Avg</th>
|
<th class="num mobile-hide">Avg</th>
|
||||||
<th class="num">Last</th>
|
<th class="num">Last</th>
|
||||||
<th class="num">P/L</th>
|
<th class="num">P/L</th>
|
||||||
<th class="num">%</th>
|
<th class="num">%</th>
|
||||||
|
|
@ -63,8 +63,8 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td class="label">{{ pos.ticker }}</td>
|
<td class="label">{{ pos.ticker }}</td>
|
||||||
<td>{{ pos.name or "" }}</td>
|
<td>{{ pos.name or "" }}</td>
|
||||||
<td class="num">{{ pos.quantity | price }}</td>
|
<td class="num mobile-hide">{{ pos.quantity | price }}</td>
|
||||||
<td class="num neu">{{ pos.average_price | price }}</td>
|
<td class="num neu mobile-hide">{{ pos.average_price | price }}</td>
|
||||||
<td class="num">{{ pos.current_price | price }}</td>
|
<td class="num">{{ pos.current_price | price }}</td>
|
||||||
<td class="num {% if pos.ppl is none %}neu{% elif pos.ppl >= 0 %}pos{% else %}neg{% endif %}">
|
<td class="num {% if pos.ppl is none %}neu{% elif pos.ppl >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
{{ pos.ppl | signed }}
|
{{ pos.ppl | signed }}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
<li><strong>Strategic log refreshed every hour</strong> instead of every six — track intraday moves as they unfold</li>
|
<li><strong>Strategic log refreshed every hour</strong> instead of every six — track intraday moves as they unfold</li>
|
||||||
<li><strong>Follow-up chat on any past log</strong> — ask the model a question against the day’s full context</li>
|
<li><strong>Follow-up chat on any past log</strong> — ask the model a question against the day’s full context</li>
|
||||||
<li><strong>Daily email digest</strong> (Mon–Sat) — ~600-word read of the session ahead, on top of the Sunday recap</li>
|
<li><strong>Daily email digest</strong> (Mon–Sat) — ~600-word read of the session ahead, on top of the Sunday recap</li>
|
||||||
<li><strong>Portfolio import</strong> from a broker CSV (Trading 212 supported today; more brokers planned)</li>
|
<li><strong>Portfolio import</strong> from any broker CSV — Trading 212 natively, other formats auto-detected</li>
|
||||||
<li><strong>AI portfolio read</strong> — diversification, sector and currency concentration, macro-regime fit on your holdings</li>
|
<li><strong>AI portfolio read</strong> — diversification, sector and currency concentration, macro-regime fit on your holdings</li>
|
||||||
<li><strong>Optional encrypted cloud sync</strong> — PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
|
<li><strong>Optional encrypted cloud sync</strong> — PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@
|
||||||
} catch (e) { document.documentElement.dataset.theme = 'light'; }
|
} catch (e) { document.documentElement.dataset.theme = 'light'; }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/panels.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/public.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
</head>
|
</head>
|
||||||
<body class="public-page">
|
<body class="public-page">
|
||||||
<div class="public-shell">
|
<div class="public-shell">
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
<span class="neu">Investing → Your Pie → ··· → Export</span>.</span>
|
<span class="neu">Investing → Your Pie → ··· → Export</span>.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="drop-zone" class="dz">
|
<div id="drop-zone" class="dz" data-paid="{{ 'true' if paid and paid.active else 'false' }}">
|
||||||
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
||||||
<div class="dz__icon">▱</div>
|
<div class="dz__icon">▱</div>
|
||||||
<div class="dz__label">Drop your broker's portfolio CSV here</div>
|
<div class="dz__label">Drop your broker's portfolio CSV here</div>
|
||||||
|
|
@ -187,7 +187,7 @@
|
||||||
<label><input type="radio" name="digest-tone" value="NOVICE"
|
<label><input type="radio" name="digest-tone" value="NOVICE"
|
||||||
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Novice</label>
|
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Novice</label>
|
||||||
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
|
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
|
||||||
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Intermediate</label>
|
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Pro</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -224,6 +224,47 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{# --- Language block ------------------------------------------------ #}
|
||||||
|
<details class="settings-section">
|
||||||
|
<summary class="settings-section__head">Language</summary>
|
||||||
|
<p class="settings-section__lede">
|
||||||
|
Language the AI uses for the strategic log, your daily digest, and
|
||||||
|
portfolio commentary. The interface itself stays in English for now.
|
||||||
|
</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<select id="lang-select" class="settings-select">
|
||||||
|
<option value="en" {% if (user.lang or 'en') == 'en' %}selected{% endif %}>English</option>
|
||||||
|
<option value="it" {% if (user.lang or 'en') == 'it' %}selected{% endif %}>Italiano</option>
|
||||||
|
<option value="es" disabled>Español · coming soon</option>
|
||||||
|
<option value="fr" disabled>Français · coming soon</option>
|
||||||
|
<option value="de" disabled>Deutsch · coming soon</option>
|
||||||
|
</select>
|
||||||
|
<span id="lang-status" class="settings-status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var sel = document.getElementById('lang-select');
|
||||||
|
var status = document.getElementById('lang-status');
|
||||||
|
if (!sel) return;
|
||||||
|
sel.addEventListener('change', async function () {
|
||||||
|
status.textContent = 'saving…';
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/settings/language', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({lang: sel.value}),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
status.textContent = '✓ saved';
|
||||||
|
setTimeout(function () { status.textContent = ''; }, 1500);
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = '✗ failed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</details>
|
||||||
|
|
||||||
{# --- Cloud sync block --------------------------------------------- #}
|
{# --- Cloud sync block --------------------------------------------- #}
|
||||||
<details class="settings-section">
|
<details class="settings-section">
|
||||||
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
|
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
|
||||||
|
|
@ -260,7 +301,7 @@
|
||||||
<div id="sync-modal" class="modal"
|
<div id="sync-modal" class="modal"
|
||||||
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
|
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
|
||||||
display:none;align-items:center;justify-content:center;z-index:1000;">
|
display:none;align-items:center;justify-content:center;z-index:1000;">
|
||||||
<div style="background:var(--panel-bg,#fff);color:var(--text,#000);
|
<div style="background:var(--surface);color:var(--text);
|
||||||
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
|
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
|
||||||
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
|
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
|
||||||
Enable cloud sync
|
Enable cloud sync
|
||||||
|
|
@ -291,161 +332,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
<script src="{{ url_for(‘static’, path=’/js/portfolio-sync.js’) }}" defer></script>
|
||||||
<script>
|
<script src="{{ url_for(‘static’, path=’/js/settings-sync.js’) }}" defer></script>
|
||||||
(function () {
|
|
||||||
function $(id) { return document.getElementById(id); }
|
|
||||||
function esc(s) {
|
|
||||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
|
||||||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
||||||
}[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
if (!window.CassandraSync) return;
|
|
||||||
|
|
||||||
const statusEl = $('sync-status');
|
|
||||||
const actionsEl = $('sync-actions');
|
|
||||||
const feedbackEl = $('sync-feedback');
|
|
||||||
const modal = $('sync-modal');
|
|
||||||
const pin1 = $('sync-pin1');
|
|
||||||
const pin2 = $('sync-pin2');
|
|
||||||
const ack = $('sync-ack');
|
|
||||||
const errEl = $('sync-modal-err');
|
|
||||||
|
|
||||||
function setFeedback(msg, ok) {
|
|
||||||
feedbackEl.style.color = ok ? 'var(--ok,#2a9d57)' : '';
|
|
||||||
feedbackEl.textContent = msg || '';
|
|
||||||
}
|
|
||||||
// External callers (the Import section above) can pass a callback
|
|
||||||
// that fires after a successful enable-and-push.
|
|
||||||
let pendingOnSuccess = null;
|
|
||||||
function openModal(opts) {
|
|
||||||
pendingOnSuccess = (opts && opts.onSuccess) || null;
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
// Focus PIN field after the layout flush so the caret lands.
|
|
||||||
setTimeout(() => pin1.focus(), 0);
|
|
||||||
}
|
|
||||||
function closeModal() {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
pin1.value = ''; pin2.value = '';
|
|
||||||
ack.checked = false; errEl.hidden = true;
|
|
||||||
pendingOnSuccess = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$('sync-modal-cancel').addEventListener('click', closeModal);
|
|
||||||
// Backdrop click + Esc key dismiss the modal.
|
|
||||||
modal.addEventListener('click', function (e) {
|
|
||||||
if (e.target === modal) closeModal();
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('sync-modal-form').addEventListener('submit', async function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
errEl.hidden = true;
|
|
||||||
if (pin1.value !== pin2.value) {
|
|
||||||
errEl.textContent = 'PINs do not match.';
|
|
||||||
errEl.hidden = false; return;
|
|
||||||
}
|
|
||||||
if (pin1.value.length < 4) {
|
|
||||||
errEl.textContent = 'PIN must be at least 4 characters.';
|
|
||||||
errEl.hidden = false; return;
|
|
||||||
}
|
|
||||||
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
|
|
||||||
if (!pie) {
|
|
||||||
errEl.textContent =
|
|
||||||
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
|
|
||||||
errEl.hidden = false; return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await window.CassandraSync.pushSync(pie, pin1.value);
|
|
||||||
const cb = pendingOnSuccess;
|
|
||||||
closeModal(); // clears pendingOnSuccess
|
|
||||||
await refresh();
|
|
||||||
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
|
|
||||||
if (typeof cb === 'function') {
|
|
||||||
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
|
|
||||||
}
|
|
||||||
} catch (e2) {
|
|
||||||
errEl.textContent = e2.message || 'Failed to enable sync.';
|
|
||||||
errEl.hidden = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
let status;
|
|
||||||
try { status = await window.CassandraSync.getStatus(); }
|
|
||||||
catch (e) {
|
|
||||||
statusEl.querySelector('.settings-row__value').innerHTML =
|
|
||||||
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const valueEl = statusEl.querySelector('.settings-row__value');
|
|
||||||
actionsEl.innerHTML = '';
|
|
||||||
if (status.exists && status.orphaned) {
|
|
||||||
// The stored blob can no longer be decrypted (server key rotated
|
|
||||||
// since it was written). The data is permanently unrecoverable,
|
|
||||||
// so silently clean up the dead row and re-render in the
|
|
||||||
// standard "off" state — leaving a soft one-liner so the user
|
|
||||||
// knows why they need to re-import.
|
|
||||||
try { await window.CassandraSync.disableSync(); }
|
|
||||||
catch (e) { console.warn('auto-clear stale sync failed', e); }
|
|
||||||
setFeedback('Your previous cloud backup couldn’t be restored. Re-import your portfolio to enable cloud sync again.', true);
|
|
||||||
await refresh();
|
|
||||||
return;
|
|
||||||
} else if (status.exists) {
|
|
||||||
const when = status.updated_at
|
|
||||||
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
|
||||||
: '—';
|
|
||||||
valueEl.innerHTML =
|
|
||||||
'<span class="badge badge--ok">On</span> ' +
|
|
||||||
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
|
|
||||||
|
|
||||||
const disable = document.createElement('button');
|
|
||||||
disable.type = 'button';
|
|
||||||
disable.className = 'pf-secondary';
|
|
||||||
disable.textContent = 'Disable sync';
|
|
||||||
disable.addEventListener('click', async function () {
|
|
||||||
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
|
|
||||||
try {
|
|
||||||
await window.CassandraSync.disableSync();
|
|
||||||
await refresh();
|
|
||||||
setFeedback('Cloud sync disabled. Server copy removed.', true);
|
|
||||||
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
|
|
||||||
});
|
|
||||||
actionsEl.appendChild(disable);
|
|
||||||
} else {
|
|
||||||
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
|
|
||||||
// Only offer 'Enable' when there's actually a pie to encrypt;
|
|
||||||
// otherwise the user would hit a dead-end at the modal.
|
|
||||||
const hasPie = !!localStorage.getItem('cassandra.pie');
|
|
||||||
if (!hasPie) {
|
|
||||||
const hint = document.createElement('span');
|
|
||||||
hint.className = 'settings-row__hint';
|
|
||||||
hint.innerHTML =
|
|
||||||
'Nothing to sync yet — ' +
|
|
||||||
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
|
|
||||||
actionsEl.appendChild(hint);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const enable = document.createElement('button');
|
|
||||||
enable.type = 'button';
|
|
||||||
enable.textContent = 'Enable cloud sync';
|
|
||||||
enable.addEventListener('click', openModal);
|
|
||||||
actionsEl.appendChild(enable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hooks for the Import section to drive this modal + status row.
|
|
||||||
window.cassandraOpenSyncModal = openModal;
|
|
||||||
window.cassandraRefreshSyncStatus = refresh;
|
|
||||||
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -469,249 +357,7 @@
|
||||||
|
|
||||||
{% if user %}
|
{% if user %}
|
||||||
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
|
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
|
||||||
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
<script>
|
<script src="{{ url_for('static', path='/js/settings-import.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
(function () {
|
|
||||||
// Server-side hint: did the user have paid privileges when the page
|
|
||||||
// rendered? Used to decide whether to offer the 'Import & sync' button.
|
|
||||||
// We still call CassandraSync.getStatus() at click time as the source
|
|
||||||
// of truth, but this lets us skip rendering a button we know is dead.
|
|
||||||
var IS_PAID = {{ 'true' if paid and paid.active else 'false' }};
|
|
||||||
|
|
||||||
function ready(fn) {
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', fn);
|
|
||||||
} else { fn(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(function () {
|
|
||||||
var P = window.CassandraPortfolio;
|
|
||||||
if (!P) return;
|
|
||||||
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
|
|
||||||
|
|
||||||
var dropZone = document.getElementById('drop-zone');
|
|
||||||
var fileInput = document.getElementById('file-input');
|
|
||||||
var browseLink = document.getElementById('browse-link');
|
|
||||||
var filenameEl = document.getElementById('dz-filename');
|
|
||||||
var previewEl = document.getElementById('import-preview');
|
|
||||||
var resultEl = document.getElementById('import-result');
|
|
||||||
if (!dropZone) return;
|
|
||||||
|
|
||||||
var currentPie = null; // most recently parsed pie, awaiting commit
|
|
||||||
|
|
||||||
function showError(msg) {
|
|
||||||
previewEl.hidden = true;
|
|
||||||
resultEl.className = 'result result--err';
|
|
||||||
resultEl.innerHTML =
|
|
||||||
'<div class="result__head">✕ Import failed</div>' +
|
|
||||||
'<div class="result__row">' + esc(msg) + '</div>';
|
|
||||||
resultEl.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSuccess(headline, sub) {
|
|
||||||
previewEl.hidden = true;
|
|
||||||
resultEl.className = 'result result--ok';
|
|
||||||
resultEl.innerHTML =
|
|
||||||
'<div class="result__head">' + esc(headline) + '</div>' +
|
|
||||||
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
|
|
||||||
'<div class="result__row" style="margin-top:14px;">' +
|
|
||||||
'<a href="/">Open dashboard →</a>' +
|
|
||||||
'</div>';
|
|
||||||
resultEl.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPreview(pie) {
|
|
||||||
currentPie = pie;
|
|
||||||
resultEl.hidden = true;
|
|
||||||
|
|
||||||
var t = pie.totals || {};
|
|
||||||
var rows = (pie.positions || []).map(function (p) {
|
|
||||||
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
|
|
||||||
return '<tr>' +
|
|
||||||
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
|
|
||||||
'<td>' + esc(p.name || '') + '</td>' +
|
|
||||||
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
|
||||||
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
|
|
||||||
'<td class="num">' + fmt(invested) + '</td>' +
|
|
||||||
'</tr>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
var warnings = (pie.warnings || []).map(function (w) {
|
|
||||||
return '<div class="result__warn">' + esc(w) + '</div>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
var syncBtn = IS_PAID
|
|
||||||
? ('<div class="import-choice">' +
|
|
||||||
'<button type="button" id="commit-sync">Import & sync to cloud</button>' +
|
|
||||||
'<div class="settings-row__hint">' +
|
|
||||||
'Also stores an <strong>encrypted</strong> copy on the server, ' +
|
|
||||||
'restorable on any device with your PIN. Only you can decrypt ' +
|
|
||||||
'it — losing the PIN means losing the backup.' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>')
|
|
||||||
: ('<div class="import-choice">' +
|
|
||||||
'<button type="button" disabled>Import & sync to cloud</button>' +
|
|
||||||
'<div class="settings-row__hint">' +
|
|
||||||
'Encrypted cloud backup is available on the paid tier.' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>');
|
|
||||||
|
|
||||||
previewEl.innerHTML =
|
|
||||||
'<div class="result result--ok" style="margin:0;">' +
|
|
||||||
'<div class="result__head">' +
|
|
||||||
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="result__grid">' +
|
|
||||||
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
|
|
||||||
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
|
|
||||||
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
|
|
||||||
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
|
|
||||||
'</div>' +
|
|
||||||
warnings +
|
|
||||||
(rows
|
|
||||||
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
|
|
||||||
'<table class="dense">' +
|
|
||||||
'<thead><tr>' +
|
|
||||||
'<th>Ticker</th><th>Name</th>' +
|
|
||||||
'<th class="num">Qty</th>' +
|
|
||||||
'<th class="num">Avg</th>' +
|
|
||||||
'<th class="num">Invested</th>' +
|
|
||||||
'</tr></thead>' +
|
|
||||||
'<tbody>' + rows + '</tbody>' +
|
|
||||||
'</table>' +
|
|
||||||
'</div>'
|
|
||||||
: ''
|
|
||||||
) +
|
|
||||||
'<div class="import-actions">' +
|
|
||||||
'<div class="import-choice">' +
|
|
||||||
'<button type="button" id="commit-local">Import to this browser</button>' +
|
|
||||||
'<div class="settings-row__hint">' +
|
|
||||||
'Saved to this browser only. No server-side copy of your holdings.' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
syncBtn +
|
|
||||||
'<div style="flex-basis:100%;">' +
|
|
||||||
'<button type="button" id="commit-cancel" class="pf-secondary">' +
|
|
||||||
'Cancel</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
previewEl.hidden = false;
|
|
||||||
|
|
||||||
document.getElementById('commit-local').addEventListener('click', commitLocal);
|
|
||||||
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
|
|
||||||
var syncEl = document.getElementById('commit-sync');
|
|
||||||
if (syncEl) syncEl.addEventListener('click', commitSync);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitLocal() {
|
|
||||||
if (!currentPie) return;
|
|
||||||
P.savePie(currentPie);
|
|
||||||
showSuccess('▸ Imported to this browser.',
|
|
||||||
'Pie kept locally; no server-side copy.');
|
|
||||||
currentPie = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitSync() {
|
|
||||||
if (!currentPie) return;
|
|
||||||
// Save locally first so the cloud-sync flow uses the freshly-imported
|
|
||||||
// pie (the enable-PIN modal in this same page reads from localStorage).
|
|
||||||
P.savePie(currentPie);
|
|
||||||
var S = window.CassandraSync;
|
|
||||||
if (!S) { showError('Cloud sync module not loaded.'); return; }
|
|
||||||
|
|
||||||
var status;
|
|
||||||
try { status = await S.getStatus(); }
|
|
||||||
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
|
|
||||||
|
|
||||||
if (!status.paid) {
|
|
||||||
showError('Cloud sync requires the paid tier.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.exists) {
|
|
||||||
// Already enabled — try a direct push using the cached session
|
|
||||||
// key. If no key is cached (fresh browser session), this throws,
|
|
||||||
// and we fall back to the enable-PIN modal so the user can
|
|
||||||
// re-enter their PIN.
|
|
||||||
try {
|
|
||||||
await S.pushSync(currentPie, null);
|
|
||||||
showSuccess('▸ Imported and synced.',
|
|
||||||
'Encrypted copy updated on the server.');
|
|
||||||
currentPie = null;
|
|
||||||
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
// Fall through to modal so the user can re-auth with their PIN.
|
|
||||||
console.warn('direct push failed, falling back to PIN modal', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// !status.exists OR cached-key push failed → use the modal.
|
|
||||||
if (window.cassandraOpenSyncModal) {
|
|
||||||
window.cassandraOpenSyncModal({
|
|
||||||
onSuccess: function () {
|
|
||||||
showSuccess('▸ Imported and synced.',
|
|
||||||
'Cloud sync is now enabled and the pie is stored encrypted.');
|
|
||||||
currentPie = null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
showError('Cloud sync UI unavailable on this page. ' +
|
|
||||||
'Use the Cloud sync section below to enable.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetUploader() {
|
|
||||||
currentPie = null;
|
|
||||||
previewEl.hidden = true;
|
|
||||||
previewEl.innerHTML = '';
|
|
||||||
resultEl.hidden = true;
|
|
||||||
filenameEl.textContent = '';
|
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseFile(file) {
|
|
||||||
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
|
|
||||||
previewEl.hidden = true;
|
|
||||||
resultEl.hidden = true;
|
|
||||||
try {
|
|
||||||
var pie = await P.parseCsv(file);
|
|
||||||
renderPreview(pie);
|
|
||||||
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
|
||||||
} catch (e) {
|
|
||||||
filenameEl.textContent = file.name + ' (failed)';
|
|
||||||
showError(e.message || 'Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
|
||||||
fileInput.addEventListener('change', function () {
|
|
||||||
if (fileInput.files[0]) parseFile(fileInput.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(function (ev) {
|
|
||||||
dropZone.addEventListener(ev, function (e) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
dropZone.classList.add('dz--over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
['dragleave', 'drop'].forEach(function (ev) {
|
|
||||||
dropZone.addEventListener(ev, function (e) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
dropZone.classList.remove('dz--over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
dropZone.addEventListener('drop', function (e) {
|
|
||||||
var f = e.dataTransfer.files && e.dataTransfer.files[0];
|
|
||||||
if (f) parseFile(f);
|
|
||||||
});
|
|
||||||
dropZone.addEventListener('click', function (e) {
|
|
||||||
if (e.target.tagName !== 'A') fileInput.click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -77,21 +77,16 @@
|
||||||
<section class="public-section">
|
<section class="public-section">
|
||||||
<h2 class="public-section__head">5. Paid plans</h2>
|
<h2 class="public-section__head">5. Paid plans</h2>
|
||||||
<p>
|
<p>
|
||||||
If and when paid plans become available, you will be told the
|
Paid plans are available at £7/month or £70/year (terms
|
||||||
applicable fees at point of sale. Paid features remain active for as
|
and current prices on the <a href="/pricing">pricing page</a>). New
|
||||||
long as the subscription is current or any time-bounded credit
|
annual subscriptions begin with a 14-day free trial; monthly
|
||||||
granted to your account is still valid. You can cancel a paid
|
subscriptions begin immediately on payment. Paid features remain
|
||||||
subscription at any time; cancellation takes effect at the end of
|
active for as long as the subscription is current or any
|
||||||
the current billing period unless otherwise stated.
|
time-bounded credit granted to your account is still valid. You
|
||||||
</p>
|
can cancel a paid subscription at any time; cancellation takes
|
||||||
<p>
|
effect at the end of the current billing period unless otherwise
|
||||||
Where the law gives you a 14-day right to cancel a subscription
|
stated. Detailed refund and cancellation rights are set out in
|
||||||
(Consumer Contracts (Information, Cancellation and Additional
|
section 6 below.
|
||||||
Charges) Regulations 2013, UK), that right applies. By starting to
|
|
||||||
use a paid feature immediately on purchase you agree we may begin
|
|
||||||
supplying the service within the cancellation period, and you
|
|
||||||
acknowledge that you lose the right to cancel in respect of any
|
|
||||||
digital content already delivered.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ BRAND_NAME }} · Import Portfolio{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="title">Import portfolio (Trading 212 CSV)</span>
|
|
||||||
<span class="meta">held locally · optional encrypted cloud sync (paid)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
|
|
||||||
<p style="color: var(--muted); font-size: 12.5px; margin: 0 0 14px; line-height: 1.6;">
|
|
||||||
Export your pie from the T212 web app
|
|
||||||
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
|
|
||||||
and drop the CSV here. Each Slice is resolved to its Yahoo ticker;
|
|
||||||
the parsed pie is kept in <em>this browser's localStorage</em>.
|
|
||||||
The server learns only which tickers exist (anonymously) so it can
|
|
||||||
fetch their prices. If you have <a href="/settings">cloud sync</a>
|
|
||||||
enabled, an <strong>encrypted</strong> copy is also pushed to the
|
|
||||||
server — only your PIN can decrypt it.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form id="upload-form" autocomplete="off">
|
|
||||||
<div id="drop-zone" class="dz">
|
|
||||||
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
|
||||||
<div class="dz__icon">▱</div>
|
|
||||||
<div class="dz__label">Drop a T212 pie CSV here</div>
|
|
||||||
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 1 MB</div>
|
|
||||||
<div class="dz__filename" id="dz-filename"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="submit-btn" type="submit" disabled style="margin-top:18px;">Parse</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="result" class="result" hidden></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
function ready(fn) {
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', fn);
|
|
||||||
} else { fn(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(function () {
|
|
||||||
var dropZone = document.getElementById('drop-zone');
|
|
||||||
var fileInput = document.getElementById('file-input');
|
|
||||||
var browseLink = document.getElementById('browse-link');
|
|
||||||
var filenameEl = document.getElementById('dz-filename');
|
|
||||||
var submitBtn = document.getElementById('submit-btn');
|
|
||||||
var form = document.getElementById('upload-form');
|
|
||||||
var resultEl = document.getElementById('result');
|
|
||||||
|
|
||||||
function setFile(file) {
|
|
||||||
if (!file) return;
|
|
||||||
var dt = new DataTransfer();
|
|
||||||
dt.items.add(file);
|
|
||||||
fileInput.files = dt.files;
|
|
||||||
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
|
||||||
fileInput.addEventListener('change', function () {
|
|
||||||
if (fileInput.files[0]) setFile(fileInput.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(function (ev) {
|
|
||||||
dropZone.addEventListener(ev, function (e) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
dropZone.classList.add('dz--over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
['dragleave', 'drop'].forEach(function (ev) {
|
|
||||||
dropZone.addEventListener(ev, function (e) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
dropZone.classList.remove('dz--over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
dropZone.addEventListener('drop', function (e) {
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
|
|
||||||
});
|
|
||||||
dropZone.addEventListener('click', function (e) {
|
|
||||||
if (e.target.tagName !== 'A') fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', async function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files[0]) return;
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Parsing…';
|
|
||||||
// CassandraPortfolio is exposed by /static/js/portfolio.js.
|
|
||||||
var ok = await window.CassandraPortfolio.handleUpload(form, fileInput.files[0], resultEl);
|
|
||||||
submitBtn.textContent = ok ? 'Parsed' : 'Parse';
|
|
||||||
submitBtn.disabled = !ok;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
catch (e) { document.documentElement.dataset.theme = 'light'; }
|
catch (e) { document.documentElement.dataset.theme = 'light'; }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-shell">
|
<div class="auth-shell">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ Imported by both routers/pages.py and routers/api.py so the filters are
|
||||||
registered exactly once."""
|
registered exactly once."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
@ -13,6 +14,13 @@ from app.config import get_settings
|
||||||
from app.services.glossary import wrap_glossary
|
from app.services.glossary import wrap_glossary
|
||||||
|
|
||||||
|
|
||||||
|
# Cache-busting token for static assets. Computed once at import time
|
||||||
|
# (i.e. process startup), so every container restart yields a fresh
|
||||||
|
# value and browsers refetch CSS/JS instead of serving stale cache.
|
||||||
|
# Templates append `?v={{ ASSET_VERSION }}` to every static URL.
|
||||||
|
ASSET_VERSION = str(int(time.time()))
|
||||||
|
|
||||||
|
|
||||||
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,3 +85,4 @@ templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR
|
||||||
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
|
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
|
||||||
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION
|
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION
|
||||||
templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE
|
templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE
|
||||||
|
templates.env.globals["ASSET_VERSION"] = ASSET_VERSION
|
||||||
|
|
|
||||||
1728
docs/superpowers/plans/2026-05-27-localization-italian.md
Normal file
1728
docs/superpowers/plans/2026-05-27-localization-italian.md
Normal file
File diff suppressed because it is too large
Load diff
411
docs/superpowers/specs/2026-05-27-localization-italian-design.md
Normal file
411
docs/superpowers/specs/2026-05-27-localization-italian-design.md
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
# Localization (Italian active, ES/FR/DE WIP) — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-05-27
|
||||||
|
**Status:** Draft — pending implementation plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
All AI-generated content (strategic log, daily email digest, portfolio
|
||||||
|
analysis) is English-only today. The operator wants to add Italian
|
||||||
|
translation as the first localization, with Spanish, French, and
|
||||||
|
German listed as "coming soon" in the settings UI but not yet
|
||||||
|
functional. Italian must work end-to-end from settings dropdown to
|
||||||
|
rendered output; the other three exist as commitments and design
|
||||||
|
placeholders so adding them later is a flag flip.
|
||||||
|
|
||||||
|
This is foundational plumbing: it touches every LLM call site we ship
|
||||||
|
today and shapes how every future AI feature handles language. Doing it
|
||||||
|
first means later features (qty/cost edit narratives, P/L summaries,
|
||||||
|
alert text, etc.) inherit the i18n wiring for free instead of needing a
|
||||||
|
retrofit.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- A user can pick `Italiano` from a settings dropdown and immediately
|
||||||
|
see every AI-generated surface in Italian.
|
||||||
|
- Adding `es`, `fr`, or `de` later is a one-line change to a constant
|
||||||
|
plus optionally validating the dropdown's enabled set.
|
||||||
|
- Translation cost stays in the "noise" range — we use the same
|
||||||
|
DeepSeek-4-flash model the rest of the system uses (~$0.28/M output
|
||||||
|
tokens). No separate "cheap translation" plumbing.
|
||||||
|
- Strategic-log reads stay instant for non-English users — no
|
||||||
|
read-time translation latency.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- UI label translation. The dashboard buttons, settings labels,
|
||||||
|
headings, and other chrome remain English. Only the AI's own output
|
||||||
|
is localized.
|
||||||
|
- Translation of indicator summaries. The same pattern will apply when
|
||||||
|
those become user-facing prose, but they aren't surfaced today.
|
||||||
|
- Backfilling translations for historical strategic logs. Translation
|
||||||
|
only happens going forward, at the moment a new English log is written.
|
||||||
|
- Activation of Spanish/French/German. They appear in the dropdown as
|
||||||
|
"coming soon" with disabled options; the value-validation layer in
|
||||||
|
the settings POST refuses them.
|
||||||
|
|
||||||
|
## Two distinct translation paths
|
||||||
|
|
||||||
|
The system has two categories of AI-generated content, with different
|
||||||
|
generation patterns:
|
||||||
|
|
||||||
|
### Per-user content (portfolio analysis only)
|
||||||
|
|
||||||
|
Portfolio analysis is the only AI-generated surface whose *content* is
|
||||||
|
genuinely per-user — each call's input is the user's own pie. Here we
|
||||||
|
add the `"Respond in Italian."` clause to the system prompt when
|
||||||
|
`user.lang != 'en'`. One LLM call, no extra cost, no extra latency.
|
||||||
|
|
||||||
|
### Shared content (strategic log, email digest)
|
||||||
|
|
||||||
|
Strategic log and email digest are generated once per cycle (hourly,
|
||||||
|
daily) and consumed by many users. We do NOT generate them per-user
|
||||||
|
per-language. Instead:
|
||||||
|
|
||||||
|
- **Strategic log**: `ai_log_job` writes the English row as today,
|
||||||
|
then translates it to each active non-English language and persists
|
||||||
|
in `strategic_log_translations` (one row per `(log_id, lang)`).
|
||||||
|
`/log` serves the translation matching the user's `lang`, falling
|
||||||
|
back to English.
|
||||||
|
|
||||||
|
- **Email digest**: the digest job already generates one English
|
||||||
|
variant per tone (NOVICE / INTERMEDIATE / PRO). We extend the same
|
||||||
|
cycle so that for each tone variant, the job ALSO produces a
|
||||||
|
translation for each active non-English language. The translations
|
||||||
|
live alongside the English variants in memory for the duration of
|
||||||
|
the job run; the per-user send step selects the matching
|
||||||
|
`(tone, lang)` cell. No new persistence — variants exist only for
|
||||||
|
the lifetime of the job.
|
||||||
|
|
||||||
|
Why translate-after rather than generate-N-times: the shared content
|
||||||
|
involves expensive context assembly (live market data, headlines, log
|
||||||
|
history). Re-running the full generation in each language duplicates
|
||||||
|
that work; translating the rendered output preserves a single source
|
||||||
|
of truth and only spends LLM tokens on the actual prose conversion.
|
||||||
|
|
||||||
|
Why no per-user LLM call for the digest: 100 Italian users would
|
||||||
|
otherwise mean 100 translation calls per day. With the shared cycle
|
||||||
|
we make 3 translations per day (one per tone) regardless of how many
|
||||||
|
Italian users receive that variant.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ User has user.lang preference │
|
||||||
|
│ Values: 'en' (default) | 'it' (active) | 'es'/'fr'/'de' (WIP) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─ Per-user surface (portfolio analysis only)
|
||||||
|
│ └─ prompt assembly threads user.lang to
|
||||||
|
│ respond_in_clause() → appended to system prompt
|
||||||
|
│ when lang != 'en'. Single call_llm, no extra cost.
|
||||||
|
│
|
||||||
|
├─ Shared surface — strategic log
|
||||||
|
│ ├─ ai_log_job writes the English row as today
|
||||||
|
│ ├─ SELECTs distinct users.lang where lang != 'en'
|
||||||
|
│ │ (no tier gating)
|
||||||
|
│ ├─ asyncio.gather of one translate() call per language
|
||||||
|
│ └─ Each result → INSERT into strategic_log_translations
|
||||||
|
│ keyed by (log_id, lang) UNIQUE
|
||||||
|
│
|
||||||
|
└─ Shared surface — email digest
|
||||||
|
├─ Job builds one English variant per tone (existing
|
||||||
|
│ _generate_variants behaviour, unchanged)
|
||||||
|
├─ For each (variant, active non-en lang), translate
|
||||||
|
│ via asyncio.gather; results live in memory
|
||||||
|
└─ Per-user send loop looks up (user.digest_tone,
|
||||||
|
user.lang) in the in-memory dictionary; falls back
|
||||||
|
to the English variant of the same tone on miss
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `users.lang` (new column)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN lang VARCHAR(8) NOT NULL DEFAULT 'en';
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing rows pick up the `en` default. Application-level validation
|
||||||
|
restricts writes to the `ACTIVE_LANGUAGES` set; the database column
|
||||||
|
accepts anything in `VARCHAR(8)` (no CHECK constraint — we want to
|
||||||
|
add new languages without a migration).
|
||||||
|
|
||||||
|
### `strategic_log_translations` (new table)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE strategic_log_translations (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
log_id BIGINT NOT NULL,
|
||||||
|
lang VARCHAR(8) NOT NULL,
|
||||||
|
content_md TEXT NOT NULL,
|
||||||
|
generated_at DATETIME(6) NOT NULL,
|
||||||
|
llm_model VARCHAR(64),
|
||||||
|
llm_cost_usd FLOAT,
|
||||||
|
CONSTRAINT fk_slt_log
|
||||||
|
FOREIGN KEY (log_id) REFERENCES strategic_logs(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_slt_log_lang UNIQUE (log_id, lang)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
ON DELETE CASCADE means evicting an old strategic log row also drops
|
||||||
|
its translations. The UNIQUE constraint prevents duplicate translations
|
||||||
|
for the same log/lang combo.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `app/services/i18n.py` (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
LANGUAGES = {
|
||||||
|
"en": "English",
|
||||||
|
"it": "Italian",
|
||||||
|
"es": "Spanish",
|
||||||
|
"fr": "French",
|
||||||
|
"de": "German",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set of language codes that users can actually pick from the settings
|
||||||
|
# dropdown. ES/FR/DE remain in LANGUAGES so their labels render, but
|
||||||
|
# the settings POST validator and the strategic-log translation fan-out
|
||||||
|
# both consult this set.
|
||||||
|
ACTIVE_LANGUAGES = {"en", "it"}
|
||||||
|
|
||||||
|
|
||||||
|
def respond_in_clause(lang: str) -> str:
|
||||||
|
"""Suffix appended to per-user LLM system prompts.
|
||||||
|
|
||||||
|
Returns an empty string for 'en' (the default everywhere already).
|
||||||
|
Otherwise returns "\n\nRespond in <Language>." so the model knows
|
||||||
|
to write its output in the user's language.
|
||||||
|
"""
|
||||||
|
if not lang or lang == "en":
|
||||||
|
return ""
|
||||||
|
name = LANGUAGES.get(lang, "English")
|
||||||
|
return f"\n\nRespond in {name}."
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/services/translation.py` (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def translate(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
text: str,
|
||||||
|
target_lang: str,
|
||||||
|
) -> tuple[str, LogResult]:
|
||||||
|
"""Translate ``text`` (markdown) to ``target_lang``.
|
||||||
|
|
||||||
|
Uses the default ``call_llm`` provider chain — DeepSeek-4-flash via
|
||||||
|
the OG API is already cheap enough ($0.28/M output) that a separate
|
||||||
|
'translation model' setting would be over-engineering.
|
||||||
|
|
||||||
|
Returns ``(translated_markdown, LogResult)`` so the caller can
|
||||||
|
persist provenance (model + cost) alongside the translation.
|
||||||
|
Raises on provider failure; caller decides whether to surface or
|
||||||
|
swallow.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
System prompt: *"Translate the following markdown to {language}. Preserve all formatting (headings, lists, links, emphasis). Do NOT translate ticker symbols, company names, numbers, percentages, or dates. Output ONLY the translated markdown — no preamble, no commentary."*
|
||||||
|
|
||||||
|
### `app/models.py` (modified)
|
||||||
|
|
||||||
|
- `User`: add `lang: Mapped[str] = mapped_column(String(8), nullable=False, default="en", server_default="en")`
|
||||||
|
- New class `StrategicLogTranslation` matching the table above
|
||||||
|
|
||||||
|
### `app/jobs/ai_log_job.py` (modified)
|
||||||
|
|
||||||
|
After the existing English log row is persisted, add a translation
|
||||||
|
fan-out:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Select distinct active non-English languages.
|
||||||
|
async with session_factory() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(User.lang).distinct()
|
||||||
|
.where(User.lang.in_(ACTIVE_LANGUAGES - {"en"}))
|
||||||
|
)).scalars().all()
|
||||||
|
active_langs = list(rows)
|
||||||
|
|
||||||
|
if active_langs:
|
||||||
|
async with httpx.AsyncClient(...) 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, error=str(result)[:200])
|
||||||
|
continue
|
||||||
|
translated_md, llm_log = result
|
||||||
|
session.add(StrategicLogTranslation(
|
||||||
|
log_id=log_row.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()
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors in individual language translations are logged but do not fail
|
||||||
|
the job. Missing translations get rendered as the English fallback at
|
||||||
|
read time.
|
||||||
|
|
||||||
|
### `app/jobs/email_digest_job.py` (modified)
|
||||||
|
|
||||||
|
The job already builds one English variant per tone in
|
||||||
|
`_generate_variants(...)`. After that returns, the job translates each
|
||||||
|
variant into every active non-English language (parallel via
|
||||||
|
`asyncio.gather`), and exposes a `(tone, lang) -> content` lookup that
|
||||||
|
`_send_one(...)` consults using the recipient's `user.lang`.
|
||||||
|
|
||||||
|
- Variants live only in memory for the duration of the job run.
|
||||||
|
- A failed translation for `(tone, lang)` is logged and that cell
|
||||||
|
falls back to the English variant of the same tone. The send
|
||||||
|
proceeds — the user still gets a digest, just in English that day.
|
||||||
|
- The subject line is part of each variant's content, so it gets
|
||||||
|
translated as part of the same call.
|
||||||
|
|
||||||
|
### `app/services/portfolio_analysis.py` (modified)
|
||||||
|
|
||||||
|
- `AnalysisRequest` gains a `lang: str = "en"` field, populated by the
|
||||||
|
route from `principal.user.lang`
|
||||||
|
- `analyse(...)` appends `respond_in_clause(req.lang)` to its system prompt
|
||||||
|
|
||||||
|
### `app/routers/universe.py` (modified — the `/api/analyze` route)
|
||||||
|
|
||||||
|
Read the current user's `lang` and put it in the payload before calling
|
||||||
|
`analyse(...)`. (The current route gets the principal via Depends.)
|
||||||
|
|
||||||
|
### `app/routers/pages.py` / the `/log` resolution (modified)
|
||||||
|
|
||||||
|
When rendering `/log` (and the `/log/{day}` historical variant), look
|
||||||
|
up the user's `lang`. If `lang != 'en'`, attempt to fetch the matching
|
||||||
|
`StrategicLogTranslation`; if present, render that. If absent, fall
|
||||||
|
back to the English `StrategicLog.content_md`. No silent error — the
|
||||||
|
fallback is the intended graceful path.
|
||||||
|
|
||||||
|
### Settings UI (`app/templates/settings.html` modified)
|
||||||
|
|
||||||
|
New section under existing user preferences (alongside the digest-tone
|
||||||
|
toggle):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<details class="settings-section">
|
||||||
|
<summary class="settings-section__head">Language</summary>
|
||||||
|
<p class="settings-section__lede">
|
||||||
|
The language the AI uses for the strategic log, your daily digest,
|
||||||
|
and portfolio commentary. UI labels stay in English for now.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/settings/language" class="settings-row">
|
||||||
|
<select name="lang" id="lang-select">
|
||||||
|
<option value="en" {% if user.lang == 'en' %}selected{% endif %}>English</option>
|
||||||
|
<option value="it" {% if user.lang == 'it' %}selected{% endif %}>Italiano</option>
|
||||||
|
<option value="es" disabled>Español (coming soon)</option>
|
||||||
|
<option value="fr" disabled>Français (coming soon)</option>
|
||||||
|
<option value="de" disabled>Deutsch (coming soon)</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="settings-btn">Save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings POST endpoint (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("/settings/language")
|
||||||
|
async def set_language(
|
||||||
|
lang: str = Form(...),
|
||||||
|
cu: CurrentUser = Depends(require_auth),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
if lang not in ACTIVE_LANGUAGES:
|
||||||
|
raise HTTPException(status_code=400, detail="unsupported language")
|
||||||
|
if cu.user is None:
|
||||||
|
raise HTTPException(status_code=403, detail="user required")
|
||||||
|
cu.user.lang = lang
|
||||||
|
await session.commit()
|
||||||
|
return RedirectResponse(url="/settings#language", status_code=303)
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side validation against `ACTIVE_LANGUAGES` is the gate that
|
||||||
|
keeps ES/FR/DE non-functional even if someone POSTs them by hand.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Translation provider down at ai_log_job time | English row still written. Translation row missing for that hour and language. Next hour retries. No retroactive backfill in v1. |
|
||||||
|
| Translation returns malformed markdown | Stored anyway (we trust DeepSeek output enough that this is rare). Operator can delete a bad row by hand. |
|
||||||
|
| User has `lang=it` but no IT translation for the latest log | Fall back to English silently. Better than an empty pane. |
|
||||||
|
| User saves an unsupported lang (`es`/`fr`/`de`/`xx`) via raw POST | 400 — validated against `ACTIVE_LANGUAGES`. |
|
||||||
|
| Migrating an existing user with no `lang` column | The `DEFAULT 'en'` clause on the migration handles it; no application code change needed. |
|
||||||
|
| User picks Italian, then logs change reaches them mid-hour | The next ai_log_job tick generates and translates a fresh log; users see the IT version on the next refresh. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Backend (`tests/test_i18n.py`, `tests/test_translation.py`,
|
||||||
|
`tests/test_localization_integration.py`):
|
||||||
|
|
||||||
|
- `respond_in_clause('en')` returns empty string
|
||||||
|
- `respond_in_clause('it')` includes the word "Italian"
|
||||||
|
- `respond_in_clause('xx')` falls back to "English" (defensive)
|
||||||
|
- `translate()` mocked happy path returns the translated text + LogResult
|
||||||
|
- `translate()` provider failure raises
|
||||||
|
- ai_log_job: with no non-en users, no translation calls happen (mock asserts call_count=0)
|
||||||
|
- ai_log_job: with one user at `lang='it'`, one translation row written with the right `lang` and `log_id`
|
||||||
|
- ai_log_job: translation failure on one lang doesn't fail the job; the other lang's row still writes
|
||||||
|
- `/log` serves IT row when `user.lang='it'` and an IT translation exists
|
||||||
|
- `/log` falls back to English when `user.lang='it'` but no IT translation exists
|
||||||
|
- `/settings/language` POST: accepts `en`/`it`, rejects `es`/`fr`/`de`/`xx` with 400
|
||||||
|
- `analyse()` system prompt contains `"Respond in Italian."` when `lang='it'` (assert on the messages list passed to call_llm)
|
||||||
|
- digest job system prompt likewise contains the clause when the user is Italian
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end manual check after deploy:
|
||||||
|
|
||||||
|
1. **Switch a paid test user to Italian via the settings dropdown.** Confirm `users.lang='it'` in the DB.
|
||||||
|
2. **Wait for the next hourly log generation** (or trigger manually via cron/admin). Confirm a new `strategic_log_translations` row exists with `lang='it'` and `content_md` clearly Italian.
|
||||||
|
3. **Open the dashboard as that user.** Strategic log renders in Italian.
|
||||||
|
4. **Trigger the daily digest send for that user** (CLI: `python -m app.cli send-test-digest user@x daily`). Confirm the received email is in Italian.
|
||||||
|
5. **Click "Analyse my portfolio"** on the dashboard. Confirm the AI commentary is in Italian.
|
||||||
|
6. **Switch the same user back to English.** Confirm the next dashboard refresh shows the English log. The IT translation row stays in the DB (other IT users still benefit).
|
||||||
|
7. **Inspect the dropdown.** Verify ES/FR/DE appear with "(coming soon)" suffix and the option is disabled.
|
||||||
|
8. **Attempt `curl -X POST /settings/language -d lang=es`** with a valid session cookie. Expect 400.
|
||||||
|
|
||||||
|
## Migration / rollout
|
||||||
|
|
||||||
|
- Alembic migration `0022_localization` adds `users.lang` and creates
|
||||||
|
`strategic_log_translations`. Existing rows pick up `en` default.
|
||||||
|
- App restart picks up the new code paths. Pre-existing English logs
|
||||||
|
stay as-is. The first ai_log_job tick after deploy generates the
|
||||||
|
first Italian translation for whatever active IT users exist (likely
|
||||||
|
zero on day one until someone opts in).
|
||||||
|
- Removing localization later (if needed) is harmless: setting any
|
||||||
|
user's `lang` back to `en` makes their experience identical to the
|
||||||
|
pre-localization state.
|
||||||
|
|
||||||
|
## Out-of-scope clarifications
|
||||||
|
|
||||||
|
- We do not translate UI labels. Italian users see English buttons,
|
||||||
|
headings, and tooltips. Future scope.
|
||||||
|
- We do not translate user-supplied input (e.g. portfolio names, any
|
||||||
|
free-text fields). Only AI-generated output is localized.
|
||||||
|
- The email subject line is part of each variant's content, so it
|
||||||
|
gets translated alongside the body in the same `translate()` call
|
||||||
|
per (tone, lang) cell — no separate subject-translation path.
|
||||||
|
- We do not surface translation cost in any user-visible UI. Strategic
|
||||||
|
log translation cost lands in `strategic_log_translations.llm_cost_usd`;
|
||||||
|
digest translation cost is captured in the existing `ai_calls` ledger
|
||||||
|
via the underlying `call_llm` calls.
|
||||||
|
- We do **not** gate strategic-log translation on user tier. Any user
|
||||||
|
with `lang='it'` triggers Italian translation for that hour's log,
|
||||||
|
regardless of whether they are paid, on credit, or free. Rationale:
|
||||||
|
Italian + UK are the first markets the operator is targeting, so
|
||||||
|
Italian availability is part of the public-facing experience — a
|
||||||
|
free-tier visitor needs to see the AI in Italian to convert. At
|
||||||
|
~$0.005/day total cost the gating overhead is not worth the savings.
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Mobile responsiveness — design
|
||||||
|
|
||||||
|
**Status:** approved 2026-05-28 (user opted to skip the implementation-plan
|
||||||
|
ceremony and iterate on the coded product instead).
|
||||||
|
**Scope:** all views, single ≤480px breakpoint, incremental media-query approach.
|
||||||
|
|
||||||
|
## Decisions captured
|
||||||
|
|
||||||
|
1. **Target device:** phones only. Single `@media (max-width: 480px)` breakpoint.
|
||||||
|
Tablets and small laptops keep the existing desktop layout.
|
||||||
|
2. **Scope:** every template (auth, public, app, dashboard, log, news, settings).
|
||||||
|
3. **Mobile topbar pattern:** hamburger drawer, **side-slide from the right**.
|
||||||
|
4. **Indicator table:** hide secondary columns on phones (`ccy`, `1y`, `anchor`,
|
||||||
|
`as_of`); keep symbol, price, 1d, 1m.
|
||||||
|
5. **CSS organisation:** per-file `@media` block at the bottom of each CSS file —
|
||||||
|
extends the pattern already in `layout.css`, `log-chat.css`, `news.css`,
|
||||||
|
`portfolio.css`, `public.css`. No central `mobile.css`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
tokens.css — no mobile rules
|
||||||
|
layout.css — drawer geometry + topbar mobile layout
|
||||||
|
panels.css — header padding tightens
|
||||||
|
dashboard.css — group tabs scroll, indicator table column-hiding
|
||||||
|
portfolio.css — overall grid 2-col, composer textarea full-width, action wrap
|
||||||
|
log-chat.css — body padding, bubble width
|
||||||
|
auth.css — card padding
|
||||||
|
settings.css — form rows stack
|
||||||
|
news.css — pill wrap, source under headline
|
||||||
|
public.css — tighten existing 520/560 rules, hero typography clamp
|
||||||
|
```
|
||||||
|
|
||||||
|
Two small additions to `base.html`:
|
||||||
|
- A hamburger button in `.app-header` (hidden on desktop via `display: none`,
|
||||||
|
shown at ≤480px).
|
||||||
|
- ~20 lines of vanilla JS to toggle `body.drawer-open` plus a backdrop element.
|
||||||
|
Tap-backdrop, ESC, and swipe-right-on-drawer all close.
|
||||||
|
|
||||||
|
## Hamburger drawer (right-side)
|
||||||
|
|
||||||
|
- `position: fixed; top: 0; right: 0; height: 100vh; width: min(82vw, 320px)`
|
||||||
|
- Transform animation: `translateX(100%) → translateX(0)`, `180ms ease-out`
|
||||||
|
- Backdrop: `rgba(0,0,0,0.4)`, fades in over `120ms`
|
||||||
|
- Existing nav + `.header-right` widgets get wrapped in `.mobile-drawer` which
|
||||||
|
is `display: contents` on desktop (zero layout effect) and the fixed slide-out
|
||||||
|
panel on mobile.
|
||||||
|
- The existing `.user-menu` dropdown chip hides on mobile; its links surface
|
||||||
|
flat inside the drawer.
|
||||||
|
|
||||||
|
## Per-view rules
|
||||||
|
|
||||||
|
**Dashboard.** Group-tabs `overflow-x: auto`, no-wrap. Indicator table hides
|
||||||
|
`Ccy / 1y / anchor / as_of` columns. Aggregate-read summary header tightens.
|
||||||
|
|
||||||
|
**Portfolio.** `.pf-overall__grid` collapses to 2 columns. Composer textarea
|
||||||
|
becomes full-width. `.pf-actions` buttons wrap to two rows instead of squishing.
|
||||||
|
|
||||||
|
**Log + chat.** Body padding `16px → 10px`. Chat bubbles `max-width: 100%`,
|
||||||
|
user bubble loses right margin so it reaches the screen edge.
|
||||||
|
|
||||||
|
**News.** Tag pills flex-wrap. Source + timestamp move under headline.
|
||||||
|
Shift-click hint hides (touch users get long-press equivalent).
|
||||||
|
|
||||||
|
**Settings.** Form rows stack — label above input. Two-column import picker
|
||||||
|
becomes single column. Digest preferences keep their layout.
|
||||||
|
|
||||||
|
**Auth.** Card padding `28px 26px → 20px 18px`. Width already fluid.
|
||||||
|
|
||||||
|
**Public pages.** Audit existing 520/560 breakpoints; tighten hero typography
|
||||||
|
with `clamp()` so it scales down for small phones.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- No Python tests affected — this is pure CSS + a single template tweak.
|
||||||
|
Existing 336-pass suite stays green.
|
||||||
|
- Manual verification on the user's phone post-deploy. (User cannot reach
|
||||||
|
localhost on the dev host, so visual companion was abandoned mid-brainstorm
|
||||||
|
in favour of ASCII previews; same constraint means no local browser smoke
|
||||||
|
test from the assistant side either — user iterates on the deployed site.)
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Tablet / small-laptop breakpoints. Single ≤480 only.
|
||||||
|
- Touch gestures beyond drawer swipe-right-to-close.
|
||||||
|
- Mobile-specific reordering of dashboard panels (existing collapse order
|
||||||
|
is preserved).
|
||||||
|
- Visual companion server work (host unreachable from user's browser).
|
||||||
60
requirements.lock
Normal file
60
requirements.lock
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
aiomysql==0.3.2
|
||||||
|
aiosmtplib==5.1.0
|
||||||
|
aiosqlite==0.22.1
|
||||||
|
alembic==1.18.4
|
||||||
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.13.0
|
||||||
|
APScheduler==3.11.2
|
||||||
|
argon2-cffi==25.1.0
|
||||||
|
argon2-cffi-bindings==25.1.0
|
||||||
|
certifi==2026.5.20
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
click==8.4.1
|
||||||
|
cryptography==48.0.0
|
||||||
|
dnspython==2.8.0
|
||||||
|
email-validator==2.3.0
|
||||||
|
fastapi==0.136.3
|
||||||
|
greenlet==3.5.1
|
||||||
|
h11==0.16.0
|
||||||
|
hiredis==3.3.1
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.8.0
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.16
|
||||||
|
iniconfig==2.3.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
Mako==1.3.12
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
packaging==26.2
|
||||||
|
pluggy==1.6.0
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic-settings==2.14.1
|
||||||
|
pydantic_core==2.46.4
|
||||||
|
Pygments==2.20.0
|
||||||
|
PyMySQL==1.2.0
|
||||||
|
pytest==9.0.3
|
||||||
|
pytest-asyncio==1.4.0
|
||||||
|
pytest-httpx==0.36.2
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.29
|
||||||
|
PyYAML==6.0.3
|
||||||
|
redis==7.4.0
|
||||||
|
requests==2.34.2
|
||||||
|
ruff==0.15.14
|
||||||
|
SQLAlchemy==2.0.50
|
||||||
|
starlette==1.1.0
|
||||||
|
stripe==15.1.0
|
||||||
|
structlog==25.5.0
|
||||||
|
tenacity==9.1.4
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
tzlocal==5.3.1
|
||||||
|
urllib3==2.7.0
|
||||||
|
uvicorn==0.48.0
|
||||||
|
uvloop==0.22.1
|
||||||
|
watchfiles==1.2.0
|
||||||
|
websockets==16.0
|
||||||
154
scripts/backfill_truncated_translations.py
Normal file
154
scripts/backfill_truncated_translations.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""One-off backfill: re-translate StrategicLog rows whose Italian (or
|
||||||
|
other-language) translation was truncated by the old 4000-token cap in
|
||||||
|
services/translation.py.
|
||||||
|
|
||||||
|
Selection criteria for a "truncated" row:
|
||||||
|
- completion_tokens >= 3990 (right at or above the old cap), OR
|
||||||
|
- the translated content is shorter than half the English source
|
||||||
|
|
||||||
|
Usage inside the app container:
|
||||||
|
docker compose exec app python -m scripts.backfill_truncated_translations \
|
||||||
|
--date 2026-05-28 # restrict to one day, repeatable
|
||||||
|
docker compose exec app python -m scripts.backfill_truncated_translations \
|
||||||
|
--since 2026-04-01 # everything from a date onward
|
||||||
|
docker compose exec app python -m scripts.backfill_truncated_translations \
|
||||||
|
--all # entire history (slow / costs $$)
|
||||||
|
docker compose exec app python -m scripts.backfill_truncated_translations \
|
||||||
|
--date 2026-05-28 --dry-run # just print what would be touched
|
||||||
|
|
||||||
|
Idempotent: each affected row is deleted then re-inserted in its own
|
||||||
|
transaction, so a re-run only re-translates rows that are STILL flagged
|
||||||
|
truncated after the previous pass.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import and_, delete, func, or_, select
|
||||||
|
|
||||||
|
from app.db import get_session_factory
|
||||||
|
from app.logging import get_logger
|
||||||
|
from app.models import StrategicLog, StrategicLogTranslation
|
||||||
|
from app.services.translation import translate
|
||||||
|
|
||||||
|
log = get_logger("backfill.translations")
|
||||||
|
|
||||||
|
# Italian (and the other expansive Romance / Germanic targets we support)
|
||||||
|
# typically produce 15-25 % MORE characters than the English source, so
|
||||||
|
# a translation shorter than the source — let alone much shorter — is a
|
||||||
|
# truncation signal even if completion_tokens didn't land exactly at the
|
||||||
|
# old 4000-token cap. We tolerate down to 70 % of source length to avoid
|
||||||
|
# touching the occasional legitimately-compressed translation.
|
||||||
|
SHORTNESS_RATIO = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
def _is_truncated(en_chars: int, tr_chars: int, tr_completion: int | None) -> bool:
|
||||||
|
if en_chars <= 0:
|
||||||
|
return False
|
||||||
|
return tr_chars < en_chars * SHORTNESS_RATIO
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_targets(session, day: date | None, since: date | None, all_: bool):
|
||||||
|
q = (
|
||||||
|
select(
|
||||||
|
StrategicLog.id.label("log_id"),
|
||||||
|
StrategicLog.generated_at,
|
||||||
|
func.char_length(StrategicLog.content).label("en_chars"),
|
||||||
|
StrategicLogTranslation.id.label("tr_id"),
|
||||||
|
StrategicLogTranslation.lang,
|
||||||
|
StrategicLogTranslation.completion_tokens.label("tr_tok"),
|
||||||
|
func.char_length(StrategicLogTranslation.content).label("tr_chars"),
|
||||||
|
)
|
||||||
|
.join(StrategicLogTranslation,
|
||||||
|
StrategicLogTranslation.log_id == StrategicLog.id)
|
||||||
|
)
|
||||||
|
if day is not None:
|
||||||
|
q = q.where(func.date(StrategicLog.generated_at) == day)
|
||||||
|
elif since is not None:
|
||||||
|
q = q.where(StrategicLog.generated_at >= since)
|
||||||
|
# all_ → no date filter
|
||||||
|
q = q.order_by(StrategicLog.generated_at, StrategicLogTranslation.lang)
|
||||||
|
rows = (await session.execute(q)).all()
|
||||||
|
return [r for r in rows if _is_truncated(r.en_chars, r.tr_chars, r.tr_tok)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _retranslate_one(session, client: httpx.AsyncClient, log_id: int, lang: str):
|
||||||
|
"""Delete the existing (log_id, lang) translation row and write a fresh
|
||||||
|
one via the (now uncapped) translation service. Each row commits
|
||||||
|
independently so a per-row failure doesn't roll back the rest."""
|
||||||
|
src_row = (await session.execute(
|
||||||
|
select(StrategicLog).where(StrategicLog.id == log_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if src_row is None:
|
||||||
|
log.warning("backfill.missing_source", log_id=log_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(StrategicLogTranslation)
|
||||||
|
.where(StrategicLogTranslation.log_id == log_id)
|
||||||
|
.where(StrategicLogTranslation.lang == lang)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
translated_md, llm_result = await translate(client, src_row.content, lang)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("backfill.translate_failed",
|
||||||
|
log_id=log_id, lang=lang, error=str(exc)[:200])
|
||||||
|
return False
|
||||||
|
|
||||||
|
session.add(StrategicLogTranslation(
|
||||||
|
log_id=log_id,
|
||||||
|
lang=lang,
|
||||||
|
content=translated_md,
|
||||||
|
model=llm_result.model,
|
||||||
|
prompt_tokens=llm_result.prompt_tokens,
|
||||||
|
completion_tokens=llm_result.completion_tokens,
|
||||||
|
cost_usd=llm_result.cost_usd,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args):
|
||||||
|
day = datetime.strptime(args.date, "%Y-%m-%d").date() if args.date else None
|
||||||
|
since = datetime.strptime(args.since, "%Y-%m-%d").date() if args.since else None
|
||||||
|
if not (day or since or args.all):
|
||||||
|
print("Specify --date, --since, or --all", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as session:
|
||||||
|
targets = await _find_targets(session, day, since, args.all)
|
||||||
|
print(f"Found {len(targets)} truncated translation row(s):")
|
||||||
|
for r in targets:
|
||||||
|
print(f" log_id={r.log_id} lang={r.lang} "
|
||||||
|
f"en={r.en_chars}c tr={r.tr_chars}c "
|
||||||
|
f"tok={r.tr_tok} at {r.generated_at}")
|
||||||
|
if args.dry_run or not targets:
|
||||||
|
return
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
for r in targets:
|
||||||
|
print(f" re-translating log_id={r.log_id} lang={r.lang}…", end=" ")
|
||||||
|
done = await _retranslate_one(session, client, r.log_id, r.lang)
|
||||||
|
print("OK" if done else "FAILED")
|
||||||
|
if done:
|
||||||
|
ok += 1
|
||||||
|
print(f"\nRe-translated {ok}/{len(targets)} row(s).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
grp = p.add_mutually_exclusive_group()
|
||||||
|
grp.add_argument("--date", help="single day YYYY-MM-DD")
|
||||||
|
grp.add_argument("--since", help="from YYYY-MM-DD onward")
|
||||||
|
grp.add_argument("--all", action="store_true", help="entire history")
|
||||||
|
p.add_argument("--dry-run", action="store_true",
|
||||||
|
help="list affected rows without rewriting")
|
||||||
|
asyncio.run(main(p.parse_args()))
|
||||||
76
scripts/purge_unclean_summaries.py
Normal file
76
scripts/purge_unclean_summaries.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""One-off purge: ask the reviewer agent to judge every IndicatorSummary
|
||||||
|
row already in the DB, delete the ones it flags as unclean.
|
||||||
|
|
||||||
|
Same reviewer the live pipeline uses (services/output_review.review_read),
|
||||||
|
so post-purge rows are exactly what would survive a fresh generation.
|
||||||
|
Per-row cost ~$0.0001; total run on ~3000 rows ~$0.30.
|
||||||
|
|
||||||
|
Usage inside the app container:
|
||||||
|
docker compose exec app python /tmp/purge.py --dry-run
|
||||||
|
docker compose exec app python /tmp/purge.py # actually delete
|
||||||
|
|
||||||
|
The script processes rows concurrently up to a small fan-out (default 8)
|
||||||
|
to keep wall-clock down without hammering the provider.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.db import get_session_factory
|
||||||
|
from app.models import IndicatorSummary, IndicatorSummaryTranslation
|
||||||
|
from app.services.output_review import review_read
|
||||||
|
|
||||||
|
|
||||||
|
async def _judge(client, sem, row):
|
||||||
|
async with sem:
|
||||||
|
v = await review_read(client, row.content or "")
|
||||||
|
return row, v
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args):
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(IndicatorSummary).order_by(IndicatorSummary.id)
|
||||||
|
)).scalars().all()
|
||||||
|
print(f"Reviewing {len(rows)} IndicatorSummary rows…")
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(args.concurrency)
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
results = await asyncio.gather(*(_judge(client, sem, r) for r in rows))
|
||||||
|
|
||||||
|
unclean = [(r, v) for r, v in results if not v.clean]
|
||||||
|
print(f"\nFlagged {len(unclean)} of {len(rows)} as unclean.")
|
||||||
|
for r, v in unclean:
|
||||||
|
head = (r.content or "")[:100].replace("\n", " ")
|
||||||
|
print(f" id={r.id} group={r.group_name} tone={r.tone} "
|
||||||
|
f"at {r.generated_at} reason={v.reason!r}")
|
||||||
|
print(f" preview: {head!r}")
|
||||||
|
|
||||||
|
if args.dry_run or not unclean:
|
||||||
|
return
|
||||||
|
|
||||||
|
ids = [r.id for r, _ in unclean]
|
||||||
|
await session.execute(
|
||||||
|
delete(IndicatorSummaryTranslation)
|
||||||
|
.where(IndicatorSummaryTranslation.summary_id.in_(ids))
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(IndicatorSummary).where(IndicatorSummary.id.in_(ids))
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
print(f"\nDeleted {len(ids)} unclean row(s). The dashboard's /api/indicators/"
|
||||||
|
"<group> endpoint will now fall back to the previous clean row "
|
||||||
|
"for each (group, tone).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--dry-run", action="store_true")
|
||||||
|
p.add_argument("--concurrency", type=int, default=8,
|
||||||
|
help="Parallel reviewer calls (default 8)")
|
||||||
|
asyncio.run(main(p.parse_args()))
|
||||||
|
|
@ -17,3 +17,72 @@ sys.path.insert(0, str(ROOT))
|
||||||
# Sentinel env so importing app.config doesn't try to read a missing .env.
|
# Sentinel env so importing app.config doesn't try to read a missing .env.
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||||
os.environ.setdefault("CASSANDRA_MOCK", "1")
|
os.environ.setdefault("CASSANDRA_MOCK", "1")
|
||||||
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def stub_reviewer(monkeypatch):
|
||||||
|
"""Replace review_read with a clean-passing stub in every consumer
|
||||||
|
module. Tests that mock the generator's call_llm shouldn't also
|
||||||
|
have to mock the reviewer that runs after it — the reviewer is a
|
||||||
|
safety gate, not behaviour under test.
|
||||||
|
|
||||||
|
Tests in test_output_review.py exercise review_read through its
|
||||||
|
own module and are unaffected. Tests that want to assert the
|
||||||
|
reviewer-rejected branch can override with their own
|
||||||
|
monkeypatch.setattr — later wins.
|
||||||
|
"""
|
||||||
|
from app.services.output_review import Verdict
|
||||||
|
|
||||||
|
async def _clean(_client, _candidate):
|
||||||
|
return Verdict(clean=True, reason="stubbed-by-conftest", cost_usd=0.0)
|
||||||
|
|
||||||
|
for mod_path in (
|
||||||
|
"app.services.portfolio_analysis",
|
||||||
|
"app.routers.chat",
|
||||||
|
"app.jobs.ai_log_job",
|
||||||
|
"app.jobs.email_digest_job",
|
||||||
|
"app.jobs.indicator_summary_job",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
mod = __import__(mod_path, fromlist=["review_read"])
|
||||||
|
except ImportError:
|
||||||
|
continue
|
||||||
|
if hasattr(mod, "review_read"):
|
||||||
|
monkeypatch.setattr(mod, "review_read", _clean)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_factory(tmp_path):
|
||||||
|
"""Per-test sqlite engine + async session factory.
|
||||||
|
|
||||||
|
Creates a fresh sqlite database file under ``tmp_path``, applies
|
||||||
|
``Base.metadata.create_all``, and rebinds ``app.db._engine`` /
|
||||||
|
``app.db._session_factory`` so module-level helpers (which look
|
||||||
|
these up at call time) see the test engine.
|
||||||
|
|
||||||
|
Yields the ``async_sessionmaker``. Tests use it like:
|
||||||
|
|
||||||
|
async def test_foo(db_factory):
|
||||||
|
async with db_factory() as session:
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
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}/test.db")
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
db_mod._engine = engine
|
||||||
|
db_mod._session_factory = factory
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield factory
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
|
||||||
81
tests/test_auth_session.py
Normal file
81
tests/test_auth_session.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Session cookie sign/verify — security-critical edges that the
|
||||||
|
existing test suite uses as a fixture (``sign_session(1)`` for cookies)
|
||||||
|
but doesn't actually probe.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Round-trip: sign(user_id) → verify → user_id
|
||||||
|
- Tampered cookie → None (not raised)
|
||||||
|
- Expired cookie → None (via itsdangerous max_age)
|
||||||
|
- Garbage / non-serializer-format input → None
|
||||||
|
- Wrong-salt isolation: a pending cookie can't be unlocked by the
|
||||||
|
session verifier (and vice versa)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_signed_token_round_trips():
|
||||||
|
cookie = auth.sign_session(42)
|
||||||
|
assert auth.verify_session(cookie) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_token_is_opaque_url_safe():
|
||||||
|
"""Sanity: the serializer produces a URL-safe string with at least
|
||||||
|
two dot-separated segments (payload.timestamp.signature). Not a
|
||||||
|
semantic test, but catches a future swap to an un-encoded format."""
|
||||||
|
cookie = auth.sign_session(7)
|
||||||
|
assert "." in cookie
|
||||||
|
assert " " not in cookie
|
||||||
|
|
||||||
|
|
||||||
|
def test_tampered_session_cookie_returns_none():
|
||||||
|
"""Flip a single character in the signature segment and verify
|
||||||
|
the cookie no longer authenticates — without exceptions leaking."""
|
||||||
|
cookie = auth.sign_session(99)
|
||||||
|
# Flip the last character (signature segment).
|
||||||
|
tampered = cookie[:-1] + ("a" if cookie[-1] != "a" else "b")
|
||||||
|
assert auth.verify_session(tampered) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_session_cookie_returns_none():
|
||||||
|
assert auth.verify_session("not-a-real-cookie") is None
|
||||||
|
assert auth.verify_session("") is None
|
||||||
|
assert auth.verify_session("a.b.c") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_session_cookie_returns_none(monkeypatch):
|
||||||
|
"""Forge a cookie with an ancient timestamp and confirm the TTL
|
||||||
|
check rejects it. We bypass sign_session() so the timestamp is
|
||||||
|
in our control rather than `now`."""
|
||||||
|
s = auth._serializer()
|
||||||
|
# itsdangerous stores the issued-at timestamp in a base62 segment.
|
||||||
|
# Easier than hand-building: monkeypatch the SESSION_TTL_SECONDS
|
||||||
|
# to a negative value so any freshly-signed cookie is "expired"
|
||||||
|
# the moment we verify it.
|
||||||
|
cookie = auth.sign_session(123)
|
||||||
|
monkeypatch.setattr(auth, "SESSION_TTL_SECONDS", -1)
|
||||||
|
assert auth.verify_session(cookie) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_serializer_isolated_from_pending_serializer():
|
||||||
|
"""A pending-verify cookie must not authenticate as a session
|
||||||
|
(different salts), and vice versa — otherwise the half-finished
|
||||||
|
OTP flow becomes a free login."""
|
||||||
|
pending = auth.sign_pending("u@x", 5)
|
||||||
|
session = auth.sign_session(5)
|
||||||
|
assert auth.verify_session(pending) is None
|
||||||
|
assert auth.verify_pending(session) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_cookie_signed_with_different_secret_rejected(monkeypatch):
|
||||||
|
"""Defence-in-depth: signing with a different secret produces a
|
||||||
|
cookie that the live verifier (using the configured secret)
|
||||||
|
rejects. Confirms we're actually checking the HMAC, not just the
|
||||||
|
payload format."""
|
||||||
|
rogue = URLSafeTimedSerializer("totally-different-secret",
|
||||||
|
salt="cassandra-session-v1")
|
||||||
|
rogue_cookie = rogue.dumps({"uid": 1})
|
||||||
|
assert auth.verify_session(rogue_cookie) is None
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Drift-detection: brand palette in `app/branding.py` must match the CSS.
|
"""Drift-detection: brand palette in `app/branding.py` must match the CSS.
|
||||||
|
|
||||||
Both the website (cassandra.css) and the email templates use the same
|
Both the website (tokens.css) and the email templates use the same
|
||||||
palette. The CSS hand-authors the values in :root and [data-theme="light"]
|
palette. The CSS hand-authors the values in :root and [data-theme="light"]
|
||||||
blocks; this test parses those blocks and asserts every variable matches
|
blocks; this test parses those blocks and asserts every variable matches
|
||||||
its counterpart in branding.py. If a colour changes, both must change.
|
its counterpart in branding.py. If a colour changes, both must change.
|
||||||
|
|
@ -15,7 +15,7 @@ import pytest
|
||||||
from app import branding
|
from app import branding
|
||||||
|
|
||||||
|
|
||||||
CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "cassandra.css"
|
CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "tokens.css"
|
||||||
|
|
||||||
|
|
||||||
def _extract_vars(css: str, selector: str) -> dict[str, str]:
|
def _extract_vars(css: str, selector: str) -> dict[str, str]:
|
||||||
|
|
@ -23,7 +23,7 @@ def _extract_vars(css: str, selector: str) -> dict[str, str]:
|
||||||
selector block. Strips whitespace; lowercases hex values."""
|
selector block. Strips whitespace; lowercases hex values."""
|
||||||
# Match the selector followed by its block. Non-greedy on the body to
|
# Match the selector followed by its block. Non-greedy on the body to
|
||||||
# stop at the first closing brace at the same depth (these blocks
|
# stop at the first closing brace at the same depth (these blocks
|
||||||
# don't nest in cassandra.css).
|
# don't nest in tokens.css).
|
||||||
pattern = re.escape(selector) + r"\s*\{([^}]*)\}"
|
pattern = re.escape(selector) + r"\s*\{([^}]*)\}"
|
||||||
m = re.search(pattern, css)
|
m = re.search(pattern, css)
|
||||||
if not m:
|
if not m:
|
||||||
|
|
|
||||||
163
tests/test_cadence_policy.py
Normal file
163
tests/test_cadence_policy.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""Cadence policy — the gate that ai_log_job and indicator_summary_job
|
||||||
|
use to throttle OpenRouter spend outside active market hours.
|
||||||
|
|
||||||
|
Pure-function module, so tests just construct timestamps and assert on
|
||||||
|
the (should_run, reason) tuple. Uses the default policy (active window
|
||||||
|
07:00-21:00 UTC weekdays, no off-hours runs without 4+ hours since
|
||||||
|
last success, weekends 12+ hours).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.cadence import DEFAULT_POLICY, NEWS_POLICY, CadencePolicy
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(year, month, day, hour, minute=0):
|
||||||
|
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# Pick reference timestamps used across tests. Wednesday 12:00 UTC is
|
||||||
|
# squarely inside the active window; Wednesday 03:00 is off-hours;
|
||||||
|
# Saturday 12:00 is weekend.
|
||||||
|
_WED_NOON = _utc(2026, 5, 27, 12) # Wednesday 12:00
|
||||||
|
_WED_PRE_DAWN = _utc(2026, 5, 27, 3) # Wednesday 03:00
|
||||||
|
_SAT_NOON = _utc(2026, 5, 30, 12) # Saturday 12:00
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_active_window
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_window_weekday_noon_is_active():
|
||||||
|
assert DEFAULT_POLICY.is_active_window(_WED_NOON) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_window_weekday_predawn_is_off_hours():
|
||||||
|
assert DEFAULT_POLICY.is_active_window(_WED_PRE_DAWN) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_window_weekend_always_off_hours():
|
||||||
|
"""Weekends bypass the hour check — even Saturday noon is throttled."""
|
||||||
|
assert DEFAULT_POLICY.is_active_window(_SAT_NOON) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_window_boundary_inclusive_start_exclusive_end():
|
||||||
|
"""07:00 UTC is the first active hour; 21:00 is the first off-hour.
|
||||||
|
Locks the half-open interval semantics in place."""
|
||||||
|
assert DEFAULT_POLICY.is_active_window(_utc(2026, 5, 27, 7)) is True
|
||||||
|
assert DEFAULT_POLICY.is_active_window(_utc(2026, 5, 27, 21)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# min_gap_hours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_gap_uses_zero_during_active_window():
|
||||||
|
assert DEFAULT_POLICY.min_gap_hours(_WED_NOON) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_gap_uses_off_hours_value_at_night():
|
||||||
|
assert DEFAULT_POLICY.min_gap_hours(_WED_PRE_DAWN) == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_gap_uses_weekend_value_on_saturday():
|
||||||
|
assert DEFAULT_POLICY.min_gap_hours(_SAT_NOON) == 12.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# should_run — the function jobs call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_run_first_ever_call_always_proceeds():
|
||||||
|
ok, reason = DEFAULT_POLICY.should_run(None, now=_WED_NOON)
|
||||||
|
assert ok is True
|
||||||
|
assert "no prior" in reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_run_during_active_window_always_proceeds():
|
||||||
|
"""Default policy has active_gap_h=0, so even a run from 1 minute ago
|
||||||
|
is allowed when we're in the active window."""
|
||||||
|
last = _WED_NOON - timedelta(minutes=1)
|
||||||
|
ok, reason = DEFAULT_POLICY.should_run(last, now=_WED_NOON)
|
||||||
|
assert ok is True
|
||||||
|
assert "active" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_run_off_hours_too_soon_is_throttled():
|
||||||
|
"""Off-hours requires 4+ hours since last success. 1 hour ago → no."""
|
||||||
|
last = _WED_PRE_DAWN - timedelta(hours=1)
|
||||||
|
ok, reason = DEFAULT_POLICY.should_run(last, now=_WED_PRE_DAWN)
|
||||||
|
assert ok is False
|
||||||
|
assert "throttled" in reason
|
||||||
|
assert "off-hours" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_run_off_hours_after_gap_proceeds():
|
||||||
|
last = _WED_PRE_DAWN - timedelta(hours=5)
|
||||||
|
ok, reason = DEFAULT_POLICY.should_run(last, now=_WED_PRE_DAWN)
|
||||||
|
assert ok is True
|
||||||
|
assert "off-hours" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_run_weekend_requires_12h_gap():
|
||||||
|
"""Weekend gap is 12h. 6h is too soon; 13h is enough."""
|
||||||
|
ok6, _ = DEFAULT_POLICY.should_run(
|
||||||
|
_SAT_NOON - timedelta(hours=6), now=_SAT_NOON,
|
||||||
|
)
|
||||||
|
ok13, _ = DEFAULT_POLICY.should_run(
|
||||||
|
_SAT_NOON - timedelta(hours=13), now=_SAT_NOON,
|
||||||
|
)
|
||||||
|
assert ok6 is False
|
||||||
|
assert ok13 is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_run_naive_datetime_treated_as_utc():
|
||||||
|
"""The DB column comes back as a naive datetime in some test paths;
|
||||||
|
the policy must coerce it to UTC rather than crash on tz subtraction."""
|
||||||
|
naive_last = _WED_PRE_DAWN.replace(tzinfo=None) - timedelta(hours=5)
|
||||||
|
ok, _ = DEFAULT_POLICY.should_run(naive_last, now=_WED_PRE_DAWN)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NEWS_POLICY — tighter gaps so 3 runs/hour during the active window.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_news_policy_active_gap_is_twenty_minutes():
|
||||||
|
# 20 minutes = 1/3 hour. Verify a 15-min-ago run is throttled but
|
||||||
|
# a 21-min-ago one is allowed.
|
||||||
|
last_15 = _WED_NOON - timedelta(minutes=15)
|
||||||
|
last_21 = _WED_NOON - timedelta(minutes=21)
|
||||||
|
assert NEWS_POLICY.should_run(last_15, now=_WED_NOON)[0] is False
|
||||||
|
assert NEWS_POLICY.should_run(last_21, now=_WED_NOON)[0] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_news_policy_off_hours_gap_is_three_hours():
|
||||||
|
last_2h = _WED_PRE_DAWN - timedelta(hours=2)
|
||||||
|
last_4h = _WED_PRE_DAWN - timedelta(hours=4)
|
||||||
|
assert NEWS_POLICY.should_run(last_2h, now=_WED_PRE_DAWN)[0] is False
|
||||||
|
assert NEWS_POLICY.should_run(last_4h, now=_WED_PRE_DAWN)[0] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bespoke policy — confirms the dataclass is reconfigurable for callers
|
||||||
|
# (the audit flagged this as risky to over-fit to defaults).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_policy_with_active_gap_throttles_within_window():
|
||||||
|
"""active_gap_h=0.5 means even during the active window a run from
|
||||||
|
20 minutes ago is throttled — verifies the gate isn't hardcoded to
|
||||||
|
'always run during active'."""
|
||||||
|
p = CadencePolicy(active_gap_h=0.5)
|
||||||
|
last = _WED_NOON - timedelta(minutes=20)
|
||||||
|
ok, reason = p.should_run(last, now=_WED_NOON)
|
||||||
|
assert ok is False
|
||||||
|
assert "throttled" in reason
|
||||||
|
|
@ -23,6 +23,7 @@ def _build_app(tmp_path):
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models import StrategicLog, User
|
from app.models import StrategicLog, User
|
||||||
from app.routers import api as api_router
|
from app.routers import api as api_router
|
||||||
|
from app.routers import chat as chat_router
|
||||||
|
|
||||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/gates.db")
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/gates.db")
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
@ -56,6 +57,7 @@ def _build_app(tmp_path):
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(api_router.router, prefix="/api")
|
app.include_router(api_router.router, prefix="/api")
|
||||||
|
app.include_router(chat_router.router, prefix="/api")
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
return client, sign_session(1), sign_session(2)
|
return client, sign_session(1), sign_session(2)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.services.openrouter import (
|
from app.services.llm_prompts import (
|
||||||
build_daily_digest_prompt,
|
build_daily_digest_prompt,
|
||||||
build_weekly_digest_prompt,
|
build_weekly_digest_prompt,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Unit tests for render_digest_email."""
|
"""Unit tests for render_digest_email."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.services.email_service import render_digest_email
|
from app.services.digest_email import render_digest_email
|
||||||
|
|
||||||
|
|
||||||
def test_daily_subject_and_bodies():
|
def test_daily_subject_and_bodies():
|
||||||
|
|
|
||||||
115
tests/test_i18n.py
Normal file
115
tests/test_i18n.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""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") == ""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
@ -4,26 +4,6 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _build_session_factory(tmp_path):
|
|
||||||
"""Spin up a fresh in-memory schema and return (engine, factory).
|
|
||||||
Matches the pattern used in tests/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}/csv.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_csv_format_template_model_columns():
|
def test_csv_format_template_model_columns():
|
||||||
"""Model exposes every column the spec requires, with correct types."""
|
"""Model exposes every column the spec requires, with correct types."""
|
||||||
|
|
@ -42,8 +22,10 @@ def test_csv_format_template_model_columns():
|
||||||
assert "first_seen_at" in cols
|
assert "first_seen_at" in cols
|
||||||
assert "use_count" in cols
|
assert "use_count" in cols
|
||||||
assert "last_used_at" in cols
|
assert "last_used_at" in cols
|
||||||
assert "llm_model" in cols
|
assert "model" in cols
|
||||||
assert "llm_cost_usd" in cols
|
assert "cost_usd" in cols
|
||||||
|
assert "prompt_tokens" in cols
|
||||||
|
assert "completion_tokens" in cols
|
||||||
# Crucially, no user attribution.
|
# Crucially, no user attribution.
|
||||||
assert "user_id" not in cols
|
assert "user_id" not in cols
|
||||||
assert "first_seen_user_id" not in cols
|
assert "first_seen_user_id" not in cols
|
||||||
|
|
@ -243,7 +225,6 @@ def test_apply_mapping_skips_blank_and_unparseable_rows():
|
||||||
assert [p.slice for p in pie.positions] == ["AAPL", "NVDA"]
|
assert [p.slice for p in pie.positions] == ["AAPL", "NVDA"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extract_mapping_via_llm_parses_valid_json():
|
async def test_extract_mapping_via_llm_parses_valid_json():
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.llm_csv_parser import _extract_mapping_via_llm
|
from app.services.llm_csv_parser import _extract_mapping_via_llm
|
||||||
|
|
@ -275,7 +256,6 @@ async def test_extract_mapping_via_llm_parses_valid_json():
|
||||||
fake_call_llm.assert_awaited_once()
|
fake_call_llm.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extract_mapping_via_llm_malformed_json_raises():
|
async def test_extract_mapping_via_llm_malformed_json_raises():
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
|
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
|
||||||
|
|
@ -298,7 +278,6 @@ async def test_extract_mapping_via_llm_malformed_json_raises():
|
||||||
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
|
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extract_mapping_via_llm_provider_failure_wraps():
|
async def test_extract_mapping_via_llm_provider_failure_wraps():
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
|
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
|
||||||
|
|
@ -313,8 +292,7 @@ async def test_extract_mapping_via_llm_provider_failure_wraps():
|
||||||
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
|
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_parse_with_llm_cache_miss_inserts_template(db_factory):
|
||||||
async def test_parse_with_llm_cache_miss_inserts_template(tmp_path):
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
@ -322,8 +300,7 @@ async def test_parse_with_llm_cache_miss_inserts_template(tmp_path):
|
||||||
from app.services.llm_csv_parser import parse_with_llm
|
from app.services.llm_csv_parser import parse_with_llm
|
||||||
from app.services.openrouter import LogResult
|
from app.services.openrouter import LogResult
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
raw = (
|
raw = (
|
||||||
b"Symbol,Quantity,Avg Price,Currency\n"
|
b"Symbol,Quantity,Avg Price,Currency\n"
|
||||||
|
|
@ -355,13 +332,12 @@ async def test_parse_with_llm_cache_miss_inserts_template(tmp_path):
|
||||||
assert tmpl.mapping["ticker_col"] == "Symbol"
|
assert tmpl.mapping["ticker_col"] == "Symbol"
|
||||||
assert tmpl.broker_label == "Generic broker"
|
assert tmpl.broker_label == "Generic broker"
|
||||||
assert tmpl.use_count == 1
|
assert tmpl.use_count == 1
|
||||||
assert tmpl.llm_cost_usd == pytest.approx(0.0002)
|
assert tmpl.cost_usd == pytest.approx(0.0002)
|
||||||
# The crucial PII guarantee:
|
# The crucial PII guarantee:
|
||||||
assert not hasattr(tmpl, "user_id"), "sample row must not be linked to a user"
|
assert not hasattr(tmpl, "user_id"), "sample row must not be linked to a user"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_parse_with_llm_cache_hit_skips_llm(db_factory):
|
||||||
async def test_parse_with_llm_cache_hit_skips_llm(tmp_path):
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
@ -369,8 +345,7 @@ async def test_parse_with_llm_cache_hit_skips_llm(tmp_path):
|
||||||
from app.models import CsvFormatTemplate
|
from app.models import CsvFormatTemplate
|
||||||
from app.services.llm_csv_parser import _fingerprint, parse_with_llm
|
from app.services.llm_csv_parser import _fingerprint, parse_with_llm
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
headers = ["Symbol", "Quantity", "Avg Price", "Currency"]
|
headers = ["Symbol", "Quantity", "Avg Price", "Currency"]
|
||||||
fp = _fingerprint(headers)
|
fp = _fingerprint(headers)
|
||||||
|
|
@ -392,8 +367,8 @@ async def test_parse_with_llm_cache_hit_skips_llm(tmp_path):
|
||||||
first_seen_at=utcnow(),
|
first_seen_at=utcnow(),
|
||||||
last_used_at=utcnow(),
|
last_used_at=utcnow(),
|
||||||
use_count=1,
|
use_count=1,
|
||||||
llm_model="seed",
|
model="seed",
|
||||||
llm_cost_usd=0.0,
|
cost_usd=0.0,
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
@ -416,8 +391,7 @@ async def test_parse_with_llm_cache_hit_skips_llm(tmp_path):
|
||||||
assert rows[0].use_count == 2
|
assert rows[0].use_count == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(db_factory):
|
||||||
async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
@ -425,8 +399,7 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
|
||||||
from app.models import CsvFormatTemplate
|
from app.models import CsvFormatTemplate
|
||||||
from app.services.llm_csv_parser import LLMParseError, _fingerprint, parse_with_llm
|
from app.services.llm_csv_parser import LLMParseError, _fingerprint, parse_with_llm
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
headers = ["Symbol", "Quantity"]
|
headers = ["Symbol", "Quantity"]
|
||||||
fp = _fingerprint(headers)
|
fp = _fingerprint(headers)
|
||||||
|
|
@ -439,7 +412,7 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
|
||||||
mapping={"ticker_col": "Symbol", "qty_col": "Symbol"},
|
mapping={"ticker_col": "Symbol", "qty_col": "Symbol"},
|
||||||
preamble_rows=0, delimiter=",", broker_label=None,
|
preamble_rows=0, delimiter=",", broker_label=None,
|
||||||
first_seen_at=utcnow(), last_used_at=utcnow(), use_count=1,
|
first_seen_at=utcnow(), last_used_at=utcnow(), use_count=1,
|
||||||
llm_model="seed", llm_cost_usd=0.0,
|
model="seed", cost_usd=0.0,
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
@ -458,8 +431,7 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_parse_portfolio_route_falls_through_to_llm(db_factory, monkeypatch):
|
||||||
async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch):
|
|
||||||
"""End-to-end: T212 parser raises CSVImportError, LLM fallback runs,
|
"""End-to-end: T212 parser raises CSVImportError, LLM fallback runs,
|
||||||
response shape matches the existing JSON contract."""
|
response shape matches the existing JSON contract."""
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
@ -468,8 +440,7 @@ async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch)
|
||||||
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
import app.services.llm_csv_parser as mod
|
import app.services.llm_csv_parser as mod
|
||||||
from app.services.openrouter import LogResult
|
from app.services.openrouter import LogResult
|
||||||
|
|
|
||||||
361
tests/test_localization_integration.py
Normal file
361
tests/test_localization_integration.py
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
"""Integration tests: model surface, ai_log_job translation fan-out,
|
||||||
|
route-level localized fetch, settings PATCH validation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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" in cols
|
||||||
|
assert "generated_at" in cols
|
||||||
|
assert "model" in cols
|
||||||
|
assert "cost_usd" in cols
|
||||||
|
assert cols["log_id"].nullable is False
|
||||||
|
assert cols["lang"].nullable is False
|
||||||
|
assert cols["content"].nullable is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_log_translation_fanout_no_active_non_en_users(db_factory, 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 = db_factory
|
||||||
|
|
||||||
|
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="# Open\n\nDown 0.4%.",
|
||||||
|
model="test-model",
|
||||||
|
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 == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_log_translation_fanout_italian_user(db_factory, monkeypatch):
|
||||||
|
"""One user at lang=it triggers one translation; the row lands with
|
||||||
|
the right lang and log_id."""
|
||||||
|
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 = db_factory
|
||||||
|
|
||||||
|
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="# Open\n\nDown 0.4%.",
|
||||||
|
model="test-model",
|
||||||
|
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.startswith("# Apertura")
|
||||||
|
assert row.model == "deepseek/deepseek-v4-flash"
|
||||||
|
assert row.cost_usd == pytest.approx(0.00002)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_log_translation_fanout_per_language_failure_isolated(db_factory, monkeypatch):
|
||||||
|
"""If one language's translation fails, the others (if any) still land
|
||||||
|
and the job does not raise."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.models import StrategicLog, StrategicLogTranslation, User
|
||||||
|
from app.jobs import ai_log_job
|
||||||
|
|
||||||
|
factory = db_factory
|
||||||
|
|
||||||
|
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="# Open",
|
||||||
|
model="test-model",
|
||||||
|
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 == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_analyse_threads_lang_into_system_prompt(db_factory, 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 = db_factory
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def test_analyse_no_clause_when_lang_is_en(db_factory, 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 = db_factory
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def test_digest_translates_variants_per_active_lang(monkeypatch):
|
||||||
|
"""After English variants are built, the job translates each to every
|
||||||
|
active non-en lang. The result is an in-memory mapping the send loop
|
||||||
|
consults."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from app.jobs import email_digest_job as ed
|
||||||
|
from app.services.openrouter import LogResult
|
||||||
|
|
||||||
|
english_variants = {
|
||||||
|
"NOVICE": "**Today.** Markets calmer.",
|
||||||
|
"INTERMEDIATE": "**Today.** Indices slightly down.",
|
||||||
|
"PRO": "**Today.** Risk-off rotation, breadth weak.",
|
||||||
|
}
|
||||||
|
|
||||||
|
translate_calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def _fake_translate(client, text, target_lang):
|
||||||
|
translate_calls.append((text, target_lang))
|
||||||
|
return f"[IT] {text}", LogResult(
|
||||||
|
content=f"[IT] {text}", model="m",
|
||||||
|
prompt_tokens=10, completion_tokens=10, cost_usd=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ed, "translate", _fake_translate)
|
||||||
|
|
||||||
|
client = MagicMock()
|
||||||
|
table = await ed._translate_variants_for_active_langs(
|
||||||
|
client, english_variants, ["it"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Three tones × one non-en lang = three translation calls.
|
||||||
|
assert len(translate_calls) == 3
|
||||||
|
assert {lang for _, lang in translate_calls} == {"it"}
|
||||||
|
|
||||||
|
# English entries are present unchanged.
|
||||||
|
assert table[("NOVICE", "en")] == english_variants["NOVICE"]
|
||||||
|
assert table[("PRO", "en")] == english_variants["PRO"]
|
||||||
|
# Italian entries are populated.
|
||||||
|
assert table[("INTERMEDIATE", "it")].startswith("[IT] ")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_digest_translation_failure_falls_back_to_english(monkeypatch):
|
||||||
|
"""When translate() fails for a (tone, lang) cell, the table entry
|
||||||
|
for that cell is the English variant of the same tone — the user
|
||||||
|
still gets a digest, just in English that day."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from app.jobs import email_digest_job as ed
|
||||||
|
|
||||||
|
english_variants = {"INTERMEDIATE": "**Today.** Indices down."}
|
||||||
|
|
||||||
|
async def _fake_translate(client, text, target_lang):
|
||||||
|
raise RuntimeError("upstream down")
|
||||||
|
monkeypatch.setattr(ed, "translate", _fake_translate)
|
||||||
|
|
||||||
|
client = MagicMock()
|
||||||
|
table = await ed._translate_variants_for_active_langs(
|
||||||
|
client, english_variants, ["it"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert table[("INTERMEDIATE", "it")] == english_variants["INTERMEDIATE"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_pick_variant_uses_user_lang():
|
||||||
|
"""The variant-picker helper consults user.digest_tone + user.lang."""
|
||||||
|
from app.jobs import email_digest_job as ed
|
||||||
|
|
||||||
|
table = {
|
||||||
|
("NOVICE", "en"): "novice en",
|
||||||
|
("NOVICE", "it"): "novice it",
|
||||||
|
("INTERMEDIATE", "en"): "intermediate en",
|
||||||
|
("INTERMEDIATE", "it"): "intermediate it",
|
||||||
|
}
|
||||||
|
assert ed._pick_variant(table, tone="NOVICE", lang="it") == "novice it"
|
||||||
|
assert ed._pick_variant(table, tone="INTERMEDIATE", lang="en") == "intermediate en"
|
||||||
|
# Missing lang → fallback to English variant of the same tone.
|
||||||
|
assert ed._pick_variant(table, tone="NOVICE", lang="de") == "novice en"
|
||||||
|
# Missing tone → fallback to INTERMEDIATE/en (the safe default).
|
||||||
|
assert ed._pick_variant(table, tone="UNKNOWN", lang="en") == "intermediate en"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_language_accepts_active(db_factory):
|
||||||
|
"""PATCH /api/settings/language accepts 'en' and 'it' and persists."""
|
||||||
|
from app.models import User
|
||||||
|
from app.routers.api import patch_language_prefs, LanguagePrefsIn
|
||||||
|
|
||||||
|
factory = db_factory
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_language_rejects_wip(db_factory):
|
||||||
|
"""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 = db_factory
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -9,7 +9,7 @@ pytest.importorskip("pydantic_settings")
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.services.openrouter import SYSTEM_PROMPT, build_user_prompt
|
from app.services.llm_prompts import SYSTEM_PROMPT, build_user_prompt
|
||||||
|
|
||||||
|
|
||||||
def test_system_prompt_has_voice_anchors():
|
def test_system_prompt_has_voice_anchors():
|
||||||
|
|
@ -35,7 +35,7 @@ def test_pro_tone_falls_back_to_intermediate():
|
||||||
"""PRO was removed in PROMPT_VERSION 6 (audience pivot to young
|
"""PRO was removed in PROMPT_VERSION 6 (audience pivot to young
|
||||||
investors). Legacy callers that still pass PRO should get the
|
investors). Legacy callers that still pass PRO should get the
|
||||||
INTERMEDIATE prompt rather than a KeyError."""
|
INTERMEDIATE prompt rather than a KeyError."""
|
||||||
from app.services.openrouter import build_system_prompt
|
from app.services.llm_prompts import build_system_prompt
|
||||||
pro = build_system_prompt("PRO", "SPECULATIVE")
|
pro = build_system_prompt("PRO", "SPECULATIVE")
|
||||||
inter = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
|
inter = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
|
||||||
assert pro == inter
|
assert pro == inter
|
||||||
|
|
|
||||||
258
tests/test_openrouter_transport.py
Normal file
258
tests/test_openrouter_transport.py
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""Transport-layer tests for app.services.openrouter.
|
||||||
|
|
||||||
|
The companion file `test_openrouter_prompt.py` covers prompt building;
|
||||||
|
this one covers the HTTP plumbing: provider chain selection, endpoint
|
||||||
|
resolution, the per-call retry/parse path in `_call_provider`, and
|
||||||
|
fallback behaviour in `call_llm`. Network requests are intercepted with
|
||||||
|
``httpx.MockTransport`` so nothing hits the wire.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.services import openrouter as ot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _estimate_cost_usd
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_cost_known_model_uses_table_rates():
|
||||||
|
# deepseek-v4-flash table: 0.07/M input, 0.28/M output.
|
||||||
|
# 1000 in + 2000 out = 0.000_07 + 0.000_56 = 0.000_63.
|
||||||
|
cost = ot._estimate_cost_usd("deepseek-v4-flash", 1000, 2000)
|
||||||
|
assert cost == pytest.approx(0.00063, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_cost_handles_provider_prefixed_model_name():
|
||||||
|
# OpenRouter-style model strings use the slash-prefixed form.
|
||||||
|
cost = ot._estimate_cost_usd("deepseek/deepseek-v4-flash", 1000, 2000)
|
||||||
|
assert cost == pytest.approx(0.00063, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_cost_unknown_model_returns_none():
|
||||||
|
assert ot._estimate_cost_usd("never-heard-of-this-model", 100, 200) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_cost_missing_tokens_returns_none():
|
||||||
|
assert ot._estimate_cost_usd("deepseek-v4-flash", None, 200) is None
|
||||||
|
assert ot._estimate_cost_usd("deepseek-v4-flash", 100, None) is None
|
||||||
|
assert ot._estimate_cost_usd("deepseek-v4-flash", None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _provider_chain / llm_configured / active_model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(monkeypatch, **overrides):
|
||||||
|
"""Apply a small bundle of LLM settings for one test."""
|
||||||
|
s = get_settings()
|
||||||
|
defaults = {
|
||||||
|
"LLM_PROVIDER": "deepseek",
|
||||||
|
"LLM_FALLBACK": "openrouter",
|
||||||
|
"DEEPSEEK_API_KEY": "",
|
||||||
|
"OPENROUTER_API_KEY": "",
|
||||||
|
"DEEPSEEK_MODEL": "deepseek-v4-flash",
|
||||||
|
"OPENROUTER_MODEL": "deepseek/deepseek-v4-flash",
|
||||||
|
"DEEPSEEK_URL": "https://api.deepseek.com/chat/completions",
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
for k, v in defaults.items():
|
||||||
|
monkeypatch.setattr(s, k, v, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_chain_drops_providers_without_keys(monkeypatch):
|
||||||
|
_configure(monkeypatch, DEEPSEEK_API_KEY="sk-deepseek") # openrouter key missing
|
||||||
|
assert ot._provider_chain() == ["deepseek"]
|
||||||
|
assert ot.llm_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_chain_lists_primary_then_fallback(monkeypatch):
|
||||||
|
_configure(monkeypatch,
|
||||||
|
DEEPSEEK_API_KEY="sk-deepseek", OPENROUTER_API_KEY="sk-openrouter")
|
||||||
|
assert ot._provider_chain() == ["deepseek", "openrouter"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_chain_skips_duplicate_when_primary_equals_fallback(monkeypatch):
|
||||||
|
_configure(monkeypatch, LLM_FALLBACK="deepseek", DEEPSEEK_API_KEY="sk")
|
||||||
|
assert ot._provider_chain() == ["deepseek"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_configured_false_when_no_keys(monkeypatch):
|
||||||
|
_configure(monkeypatch) # both keys empty
|
||||||
|
assert ot.llm_configured() is False
|
||||||
|
assert ot._provider_chain() == []
|
||||||
|
assert ot.active_model() == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_model_reflects_primary(monkeypatch):
|
||||||
|
_configure(monkeypatch,
|
||||||
|
LLM_PROVIDER="openrouter", OPENROUTER_API_KEY="sk-or",
|
||||||
|
DEEPSEEK_API_KEY="")
|
||||||
|
assert ot.active_model() == "deepseek/deepseek-v4-flash" # OPENROUTER_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _endpoint_for
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_for_unknown_provider_raises(monkeypatch):
|
||||||
|
_configure(monkeypatch, DEEPSEEK_API_KEY="sk")
|
||||||
|
with pytest.raises(RuntimeError, match="Unknown LLM provider"):
|
||||||
|
ot._endpoint_for("anthropic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_for_provider_without_key_raises(monkeypatch):
|
||||||
|
_configure(monkeypatch) # both keys empty
|
||||||
|
with pytest.raises(RuntimeError, match="DEEPSEEK_API_KEY not set"):
|
||||||
|
ot._endpoint_for("deepseek")
|
||||||
|
with pytest.raises(RuntimeError, match="OPENROUTER_API_KEY not set"):
|
||||||
|
ot._endpoint_for("openrouter")
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_for_openrouter_includes_attribution_and_no_train_headers(monkeypatch):
|
||||||
|
_configure(monkeypatch, OPENROUTER_API_KEY="sk-or")
|
||||||
|
url, key, model, headers = ot._endpoint_for("openrouter")
|
||||||
|
assert url.endswith("/chat/completions")
|
||||||
|
assert key == "sk-or"
|
||||||
|
assert headers["X-OR-Allow-Training"] == "false"
|
||||||
|
assert "HTTP-Referer" in headers and "X-Title" in headers
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _call_provider (through call_llm so retry doesn't fire — happy paths only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_post(callback):
|
||||||
|
"""Wrap a callback into an httpx.MockTransport. Callback receives the
|
||||||
|
request and returns either an httpx.Response or raises."""
|
||||||
|
return httpx.MockTransport(callback)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_llm_returns_parsed_log_result(monkeypatch):
|
||||||
|
_configure(monkeypatch, DEEPSEEK_API_KEY="sk-deepseek", LLM_FALLBACK="")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
body = json.loads(request.content.decode())
|
||||||
|
assert body["model"] == "deepseek-v4-flash"
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": "hello"}, "finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 200},
|
||||||
|
})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
result = await ot.call_llm(client, [{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
assert result.content == "hello"
|
||||||
|
# Model is prefixed with the answering provider for ledger traceability.
|
||||||
|
assert result.model == "deepseek/deepseek-v4-flash"
|
||||||
|
assert result.prompt_tokens == 100
|
||||||
|
assert result.completion_tokens == 200
|
||||||
|
# DeepSeek doesn't return cost — estimated from tokens.
|
||||||
|
# 100 * 0.07 + 200 * 0.28 = 7 + 56 = 63 → 0.000063.
|
||||||
|
assert result.cost_usd == pytest.approx(0.000063, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_llm_uses_upstream_cost_when_provided(monkeypatch):
|
||||||
|
"""When the upstream supplies usage.cost (OpenRouter), we trust it
|
||||||
|
and skip the per-model table estimate."""
|
||||||
|
_configure(monkeypatch, LLM_PROVIDER="openrouter",
|
||||||
|
OPENROUTER_API_KEY="sk-or", LLM_FALLBACK="")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 50, "cost": 0.0042},
|
||||||
|
})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
result = await ot.call_llm(client, [{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
assert result.cost_usd == 0.0042
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_llm_does_not_publish_reasoning_when_content_null(monkeypatch):
|
||||||
|
"""The `reasoning` field is the model's internal chain-of-thought
|
||||||
|
(scratchpad: "Let's see…", planning notes, half-formed math). It is
|
||||||
|
never safe to surface as the user-facing answer — see the
|
||||||
|
2026-05-29 valuation-read leak. If `content` is null we treat the
|
||||||
|
row as a generation failure and raise; the caller can retry or skip."""
|
||||||
|
_configure(monkeypatch, DEEPSEEK_API_KEY="sk-d", LLM_FALLBACK="")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{
|
||||||
|
"message": {"content": None, "reasoning": "deep thought"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||||
|
})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
with pytest.raises(RuntimeError, match="LLM returned empty content"):
|
||||||
|
await ot.call_llm(client, [{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_llm_raises_when_no_provider_configured(monkeypatch):
|
||||||
|
_configure(monkeypatch) # both keys empty
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
with pytest.raises(RuntimeError, match="No LLM provider configured"):
|
||||||
|
await ot.call_llm(client, [{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# call_llm fallback chain — patch _call_provider to bypass the retry/sleep
|
||||||
|
# decorator and exercise the cross-provider failover logic directly.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_llm_falls_back_to_secondary_when_primary_raises(monkeypatch):
|
||||||
|
_configure(monkeypatch,
|
||||||
|
DEEPSEEK_API_KEY="sk-d", OPENROUTER_API_KEY="sk-or")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
success = ot.LogResult(
|
||||||
|
content="from-fallback", model="openrouter/deepseek/deepseek-v4-flash",
|
||||||
|
prompt_tokens=1, completion_tokens=2, cost_usd=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake(_client, provider, _messages, _model, _max_tokens, response_format=None):
|
||||||
|
calls.append(provider)
|
||||||
|
if provider == "deepseek":
|
||||||
|
raise RuntimeError("primary down")
|
||||||
|
return success
|
||||||
|
|
||||||
|
with patch.object(ot, "_call_provider", fake):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
result = await ot.call_llm(client, [{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
assert calls == ["deepseek", "openrouter"]
|
||||||
|
assert result.content == "from-fallback"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_llm_raises_last_exception_when_chain_exhausted(monkeypatch):
|
||||||
|
_configure(monkeypatch,
|
||||||
|
DEEPSEEK_API_KEY="sk-d", OPENROUTER_API_KEY="sk-or")
|
||||||
|
|
||||||
|
async def fake(_client, provider, _messages, _model, _max_tokens, response_format=None):
|
||||||
|
raise RuntimeError(f"{provider} broken")
|
||||||
|
|
||||||
|
with patch.object(ot, "_call_provider", fake):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
with pytest.raises(RuntimeError, match="openrouter broken"):
|
||||||
|
await ot.call_llm(client, [{"role": "user", "content": "hi"}])
|
||||||
172
tests/test_output_review.py
Normal file
172
tests/test_output_review.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""Tests for the JSON-envelope extractor and the reviewer agent.
|
||||||
|
|
||||||
|
The two together replaced the regex `clean_summary` + `looks_like_leakage`
|
||||||
|
scaffolding that used to live in indicator_summary_job. The extractor is
|
||||||
|
pure-function so it's covered exhaustively; the reviewer makes an LLM
|
||||||
|
call and is exercised via the httpx MockTransport that the other
|
||||||
|
openrouter tests use."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.jobs.indicator_summary_job import _extract_read
|
||||||
|
from app.services import openrouter as ot
|
||||||
|
from app.services.output_review import review_read
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _extract_read — JSON envelope handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_read_returns_trimmed_field():
|
||||||
|
raw = '{"read": " The market is pricing growth. "}'
|
||||||
|
assert _extract_read(raw) == "The market is pricing growth."
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_read_returns_none_on_invalid_json():
|
||||||
|
assert _extract_read("not json") is None
|
||||||
|
assert _extract_read("{bad}") is None
|
||||||
|
assert _extract_read("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_read_returns_none_when_field_missing():
|
||||||
|
assert _extract_read('{"other": "x"}') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_read_returns_none_when_field_not_string():
|
||||||
|
assert _extract_read('{"read": 42}') is None
|
||||||
|
assert _extract_read('{"read": null}') is None
|
||||||
|
assert _extract_read('{"read": ["a","b"]}') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_read_returns_none_when_field_empty():
|
||||||
|
assert _extract_read('{"read": ""}') is None
|
||||||
|
assert _extract_read('{"read": " "}') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_read_returns_none_when_envelope_not_object():
|
||||||
|
# A bare string or array is valid JSON but not the expected shape.
|
||||||
|
assert _extract_read('"just a string"') is None
|
||||||
|
assert _extract_read('["a", "b"]') is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# review_read — judges candidate read via a second LLM call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_post(handler):
|
||||||
|
return httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(monkeypatch):
|
||||||
|
"""Minimal env so call_llm believes a provider is configured.
|
||||||
|
Both review_read (which pins to OpenRouter for a non-thinking model)
|
||||||
|
and the openrouter module itself read get_settings, so we patch
|
||||||
|
both module-level references."""
|
||||||
|
import app.services.output_review as orr
|
||||||
|
settings = type("S", (), {
|
||||||
|
"LLM_PROVIDER": "deepseek", "LLM_FALLBACK": "",
|
||||||
|
"DEEPSEEK_API_KEY": "sk-d", "OPENROUTER_API_KEY": "sk-or",
|
||||||
|
"DEEPSEEK_URL": "https://x/deepseek", "DEEPSEEK_MODEL": "deepseek-v4-flash",
|
||||||
|
"OPENROUTER_URL": "https://x/or", "OPENROUTER_MODEL": "deepseek/deepseek-v4-flash",
|
||||||
|
"REVIEWER_MODEL": "anthropic/claude-haiku-4.5",
|
||||||
|
})()
|
||||||
|
monkeypatch.setattr(ot, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(orr, "get_settings", lambda: settings)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_clean_verdict(monkeypatch):
|
||||||
|
_configure(monkeypatch)
|
||||||
|
def handler(_req):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": '{"clean": true, "reason": "ok"}'},
|
||||||
|
"finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 12, "cost": 0.00007},
|
||||||
|
})
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
v = await review_read(client, "Markets are pricing tighter policy.")
|
||||||
|
assert v.clean is True
|
||||||
|
assert v.cost_usd == 0.00007
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_unclean_verdict(monkeypatch):
|
||||||
|
_configure(monkeypatch)
|
||||||
|
def handler(_req):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content":
|
||||||
|
'{"clean": false, "reason": "chain of thought"}'},
|
||||||
|
"finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 14, "cost": 0.00009},
|
||||||
|
})
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
v = await review_read(client, "Let's see, is it X? Actually Y?")
|
||||||
|
assert v.clean is False
|
||||||
|
assert "chain of thought" in v.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_strips_markdown_fence_around_json(monkeypatch):
|
||||||
|
"""Haiku (and friends) sometimes wrap JSON in ```json ... ``` even
|
||||||
|
when response_format is set. The parser needs to peel that off
|
||||||
|
before json.loads or it'll reject otherwise-valid verdicts."""
|
||||||
|
_configure(monkeypatch)
|
||||||
|
fenced = '```json\n{"clean": true, "reason": "polished read"}\n```'
|
||||||
|
def handler(_req):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": fenced},
|
||||||
|
"finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 18, "cost": 0.0006},
|
||||||
|
})
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
v = await review_read(client, "Markets are pricing tighter policy.")
|
||||||
|
assert v.clean is True
|
||||||
|
assert v.reason == "polished read"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_failsafe_on_malformed_json(monkeypatch):
|
||||||
|
"""Reviewer returned prose instead of JSON → conservative reject."""
|
||||||
|
_configure(monkeypatch)
|
||||||
|
def handler(_req):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": "yes it looks clean"},
|
||||||
|
"finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 6},
|
||||||
|
})
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
v = await review_read(client, "Some candidate.")
|
||||||
|
assert v.clean is False
|
||||||
|
assert "non-JSON" in v.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_failsafe_on_missing_clean_field(monkeypatch):
|
||||||
|
_configure(monkeypatch)
|
||||||
|
def handler(_req):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": '{"reason": "no field"}'},
|
||||||
|
"finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 6},
|
||||||
|
})
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
v = await review_read(client, "Some candidate.")
|
||||||
|
assert v.clean is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_failsafe_on_empty_candidate(monkeypatch):
|
||||||
|
"""No LLM call should fire if the candidate is empty."""
|
||||||
|
_configure(monkeypatch)
|
||||||
|
calls = []
|
||||||
|
def handler(_req):
|
||||||
|
calls.append(1)
|
||||||
|
return httpx.Response(500, json={"error": "should not be called"})
|
||||||
|
async with httpx.AsyncClient(transport=_mock_post(handler)) as client:
|
||||||
|
v = await review_read(client, " ")
|
||||||
|
assert v.clean is False
|
||||||
|
assert calls == []
|
||||||
|
|
@ -23,29 +23,6 @@ import pytest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_session_factory(tmp_path):
|
|
||||||
"""Spin up a fresh in-memory schema and return (engine, factory).
|
|
||||||
Mirrors test_stripe_billing._build_app's seeding strategy but
|
|
||||||
skips the FastAPI app — most conversion tests only need the
|
|
||||||
session factory."""
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from app import db as db_mod
|
|
||||||
from app.db import Base
|
|
||||||
|
|
||||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv.db")
|
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
db_mod._engine = engine
|
|
||||||
db_mod._session_factory = factory
|
|
||||||
|
|
||||||
async def _seed():
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
asyncio.run(_seed())
|
|
||||||
return factory
|
|
||||||
|
|
||||||
|
|
||||||
async def _add_pair(factory, *, referrer_id=1, referred_id=2):
|
async def _add_pair(factory, *, referrer_id=1, referred_id=2):
|
||||||
"""Insert a referrer + referred user pair and a linking Referral row.
|
"""Insert a referrer + referred user pair and a linking Referral row.
|
||||||
Returns nothing — tests re-fetch via the factory."""
|
Returns nothing — tests re-fetch via the factory."""
|
||||||
|
|
@ -68,7 +45,7 @@ async def _add_pair(factory, *, referrer_id=1, referred_id=2):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_first_conversion_credits_both_parties(tmp_path):
|
async def test_first_conversion_credits_both_parties(db_factory):
|
||||||
"""Calling convert_referral on a freshly-paid referred user should
|
"""Calling convert_referral on a freshly-paid referred user should
|
||||||
extend credit_until by REFERRAL_CREDIT_DAYS for BOTH the buyer and
|
extend credit_until by REFERRAL_CREDIT_DAYS for BOTH the buyer and
|
||||||
the referrer, and stamp converted_at + credited_at."""
|
the referrer, and stamp converted_at + credited_at."""
|
||||||
|
|
@ -77,10 +54,9 @@ def test_first_conversion_credits_both_parties(tmp_path):
|
||||||
REFERRAL_CREDIT_DAYS, convert_referral,
|
REFERRAL_CREDIT_DAYS, convert_referral,
|
||||||
)
|
)
|
||||||
|
|
||||||
factory = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
asyncio.run(_add_pair(factory))
|
await _add_pair(factory)
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
referred = await s.get(User, 2)
|
referred = await s.get(User, 2)
|
||||||
ref = await convert_referral(s, referred)
|
ref = await convert_referral(s, referred)
|
||||||
|
|
@ -105,20 +81,17 @@ def test_first_conversion_credits_both_parties(tmp_path):
|
||||||
delta_days = (cu - now).total_seconds() / 86400
|
delta_days = (cu - now).total_seconds() / 86400
|
||||||
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
|
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
async def test_idempotent_on_repeat_call(db_factory):
|
||||||
def test_idempotent_on_repeat_call(tmp_path):
|
|
||||||
"""A second convert_referral call (e.g. from a duplicate webhook or
|
"""A second convert_referral call (e.g. from a duplicate webhook or
|
||||||
renewal event) must NOT extend credit a second time. The Referral
|
renewal event) must NOT extend credit a second time. The Referral
|
||||||
row is already stamped, so we should early-return unchanged."""
|
row is already stamped, so we should early-return unchanged."""
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.services.referral_service import convert_referral
|
from app.services.referral_service import convert_referral
|
||||||
|
|
||||||
factory = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
asyncio.run(_add_pair(factory))
|
await _add_pair(factory)
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
referred = await s.get(User, 2)
|
referred = await s.get(User, 2)
|
||||||
await convert_referral(s, referred)
|
await convert_referral(s, referred)
|
||||||
|
|
@ -142,35 +115,27 @@ def test_idempotent_on_repeat_call(tmp_path):
|
||||||
assert referrer.credit_until == first_referrer_credit
|
assert referrer.credit_until == first_referrer_credit
|
||||||
assert referred.credit_until == first_referred_credit
|
assert referred.credit_until == first_referred_credit
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
async def test_no_referral_row_returns_none(db_factory):
|
||||||
def test_no_referral_row_returns_none(tmp_path):
|
|
||||||
"""A user signing up directly (no inviter) has no Referral row.
|
"""A user signing up directly (no inviter) has no Referral row.
|
||||||
convert_referral must return None and touch nothing."""
|
convert_referral must return None and touch nothing."""
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.services.referral_service import convert_referral
|
from app.services.referral_service import convert_referral
|
||||||
|
|
||||||
factory = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
|
|
||||||
async def _seed_orphan():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
s.add(User(id=9, email="lone@x", tier="free"))
|
s.add(User(id=9, email="lone@x", tier="free"))
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
asyncio.run(_seed_orphan())
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
user = await s.get(User, 9)
|
user = await s.get(User, 9)
|
||||||
result = await convert_referral(s, user)
|
result = await convert_referral(s, user)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert user.credit_until is None
|
assert user.credit_until is None
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
async def test_credit_stacks_from_existing_window(db_factory):
|
||||||
def test_credit_stacks_from_existing_window(tmp_path):
|
|
||||||
"""If the user already has a future credit_until (admin grant, prior
|
"""If the user already has a future credit_until (admin grant, prior
|
||||||
referral), the new credit should extend from THAT anchor — not from
|
referral), the new credit should extend from THAT anchor — not from
|
||||||
now. Mirrors cli.grant_credit's stacking semantics."""
|
now. Mirrors cli.grant_credit's stacking semantics."""
|
||||||
|
|
@ -179,21 +144,17 @@ def test_credit_stacks_from_existing_window(tmp_path):
|
||||||
REFERRAL_CREDIT_DAYS, convert_referral,
|
REFERRAL_CREDIT_DAYS, convert_referral,
|
||||||
)
|
)
|
||||||
|
|
||||||
factory = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
asyncio.run(_add_pair(factory))
|
await _add_pair(factory)
|
||||||
|
|
||||||
# Pre-load 30 days of credit on the referred user.
|
# Pre-load 30 days of credit on the referred user.
|
||||||
existing = datetime.now(timezone.utc) + timedelta(days=30)
|
existing = datetime.now(timezone.utc) + timedelta(days=30)
|
||||||
|
|
||||||
async def _preload():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
u = await s.get(User, 2)
|
u = await s.get(User, 2)
|
||||||
u.credit_until = existing
|
u.credit_until = existing
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
asyncio.run(_preload())
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
referred = await s.get(User, 2)
|
referred = await s.get(User, 2)
|
||||||
await convert_referral(s, referred)
|
await convert_referral(s, referred)
|
||||||
|
|
@ -211,19 +172,16 @@ def test_credit_stacks_from_existing_window(tmp_path):
|
||||||
f"got {cu}, expected ~{expected}"
|
f"got {cu}, expected ~{expected}"
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
async def test_deleted_referrer_does_not_crash(db_factory):
|
||||||
def test_deleted_referrer_does_not_crash(tmp_path):
|
|
||||||
"""If the referrer's User row has been deleted, the referred user
|
"""If the referrer's User row has been deleted, the referred user
|
||||||
should still be credited and the Referral still stamped — we just
|
should still be credited and the Referral still stamped — we just
|
||||||
skip the missing referrer."""
|
skip the missing referrer."""
|
||||||
from app.models import Referral, User
|
from app.models import Referral, User
|
||||||
from app.services.referral_service import convert_referral
|
from app.services.referral_service import convert_referral
|
||||||
|
|
||||||
factory = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
|
|
||||||
async def _seed():
|
|
||||||
from app.db import utcnow
|
from app.db import utcnow
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
# Referrer with FK SET NULL — we don't delete the row, we
|
# Referrer with FK SET NULL — we don't delete the row, we
|
||||||
|
|
@ -235,9 +193,6 @@ def test_deleted_referrer_does_not_crash(tmp_path):
|
||||||
created_at=utcnow()))
|
created_at=utcnow()))
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
asyncio.run(_seed())
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
referred = await s.get(User, 2)
|
referred = await s.get(User, 2)
|
||||||
ref = await convert_referral(s, referred)
|
ref = await convert_referral(s, referred)
|
||||||
|
|
@ -247,8 +202,6 @@ def test_deleted_referrer_does_not_crash(tmp_path):
|
||||||
# Referred still got their credit even though referrer is gone.
|
# Referred still got their credit even though referrer is gone.
|
||||||
assert referred.credit_until is not None
|
assert referred.credit_until is not None
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stripe-webhook integration
|
# Stripe-webhook integration
|
||||||
|
|
|
||||||
|
|
@ -463,3 +463,97 @@ def test_checkout_endpoint_requires_login(tmp_path):
|
||||||
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
|
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
|
||||||
# No session cookie → require_auth bounces with 401.
|
# No session cookie → require_auth bounces with 401.
|
||||||
assert r.status_code == 401, r.text
|
assert r.status_code == 401, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_passes_sniffed_currency_for_new_customer(tmp_path):
|
||||||
|
"""First-time buyer (no stripe_customer_id yet) gets the currency
|
||||||
|
sniffed from the request. CF-IPCountry=US → 'usd', and Stripe will
|
||||||
|
look up the USD currency_option on the Price."""
|
||||||
|
client, _, session_cookie = _build_app(tmp_path)
|
||||||
|
|
||||||
|
def asserter(params):
|
||||||
|
assert params["currency"] == "usd"
|
||||||
|
|
||||||
|
with patch("app.routers.stripe_billing._stripe_client",
|
||||||
|
return_value=_fake_checkout_client(asserter)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/stripe/checkout",
|
||||||
|
json={"cadence": "monthly"},
|
||||||
|
cookies={"cassandra_session": session_cookie},
|
||||||
|
headers={"cf-ipcountry": "US"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_body_currency_overrides_sniff(tmp_path):
|
||||||
|
"""Explicit `currency` in the request body beats header sniffing —
|
||||||
|
lets a UK-based buyer choose EUR if they want to."""
|
||||||
|
client, _, session_cookie = _build_app(tmp_path)
|
||||||
|
|
||||||
|
def asserter(params):
|
||||||
|
assert params["currency"] == "eur"
|
||||||
|
|
||||||
|
with patch("app.routers.stripe_billing._stripe_client",
|
||||||
|
return_value=_fake_checkout_client(asserter)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/stripe/checkout",
|
||||||
|
json={"cadence": "monthly", "currency": "eur"},
|
||||||
|
cookies={"cassandra_session": session_cookie},
|
||||||
|
headers={"cf-ipcountry": "GB"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_omits_currency_for_existing_customer(tmp_path):
|
||||||
|
"""Existing customer: Stripe locked their currency at first
|
||||||
|
checkout, so passing `currency` again would error. Verify we omit
|
||||||
|
it (and also use the existing `customer` ref instead of
|
||||||
|
customer_email)."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
client, factory, session_cookie = _build_app(tmp_path)
|
||||||
|
|
||||||
|
async def _link():
|
||||||
|
async with factory() as s:
|
||||||
|
u = await s.get(User, 1)
|
||||||
|
u.stripe_customer_id = "cus_existing_xxxxxxxxxxxxxx"
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
asyncio.run(_link())
|
||||||
|
|
||||||
|
def asserter(params):
|
||||||
|
assert "currency" not in params, (
|
||||||
|
"currency must not be passed once a customer exists — "
|
||||||
|
"Stripe rejects mismatches against the locked customer currency"
|
||||||
|
)
|
||||||
|
assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
|
||||||
|
|
||||||
|
with patch("app.routers.stripe_billing._stripe_client",
|
||||||
|
return_value=_fake_checkout_client(asserter)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/stripe/checkout",
|
||||||
|
json={"cadence": "monthly", "currency": "usd"},
|
||||||
|
cookies={"cassandra_session": session_cookie},
|
||||||
|
headers={"cf-ipcountry": "US"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sniff_currency_fallback_chain():
|
||||||
|
"""Unit-test the header-sniffing helper: CF country wins, then
|
||||||
|
Accept-Language exact, then language-only, then GBP default."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.routers.stripe_billing import _sniff_currency
|
||||||
|
|
||||||
|
def _req(headers):
|
||||||
|
return SimpleNamespace(headers=headers)
|
||||||
|
|
||||||
|
assert _sniff_currency(_req({"cf-ipcountry": "DE"})) == "eur"
|
||||||
|
assert _sniff_currency(_req({"cf-ipcountry": "us"})) == "usd" # case-insensitive
|
||||||
|
assert _sniff_currency(_req({"accept-language": "fr-FR,fr;q=0.9"})) == "eur"
|
||||||
|
assert _sniff_currency(_req({"accept-language": "en-US,en;q=0.5"})) == "usd"
|
||||||
|
assert _sniff_currency(_req({"accept-language": "ja,ja-JP;q=0.5"})) == "gbp"
|
||||||
|
assert _sniff_currency(_req({})) == "gbp"
|
||||||
|
|
|
||||||
|
|
@ -9,35 +9,13 @@ from unittest.mock import AsyncMock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _build_session_factory(tmp_path):
|
|
||||||
"""Spin up a fresh in-memory schema and return (engine, factory, setup).
|
|
||||||
Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from app import db as db_mod
|
async def test_validate_happy_path(db_factory, monkeypatch):
|
||||||
from app.db import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_happy_path(tmp_path, monkeypatch):
|
|
||||||
from app.routers.ticker_validate import validate_ticker
|
from app.routers.ticker_validate import validate_ticker
|
||||||
from app.services.market import Quote
|
from app.services.market import Quote
|
||||||
import app.routers.ticker_validate as mod
|
import app.routers.ticker_validate as mod
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
# Mock fetch_yahoo to return a successful quote.
|
# Mock fetch_yahoo to return a successful quote.
|
||||||
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
||||||
|
|
@ -62,14 +40,12 @@ async def test_validate_happy_path(tmp_path, monkeypatch):
|
||||||
assert result["as_of"] == "2026-05-27"
|
assert result["as_of"] == "2026-05-27"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_validate_unknown_symbol(db_factory, monkeypatch):
|
||||||
async def test_validate_unknown_symbol(tmp_path, monkeypatch):
|
|
||||||
from app.routers.ticker_validate import validate_ticker
|
from app.routers.ticker_validate import validate_ticker
|
||||||
from app.services.market import Quote
|
from app.services.market import Quote
|
||||||
import app.routers.ticker_validate as mod
|
import app.routers.ticker_validate as mod
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
# Mock fetch_yahoo to return a Quote with error and no price.
|
# Mock fetch_yahoo to return a Quote with error and no price.
|
||||||
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
||||||
|
|
@ -85,7 +61,6 @@ async def test_validate_unknown_symbol(tmp_path, monkeypatch):
|
||||||
assert "not recognised" in result["error"].lower()
|
assert "not recognised" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_empty_symbol_rejects():
|
async def test_validate_empty_symbol_rejects():
|
||||||
from app.routers.ticker_validate import validate_ticker
|
from app.routers.ticker_validate import validate_ticker
|
||||||
|
|
||||||
|
|
@ -95,8 +70,7 @@ async def test_validate_empty_symbol_rejects():
|
||||||
assert "required" in result["error"].lower()
|
assert "required" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_validate_seeds_universe_and_quote(db_factory, monkeypatch):
|
||||||
async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
|
|
||||||
"""Side-effect check: on success, the symbol is upserted into the
|
"""Side-effect check: on success, the symbol is upserted into the
|
||||||
universe and a Quote row is written."""
|
universe and a Quote row is written."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -106,8 +80,7 @@ async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
|
||||||
from app.services.market import Quote
|
from app.services.market import Quote
|
||||||
import app.routers.ticker_validate as mod
|
import app.routers.ticker_validate as mod
|
||||||
|
|
||||||
_, factory, setup = _build_session_factory(tmp_path)
|
factory = db_factory
|
||||||
await setup()
|
|
||||||
|
|
||||||
upsert_calls: list[list[str]] = []
|
upsert_calls: list[list[str]] = []
|
||||||
|
|
||||||
|
|
@ -137,7 +110,6 @@ async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
|
||||||
assert rows[0].currency == "USD"
|
assert rows[0].currency == "USD"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_historical_happy_path(monkeypatch):
|
async def test_historical_happy_path(monkeypatch):
|
||||||
from app.routers.ticker_validate import get_historical
|
from app.routers.ticker_validate import get_historical
|
||||||
import app.routers.ticker_validate as mod
|
import app.routers.ticker_validate as mod
|
||||||
|
|
@ -156,7 +128,6 @@ async def test_historical_happy_path(monkeypatch):
|
||||||
assert result["actual_date"] == "2024-01-12"
|
assert result["actual_date"] == "2024-01-12"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_historical_future_date_rejected():
|
async def test_historical_future_date_rejected():
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.routers.ticker_validate import get_historical
|
from app.routers.ticker_validate import get_historical
|
||||||
|
|
@ -168,7 +139,6 @@ async def test_historical_future_date_rejected():
|
||||||
assert "future" in str(exc.value.detail).lower()
|
assert "future" in str(exc.value.detail).lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_historical_bad_date_format_rejected():
|
async def test_historical_bad_date_format_rejected():
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.routers.ticker_validate import get_historical
|
from app.routers.ticker_validate import get_historical
|
||||||
|
|
@ -178,7 +148,6 @@ async def test_historical_bad_date_format_rejected():
|
||||||
assert exc.value.status_code == 400
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_historical_no_data(monkeypatch):
|
async def test_historical_no_data(monkeypatch):
|
||||||
from app.routers.ticker_validate import get_historical
|
from app.routers.ticker_validate import get_historical
|
||||||
import app.routers.ticker_validate as mod
|
import app.routers.ticker_validate as mod
|
||||||
|
|
@ -192,7 +161,6 @@ async def test_historical_no_data(monkeypatch):
|
||||||
assert "no data" in result["error"].lower()
|
assert "no data" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_historical_provider_failure(monkeypatch):
|
async def test_historical_provider_failure(monkeypatch):
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -208,7 +176,6 @@ async def test_historical_provider_failure(monkeypatch):
|
||||||
assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()
|
assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
|
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
|
||||||
"""Unit test for the helper itself: feed a hand-crafted series with a
|
"""Unit test for the helper itself: feed a hand-crafted series with a
|
||||||
weekend gap, ask for the Saturday close, expect Friday's close."""
|
weekend gap, ask for the Saturday close, expect Friday's close."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue