Compare commits

...

10 commits

Author SHA1 Message Date
0060166d32 review: per-surface rider, loosen for portfolio commentary
Reviewer was rejecting legitimate IT portfolio analyses, citing
descriptive risk language as actionable advice:

  reason: "Allocation guidance throughout: 'concentrazione gestibile',
  'non eliminabile', 'bassa esposizione', 'va monitorato'. Treats
  portfolio construction as actionable."

These phrases describe portfolio state (manageable concentration,
non-eliminable risk, low exposure, warrants monitoring) without
directing the user to take action. They are exactly the kind of
prose a portfolio commentary surface is supposed to produce. The
reviewer's generic "no financial advice" rule is too broad here.

Add a `surface` parameter to review_read() with a per-surface rider
mechanism (_SURFACE_RIDERS). The "portfolio" rider:

- Lists DESCRIPTIVE phrasings that are EXPLICITLY permitted:
  attribute naming ("high concentration", "currency exposure"),
  thesis invalidation conditions, impersonal observations about a
  position's sensitivity.
- Tightens the reject list to EXPLICIT calls to action: imperative
  verbs aimed at the reader, "you should", "consider X-ing",
  specific allocation prescriptions, price-target predictions.

portfolio_analysis.analyse() now passes surface="portfolio". All
other reviewer call sites (indicator summary, log, chat, digest)
default to surface=None and keep the generic rules.

tests/conftest.py's autouse review_read stub picks up **_kw so
adding new keyword arguments to review_read doesn't keep breaking
the locale-integration tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:44:27 +02:00
d47b310898 portfolio: drop rational/irrational + system-temperature from prompt
The base build_system_prompt() bakes in two artefacts that read fine
in the daily strategic log but cause repeated reviewer rejections on
the portfolio surface:

- The "Rational vs irrational" framework, which the model translates
  into IT/ES/FR/DE variants ("Razionalmente / Irrazionalmente",
  "Razionale se / Irrazionale se", etc.). Haiku reads the parallel
  contrast lists as the author working through their reasoning on
  the page and rejects as scratchpad.
- The mandatory "System temperature: [label] — …" closing line,
  which Haiku correctly flags as meta-commentary on this surface
  (it has no narrative anchor in a portfolio read).

Both are wired into the base prompt and don't add value here. Drop
them explicitly via an "# DO NOT include in this surface" override
block in _SYSTEM_OVERRIDES. The portfolio read is just plain
declarative commentary on the holdings now — opening posture
sentence, 3-5 paragraphs on concentration / tilt / currency /
winners-losers / what would invalidate, end of story.

Reviewer's rational-vs-irrational structural-device carve-out (added
in de3a9bf) stays — strategic log, indicator summaries, and digest
emails still legitimately use that framing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:27:54 +02:00
de3a9bfa66 review: teach reviewer about the rational-vs-irrational structural device
Live logs (analyze.lang_resolved final=it → reviewer_rejected:
"Self-questioning and working-through language ('Razionalmente...
Irrazionalmente')") showed Haiku confusing the dashboard's mandated
"Rational vs Irrational" contrast framework for scratchpad
self-questioning. The portfolio-analysis system prompt explicitly
requires every paragraph to contrast a rational reading
(fundamentals, policy, valuation) with an irrational one
(positioning, narrative momentum) — that's structural prose, not
the author thinking on the page.

Add an explicit "this is a structural device" carve-out to the
reviewer prompt, naming the framework and its IT / ES / FR / DE
translations so the rejection doesn't reappear when the prompt
output lands in non-English.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:20:00 +02:00
8e7ea673ce analyze: bump max_tokens 2000 → 4000 for portfolio analysis
Logs (analyze.lang_resolved → portfolio_analysis.reviewer_rejected
chain on 2026-05-29) showed the lang directive was working — the
model was producing Italian — but the reviewer was rejecting every
response as truncated mid-word ("supera i mass", "INRG +8"). The
analyze endpoint then returns 502 and the frontend keeps showing
whatever stale English row was last cached in localStorage, so from
the user's POV the analysis "is still in English".

Same shape as the strategic-log translation cap we fixed earlier:
the prompt targets ~350 English words, IT runs ~25-35% longer in
tokens, and DeepSeek-V4-flash bills internal reasoning against the
same budget. At 2000 we ran out of room mid-sentence. 4000 is well
above the longest realistic Italian output; cost is bounded by
tokens actually emitted, not the cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:04:40 +02:00
838f227175 settings: drop the broker-list line from the import lede
Removed "Trading 212 is recognised natively and other formats (IBKR,
Fidelity, Schwab…) are auto-detected" from the import section's
lede paragraph — internal/marketing noise that doesn't help the user
once they're already on the import screen with a file picker in
front of them. Kept the surrounding sentence ("Drop a portfolio CSV
from any broker. We'll parse it…") and the T212 export-path hint
since the latter is concrete instructional content for T212 users.
2026-05-29 15:58:47 +02:00
dbb14340db fix: ascii quotes in settings.html script tags
The two <script src="{{ url_for(...) }}"> lines for the sync scripts
had Unicode smart-quotes (' / ') instead of ASCII apostrophes —
left over from a copy-paste at some point. Jinja's tokenizer hit the
first one and raised TemplateSyntaxError, so /settings returned a
500. Replaced with ASCII quotes and added the missing ?v=ASSET_VERSION
cache-buster the other static URLs already use.
2026-05-29 15:34:45 +02:00
21835afebe analyze: send the live toggle lang from the frontend, log resolution
The /api/analyze flow previously read principal.user.lang from the
DB on every request and ignored anything the client might send. That
races the language toggle's PATCH: a user can flip the toggle and
click Generate/Regenerate before the PATCH /api/settings/language
hits the DB, so the analysis is sent with the OLD persisted lang
while the toggle visually reads as the new one. From the user's POV
the analysis comes back in the wrong language.

Frontend portfolio.js now reads the live #lang-toggle data-lang
attribute (the same source the UI itself uses) and includes it in
the /api/analyze body. The dataset attribute is updated optimistically
by cassandraSetLang() before the PATCH fires, so it always reflects
what the user is looking at.

Backend universe.py prefers payload["lang"] when present and falls
back to user.lang otherwise — older clients (scripts, direct curl)
that don't send anything still get the DB-stored preference. The
resolution path is logged so we can confirm in prod which lang
actually drove a given request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:32:58 +02:00
13dd3a8330 i18n: prepend a strong language directive for portfolio + chat
Reports that portfolio AI analysis was coming back in English even
for IT-toggled users. Traced the chain (DB user.lang IS set to it,
router passes it into the payload, parse_request reads it, build_prompt
appends respond_in_clause), so the wiring is correct end-to-end. The
model was simply ignoring the single-sentence tail nudge: when the
system prompt is hundreds of lines of English and the user message
adds more English context, "Respond in Italian." at the end is easy
to drop on the floor.

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:21:00 +02:00
736d161990 ui: portfolio actions row + AI analysis regenerate
Two small UX changes to the portfolio panel:

1. "Forget this pie" is destructive enough to belong in edit-mode
   only. The button now hides by default and only surfaces when the
   #portfolio-panel.pf-editing class is on the panel (same surface
   that already shows per-row × and the add-position form). The
   element stays in the DOM so the existing click handler keeps
   working without re-mount.

2. "Generate AI analysis" disappears once an analysis exists. In its
   place a small "Regenerate" button is rendered inside the
   collapsible analysis box — in the summary header, right-aligned
   next to the timestamp. The button stops the summary's default
   toggle action so a click regenerates without collapsing the
   panel. runAnalysis() now tolerates either pf-analyze or pf-regen
   as the trigger, and showAnalysis() takes an optional
   onRegenerate callback so callers can wire the button to the
   current pie/enriched closure context. Re-hydration after the
   60s portfolio refresh passes the same callback so the button
   survives a refresh cycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:04:08 +02:00
652995feea ui: log panel bottom-aligns with portfolio via contain:size
Third attempt at fixing the dashboard's right-column alignment, this
time with the structural cause identified explicitly.

Previous attempts (a55168d, 8347c90) changed align-self on #log-panel
to control how the panel filled its grid area. They got the box
edges aligned, but the underlying problem was a different one:
CSS Grid auto-sizes each row by MAX(intrinsic content height across
items in that row). When the log content is taller than indicators +
portfolio combined, the grid grows rows 2-3 to fit it; portfolio
ends up in a stretched row with empty space below the actual content.

The fix is to stop the log's content from contributing to the grid
row sizing at all. `contain: size` on the log panel declares "my
contents do not affect my intrinsic size" — the grid then sizes rows
2-3 from indicators + portfolio alone, and the log stretches to
inhabit that combined height. A flex column inside the panel
(min-height: 0 on every level of the chain) lets .panel-body fill
the remaining height below the header and scroll instead of
overflowing.

The 1100px mobile breakpoint undoes the constraint: at that width
the grid restructures to a single column, the log no longer shares
a row with indicators + portfolio, and `contain: size` would just
collapse the panel to zero. There the log expands naturally and
page scroll handles it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:56:11 +02:00
10 changed files with 279 additions and 52 deletions

View file

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

View file

@ -362,10 +362,19 @@ 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 = ( # Resolve lang. The frontend sends the live toggle state in
# payload["lang"]; that's what the user is *looking at* right now
# and is the most up-to-date value. user.lang from the DB is the
# persisted preference and is used as a fallback when the frontend
# didn't send anything (older clients, scripts, direct curl).
db_lang = (
principal.user.lang if (principal.user and principal.user.lang) else "en" principal.user.lang if (principal.user and principal.user.lang) else "en"
) )
payload["lang"] = user_lang incoming = (payload.get("lang") or "").strip().lower()
payload["lang"] = incoming or db_lang
log.info("analyze.lang_resolved",
payload_lang=incoming or None, db_lang=db_lang,
final=payload["lang"])
try: try:
req = portfolio_analysis.parse_request(payload) req = portfolio_analysis.parse_request(payload)

View file

@ -46,3 +46,31 @@ def respond_in_clause(lang: str | None) -> str:
if not lang or lang == "en" or lang not in LANGUAGES: if not lang or lang == "en" or lang not in LANGUAGES:
return "" return ""
return f"\n\nRespond in {LANGUAGES[lang]}." return f"\n\nRespond in {LANGUAGES[lang]}."
def language_directive_lead(lang: str | None) -> str:
"""Strong, top-of-prompt language directive for callers that
generate user-facing prose in real time (portfolio analysis,
chat) and need the output to actually land in the user's
preferred language. A single tail clause like
``respond_in_clause`` is easy for the model to ignore when the
rest of the prompt + user message are entirely in English; this
leads with an explicit "all output in X" block, kept verbatim
rules for symbols/numbers, and is intended to be prepended to
the system prompt so the model anchors on the target language
before reading the rest. Combined with respond_in_clause at the
tail it gives a belt-and-suspenders defence.
Empty string for English or unknown codes so callers can paste
it in unconditionally.
"""
if not lang or lang == "en" or lang not in LANGUAGES:
return ""
language = LANGUAGES[lang]
return (
f"# LANGUAGE — write everything in {language}\n"
f"All output — section headers, prose, lists, and any inline "
f"labels — must be written in {language}. Do NOT mix English in. "
f"Ticker symbols (AAPL, MSFT, VOD.L), ISO currency codes "
f"(USD, EUR, GBP), and numeric values stay unchanged.\n\n"
)

View file

@ -47,6 +47,18 @@ reply, or an email digest — and decide if it is publishable as-is.
Mark CLEAN only if the text reads like finished editorial commentary Mark CLEAN only if the text reads like finished editorial commentary
a reader could see on a public dashboard without confusion. a reader could see on a public dashboard without confusion.
Editorial framework you should KNOW about (don't flag these):
This dashboard's voice deliberately contrasts a "rational" read
(fundamentals, policy regime, valuation) with an "irrational" read
(positioning, narrative momentum, flows) and names the gap between
them. Section labels like "Rational:" / "Irrational:" (or "Bull /
Bear", or any explicit "X vs Y" contrast) are STRUCTURAL DEVICES,
not the author thinking on the page. Treat them as finished prose.
The Italian / Spanish / French / German equivalents
("Razionalmente / Irrazionalmente", "Racionalmente / Irracionalmente",
"Rationnellement / Irrationnellement", "Rational / Irrational") are
the same device translated and equally fine.
Mark UNCLEAN if the text contains ANY of: Mark UNCLEAN if the text contains ANY of:
- Chain-of-thought / scratchpad markers the author thinking on the - Chain-of-thought / scratchpad markers the author thinking on the
page rather than presenting finished commentary. Phrases like page rather than presenting finished commentary. Phrases like
@ -56,7 +68,9 @@ Mark UNCLEAN if the text contains ANY of:
front of the reader (self-questioning) are not. front of the reader (self-questioning) are not.
- Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?", - Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?",
"is it X or Y?", any place where the author appears to be working "is it X or Y?", any place where the author appears to be working
out the answer in front of the reader. out the answer in front of the reader. The "rational vs irrational"
contrast above is NOT self-questioning the author is presenting
both reads as parallel takes, not asking which one is correct.
- Meta-commentary about the task, output format, word limits, or - Meta-commentary about the task, output format, word limits, or
instructions e.g. "as required by the constraints", "the prompt instructions e.g. "as required by the constraints", "the prompt
asks", "let me address each". asks", "let me address each".
@ -87,6 +101,38 @@ No preamble, no markdown fences, no other fields.
""" """
# Surface-specific rider appended to the system prompt when the caller
# passes a known `surface` to review_read(). Lets us relax or tighten
# rules per editorial context without rewriting the whole prompt.
_SURFACE_RIDERS = {
"portfolio": """\
# Surface: portfolio commentary
This text describes a real investor's holdings. DESCRIPTIVE risk
language is the whole point of this surface and must NOT be flagged
as financial advice. The following ARE fine:
- Naming portfolio attributes: "high concentration", "single-name
exposure", "currency risk is unhedged", "FX exposure", "elevated
risk", "stretched valuations", "concentration is manageable", "low
diversification".
- Stating what would invalidate the posture: "this view fails if
rates retrace", "the thesis depends on X holding".
- Impersonal observation about a position's behaviour or sensitivity:
"the position warrants monitoring", "carries vulnerability to a
policy shock", "is sensitive to rate moves".
ONLY flag EXPLICIT calls to action where a verb or directive is
aimed at the reader:
- Imperative verbs in the second person: "buy X", "sell Y",
"trim Z", "hedge", "rotate into".
- "You should", "investors should", "consider X-ing", "we recommend".
- Specific allocation prescriptions: "go 20% bonds", "overweight
tech", "underweight defensives".
- Price-target predictions: "will reach $X by year-end".
""",
}
@dataclass(frozen=True) @dataclass(frozen=True)
class Verdict: class Verdict:
clean: bool clean: bool
@ -94,18 +140,32 @@ class Verdict:
cost_usd: float | None # cost of the review call itself, for the ledger cost_usd: float | None # cost of the review call itself, for the ledger
async def review_read(client: httpx.AsyncClient, candidate: str) -> Verdict: async def review_read(
client: httpx.AsyncClient,
candidate: str,
surface: str | None = None,
) -> Verdict:
"""Ask the LLM whether `candidate` is a publishable read. """Ask the LLM whether `candidate` is a publishable read.
Returns Verdict(clean, reason, cost). Any error provider failure, Returns Verdict(clean, reason, cost). Any error provider failure,
JSON parse failure, missing field, wrong type yields a CONSERVATIVE JSON parse failure, missing field, wrong type yields a CONSERVATIVE
verdict (clean=False) so the caller drops the candidate. The verdict (clean=False) so the caller drops the candidate. The
previously cached good summary stays visible on the dashboard.""" previously cached good summary stays visible on the dashboard.
`surface` selects a surface-specific rider that's appended to the
base system prompt see _SURFACE_RIDERS. Currently only the
"portfolio" surface uses one (descriptive risk language is the
whole point there and shouldn't be flagged as advice). Unknown
or None surfaces fall back to the generic rules."""
if not candidate or not candidate.strip(): if not candidate or not candidate.strip():
return Verdict(clean=False, reason="empty candidate", cost_usd=0.0) return Verdict(clean=False, reason="empty candidate", cost_usd=0.0)
system_prompt = _SYSTEM_PROMPT
if surface and surface in _SURFACE_RIDERS:
system_prompt = system_prompt + _SURFACE_RIDERS[surface]
messages = [ messages = [
{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "system", "content": system_prompt},
# Sent as a fenced user turn so the model can't confuse the # Sent as a fenced user turn so the model can't confuse the
# candidate with instructions, even if the candidate happens to # candidate with instructions, even if the candidate happens to
# contain prompt-like prose. # contain prompt-like prose.

View file

@ -31,7 +31,7 @@ from app.config import get_settings
from app.db import utcnow from app.db import utcnow
from app.logging import get_logger from app.logging import get_logger
from app.models import AICall from app.models import AICall
from app.services.i18n import LANGUAGES, respond_in_clause from app.services.i18n import LANGUAGES, language_directive_lead, respond_in_clause
from app.services.llm_prompts import build_system_prompt from app.services.llm_prompts import build_system_prompt
from app.services.output_review import review_read from app.services.output_review import review_read
from app.services.openrouter import ( from app.services.openrouter import (
@ -257,18 +257,14 @@ implies X under scenario Y"), not advice ("buy X" / "sell Y" are forbidden).
- ~350 words. No bullet lists. No buy/sell recommendations. - ~350 words. No bullet lists. No buy/sell recommendations.
- Do not repeat the input data verbatim interpret it. - Do not repeat the input data verbatim interpret it.
# Rational vs irrational lens (mandatory) # DO NOT include in this surface (overrides the base prompt)
Carry the base prompt's rational-vs-irrational framing through to every - No "Rational vs irrational" framing, no "Rational:" / "Irrational:"
paragraph of the portfolio read. For each section above, contrast: section labels, no parallel contrast lists. The base prompt asks
- The RATIONAL read: what the underlying factors (fundamentals, for this framework elsewhere; this surface is plain declarative
macro/policy regime, valuation, currency dynamics) justify for this commentary on the holdings, not a comparative essay.
exposure; - No "System temperature:" closing line. That artefact belongs to the
- The IRRATIONAL read: what positioning, narrative momentum, sentiment daily strategic log; here the analysis ends with the last paragraph.
or flows are doing to that same exposure right now. - No "Update mode" headers, no anchor-date callouts, no watch list.
Then name the GAP does the holder's posture line up with the rational
read, or is it riding the irrational one? A paragraph that names only
the pie's numbers or only the macro backdrop, without placing the
holding on this rational-vs-irrational axis, is incomplete.
""" """
@ -282,7 +278,18 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]:
head = enriched[:MAX_POSITIONS_INLINED] head = enriched[:MAX_POSITIONS_INLINED]
tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED) tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED)
system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES + respond_in_clause(req.lang) # Language directive both prepended (so the model anchors on the
# target language before reading the long English instruction
# block) and appended (defence in depth — a tail nudge alone
# was being ignored by deepseek-v4-flash when most of the
# context is English).
system = (
language_directive_lead(req.lang)
+ build_system_prompt(req.tone, req.analysis)
+ "\n\n"
+ _SYSTEM_OVERRIDES
+ respond_in_clause(req.lang)
)
user_parts = [ user_parts = [
f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}", f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}",
@ -333,7 +340,17 @@ async def analyse(
{"role": "system", "content": system}, {"role": "system", "content": system},
{"role": "user", "content": user}, {"role": "user", "content": user},
], ],
max_tokens=2000, # 4000 not 2000. Italian / Spanish / French / German
# output runs ~25-35% longer in tokens than English; on
# top of that DeepSeek-V4-flash bills its internal
# reasoning against the same budget. At 2000 we
# repeatedly hit finish_reason=length mid-sentence,
# which the reviewer agent then correctly flags as
# truncated and rejects — the user ends up looking at
# whatever stale row was last cached. 4000 leaves
# ample headroom; we only pay for tokens actually
# emitted, not the cap itself.
max_tokens=4000,
) )
status = "ok" status = "ok"
error_msg = None error_msg = None
@ -348,8 +365,14 @@ async def analyse(
# buy/sell or allocation language is a regulatory hazard. Drop # buy/sell or allocation language is a regulatory hazard. Drop
# the response on a reject and surface a retry-able error to the # the response on a reject and surface a retry-able error to the
# caller; no analysis is ever persisted server-side anyway. # caller; no analysis is ever persisted server-side anyway.
# surface="portfolio" applies a rider that loosens the generic
# "no advice" rule to permit descriptive risk language
# ("concentration is high", "currency exposure is unhedged",
# "the position warrants monitoring") which is the actual
# purpose of this surface, while keeping explicit
# buy/sell/allocation directives forbidden.
if llm is not None: if llm is not None:
verdict = await review_read(client, llm.content) verdict = await review_read(client, llm.content, surface="portfolio")
review_cost = verdict.cost_usd or 0.0 review_cost = verdict.cost_usd or 0.0
if not verdict.clean: if not verdict.clean:
status = "leaked" status = "leaked"

View file

@ -246,6 +246,20 @@ body.drawer-open .drawer-backdrop { opacity: 1; }
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-areas: "header" "indicators" "portfolio" "log" "news"; grid-template-areas: "header" "indicators" "portfolio" "log" "news";
} }
/* Single-column layout the log panel no longer shares a row with
indicators + portfolio, so the height-constraint dance above
would just collapse the panel to nothing. Drop the constraint and
let the log expand to its natural content height; page scroll
takes over. */
#log-panel {
contain: none;
display: block;
}
#log-panel .panel-body {
flex: none;
min-height: auto;
overflow-y: visible;
}
} }
#dash-header-container { grid-area: header; } #dash-header-container { grid-area: header; }
@ -253,10 +267,25 @@ body.drawer-open .drawer-backdrop { opacity: 1; }
#portfolio-panel { grid-area: portfolio; } #portfolio-panel { grid-area: portfolio; }
#log-panel { #log-panel {
grid-area: log; grid-area: log;
/* Stretch (default align-self) so the log panel's border reaches the /* Bottom-align with the portfolio panel WITHOUT padding the inside
bottom of the portfolio next to it the two right-hand panels of either box. The key is `contain: size`: a grid item with this
align cleanly. The log body itself sits at the top of the panel; contracts to declare "my contents do not contribute to my own
any height beyond its content is empty padding inside the box. */ intrinsic size." The outer grid therefore sizes rows 2-3 from
indicators + portfolio alone; this panel stretches to that
combined height; if the log content is taller it scrolls inside.
The flex column + min-height:0 chain lets .panel-body fill the
remaining height below the header and scroll instead of
overflowing the panel. */
contain: size;
display: flex;
flex-direction: column;
min-height: 0;
}
#log-panel .panel-header { flex-shrink: 0; }
#log-panel .panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
} }
#news-panel { grid-area: news; } #news-panel { grid-area: news; }

View file

@ -86,6 +86,13 @@
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; } .pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-secondary { color: var(--muted); } .pf-secondary { color: var(--muted); }
.pf-secondary:hover { color: var(--negative); border-color: var(--negative); } .pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
/* "Forget this pie" is destructive only show it while the user is
in edit mode (the same mode that surfaces the per-row delete × and
the add-position form). Outside of edit mode the row stays in the
DOM so existing handlers and any future surface that wants to
toggle it can do so without re-rendering. */
#pf-forget { display: none; }
#portfolio-panel.pf-editing #pf-forget { display: inline-block; }
.pf-analysis { .pf-analysis {
margin-top: 14px; margin-top: 14px;
@ -107,6 +114,24 @@
list-style: none; /* hide native marker in Firefox */ list-style: none; /* hide native marker in Firefox */
} }
.pf-analysis__head::-webkit-details-marker { display: none; } .pf-analysis__head::-webkit-details-marker { display: none; }
.pf-analysis__head-right {
display: inline-flex;
align-items: center;
gap: 12px;
}
.pf-regen {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 3px 9px;
font: inherit;
font-size: 10.5px;
letter-spacing: inherit;
cursor: pointer;
text-transform: inherit;
}
.pf-regen:hover { color: var(--accent); border-color: var(--accent); }
.pf-regen:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-analysis__head-left::before { .pf-analysis__head-left::before {
content: "▸ "; content: "▸ ";
display: inline-block; display: inline-block;

View file

@ -372,13 +372,24 @@
'</tr></thead>' + '</tr></thead>' +
'<tbody>' + rows + '</tbody>' + '<tbody>' + rows + '</tbody>' +
'</table>' + '</table>' +
// The "Generate" button only renders when there's no cached
// analysis yet. Once one exists, regeneration moves inside the
// collapsible analysis box (see showAnalysis below). The "Forget
// this pie" button is destructive enough that it lives in
// edit-mode only — CSS in portfolio.css hides it when the
// portfolio panel isn't carrying the .pf-editing class.
'<div class="pf-actions">' + '<div class="pf-actions">' +
'<button id="pf-analyze" type="button">Generate AI analysis</button>' + (pie.analysis && pie.analysis.content
? ''
: '<button id="pf-analyze" type="button">Generate AI analysis</button>') +
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' + '<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
'</div>' + '</div>' +
'<div id="pf-analysis" class="pf-analysis" hidden></div>'; '<div id="pf-analysis" class="pf-analysis" hidden></div>';
document.getElementById('pf-analyze').addEventListener('click', () => runAnalysis(pie, enriched)); const analyzeBtn = document.getElementById('pf-analyze');
if (analyzeBtn) {
analyzeBtn.addEventListener('click', () => runAnalysis(pie, enriched));
}
document.getElementById('pf-forget').addEventListener('click', () => { document.getElementById('pf-forget').addEventListener('click', () => {
if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) { if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) {
clearPie(); clearPie();
@ -390,13 +401,16 @@
// wipe it. Rendered expanded so the user keeps seeing the body they // wipe it. Rendered expanded so the user keeps seeing the body they
// just generated — collapsing it under their cursor every minute // just generated — collapsing it under their cursor every minute
// reads as "the analysis disappeared". They can still click the // reads as "the analysis disappeared". They can still click the
// header to collapse manually within a single refresh window. // header to collapse manually within a single refresh window. The
// regenerate callback closes over the current pie/enriched so a
// click rebuilds the analysis with the same context that drove
// the initial render.
if (pie.analysis && pie.analysis.content) { if (pie.analysis && pie.analysis.content) {
showAnalysis(pie.analysis, { open: true }); showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched));
} }
} }
function showAnalysis(analysis, opts) { function showAnalysis(analysis, opts, onRegenerate) {
const out = document.getElementById('pf-analysis'); const out = document.getElementById('pf-analysis');
if (!out) return; if (!out) return;
const openAttr = (opts && opts.open) ? ' open' : ''; const openAttr = (opts && opts.open) ? ' open' : '';
@ -407,20 +421,43 @@
'<span class="pf-analysis__head-left">' + '<span class="pf-analysis__head-left">' +
'AI analysis' + 'AI analysis' +
'</span>' + '</span>' +
'<span class="pf-as-of">' + '<span class="pf-analysis__head-right">' +
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) + '<span class="pf-as-of">' +
' UTC</span>' + esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
' UTC</span>' +
(onRegenerate
? '<button id="pf-regen" type="button" class="pf-regen"' +
' title="Run the analysis again on the current portfolio">' +
'Regenerate</button>'
: '') +
'</span>' +
'</summary>' + '</summary>' +
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' + '<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
'</details>'; '</details>';
if (onRegenerate) {
const regen = document.getElementById('pf-regen');
if (regen) {
regen.addEventListener('click', (e) => {
// The button lives inside <summary>; clicking it would
// normally toggle the <details> open/closed. Suppress the
// default toggle and the bubble so only our regen runs.
e.preventDefault();
e.stopPropagation();
onRegenerate();
});
}
}
} }
async function runAnalysis(pie, enriched) { async function runAnalysis(pie, enriched) {
const out = document.getElementById('pf-analysis'); const out = document.getElementById('pf-analysis');
const btn = document.getElementById('pf-analyze'); // First-run click is on pf-analyze; the regenerate path is pf-regen
// inside the details summary. Either may be the live trigger.
const btn = document.getElementById('pf-analyze') ||
document.getElementById('pf-regen');
out.hidden = false; out.hidden = false;
out.innerHTML = '<div class="empty">generating…</div>'; out.innerHTML = '<div class="empty">generating…</div>';
btn.disabled = true; if (btn) btn.disabled = true;
// Build the prices payload from the universe cache so the server // Build the prices payload from the universe cache so the server
// doesn't have to re-fetch. // doesn't have to re-fetch.
@ -432,6 +469,13 @@
} }
} }
// The language toggle's data-lang attribute is the user's LIVE
// pick — newer than user.lang in the DB if the user toggled and
// hit Generate/Regenerate before the toggle-PATCH committed.
// Backend prefers this value if provided (see universe.py).
const langPill = document.getElementById('lang-toggle');
const userLang = (langPill && langPill.dataset.lang) || 'en';
try { try {
const r = await fetch('/api/analyze', { const r = await fetch('/api/analyze', {
method: 'POST', method: 'POST',
@ -441,6 +485,7 @@
positions: pie.positions, positions: pie.positions,
prices: prices, prices: prices,
base_currency: pie.base_currency || 'GBP', base_currency: pie.base_currency || 'GBP',
lang: userLang,
}), }),
}); });
const data = await r.json(); const data = await r.json();
@ -458,11 +503,17 @@
} }
// Persist before rendering so auto-refresh can re-hydrate. // Persist before rendering so auto-refresh can re-hydrate.
saveAnalysis(data); saveAnalysis(data);
showAnalysis(data, { open: true }); // Pass the regenerate callback so the in-details "Regenerate"
// button shows up on the freshly-rendered analysis too.
showAnalysis(data, { open: true }, () => runAnalysis(pie, enriched));
} catch (e) { } catch (e) {
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>'; out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
} finally { } finally {
btn.disabled = false; // The original button may have been replaced by showAnalysis →
// re-fetch its handle (or null if neither id is on the page now).
const liveBtn = document.getElementById('pf-analyze') ||
document.getElementById('pf-regen');
if (liveBtn) liveBtn.disabled = false;
} }
} }

View file

@ -98,10 +98,8 @@
<details class="settings-section" id="import" open> <details class="settings-section" id="import" open>
<summary class="settings-section__head">Import portfolio (CSV)</summary> <summary class="settings-section__head">Import portfolio (CSV)</summary>
<p class="settings-section__lede"> <p class="settings-section__lede">
Drop a portfolio CSV from any broker &mdash; Trading 212 is recognised Drop a portfolio CSV from any broker. We&rsquo;ll parse it and show
natively and other formats (IBKR, Fidelity, Schwab&hellip;) are a preview before importing anywhere.
auto-detected. We&rsquo;ll parse it and show a preview before importing
anywhere.
<br><span class="muted">T212 export path: <br><span class="muted">T212 export path:
<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span> <span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span>
</p> </p>
@ -332,8 +330,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') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for(static, path=/js/settings-sync.js) }}" defer></script> <script src="{{ url_for('static', path='/js/settings-sync.js') }}?v={{ ASSET_VERSION }}" defer></script>
{% endif %} {% endif %}
<script> <script>

View file

@ -36,7 +36,7 @@ def stub_reviewer(monkeypatch):
""" """
from app.services.output_review import Verdict from app.services.output_review import Verdict
async def _clean(_client, _candidate): async def _clean(_client, _candidate, **_kw):
return Verdict(clean=True, reason="stubbed-by-conftest", cost_usd=0.0) return Verdict(clean=True, reason="stubbed-by-conftest", cost_usd=0.0)
for mod_path in ( for mod_path in (