From 652995feea97c6d23dd3bc4c26a03c7b5fc5aec4 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 14:56:11 +0200 Subject: [PATCH 01/10] ui: log panel bottom-aligns with portfolio via contain:size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/static/css/layout.css | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/app/static/css/layout.css b/app/static/css/layout.css index 6acaf6c..a66d5bb 100644 --- a/app/static/css/layout.css +++ b/app/static/css/layout.css @@ -246,6 +246,20 @@ body.drawer-open .drawer-backdrop { opacity: 1; } grid-template-columns: 1fr; 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; } @@ -253,10 +267,25 @@ body.drawer-open .drawer-backdrop { opacity: 1; } #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. */ + /* Bottom-align with the portfolio panel WITHOUT padding the inside + of either box. The key is `contain: size`: a grid item with this + contracts to declare "my contents do not contribute to my own + 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; } From 736d161990ac7ce48248039ef8d992b80d3bf57b Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 15:04:08 +0200 Subject: [PATCH 02/10] ui: portfolio actions row + AI analysis regenerate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/static/css/portfolio.css | 25 ++++++++++++++ app/static/js/portfolio.js | 67 +++++++++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/app/static/css/portfolio.css b/app/static/css/portfolio.css index cdf0417..83b13d0 100644 --- a/app/static/css/portfolio.css +++ b/app/static/css/portfolio.css @@ -86,6 +86,13 @@ .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); } +/* "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 { margin-top: 14px; @@ -107,6 +114,24 @@ list-style: none; /* hide native marker in Firefox */ } .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 { content: "▸ "; display: inline-block; diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index 0f3ecb4..767f078 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -372,13 +372,24 @@ '' + '' + rows + '' + '' + + // 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. '
' + - '' + + (pie.analysis && pie.analysis.content + ? '' + : '') + '' + '
' + ''; - 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', () => { if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) { clearPie(); @@ -390,13 +401,16 @@ // wipe it. Rendered expanded so the user keeps seeing the body they // 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. + // 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) { - 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'); if (!out) return; const openAttr = (opts && opts.open) ? ' open' : ''; @@ -407,20 +421,43 @@ '' + 'AI analysis' + '' + - '' + - esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) + - ' UTC' + + '' + + '' + + esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) + + ' UTC' + + (onRegenerate + ? '' + : '') + + '' + '' + '
' + esc(analysis.content) + '
' + ''; + if (onRegenerate) { + const regen = document.getElementById('pf-regen'); + if (regen) { + regen.addEventListener('click', (e) => { + // The button lives inside ; clicking it would + // normally toggle the
open/closed. Suppress the + // default toggle and the bubble so only our regen runs. + e.preventDefault(); + e.stopPropagation(); + onRegenerate(); + }); + } + } } async function runAnalysis(pie, enriched) { 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.innerHTML = '
generating…
'; - btn.disabled = true; + if (btn) btn.disabled = true; // Build the prices payload from the universe cache so the server // doesn't have to re-fetch. @@ -458,11 +495,17 @@ } // Persist before rendering so auto-refresh can re-hydrate. 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) { out.innerHTML = '
' + esc(e.message) + '
'; } 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; } } From 13dd3a833044fd6b38f00743262a02fb9eca63a6 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 15:21:00 +0200 Subject: [PATCH 03/10] i18n: prepend a strong language directive for portfolio + chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 --- app/routers/chat.py | 18 +++++++++++------- app/services/i18n.py | 28 ++++++++++++++++++++++++++++ app/services/portfolio_analysis.py | 15 +++++++++++++-- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/app/routers/chat.py b/app/routers/chat.py index 20f99e5..6e12a8e 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -21,7 +21,7 @@ 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.i18n import language_directive_lead, 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 @@ -165,13 +165,17 @@ async def chat( 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. + # Respect the user's interface language preference. The tail + # "Respond in X" clause is easy for the model to drop when the + # rest of the prompt is English (long log content, English + # market data, English headlines), so we ALSO prepend a stronger + # language directive at the top — see services/i18n. 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}] for m in history: diff --git a/app/services/i18n.py b/app/services/i18n.py index 742373d..55d00da 100644 --- a/app/services/i18n.py +++ b/app/services/i18n.py @@ -46,3 +46,31 @@ def respond_in_clause(lang: str | None) -> str: if not lang or lang == "en" or lang not in LANGUAGES: return "" 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" + ) diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index 1f6bea7..dfd3cb1 100644 --- a/app/services/portfolio_analysis.py +++ b/app/services/portfolio_analysis.py @@ -31,7 +31,7 @@ from app.config import get_settings from app.db import utcnow from app.logging import get_logger from app.models import AICall -from app.services.i18n import LANGUAGES, respond_in_clause +from app.services.i18n import LANGUAGES, language_directive_lead, 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 ( @@ -282,7 +282,18 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]: head = enriched[:MAX_POSITIONS_INLINED] tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED) - system = build_system_prompt(req.tone, req.analysis) + "\n\n" + _SYSTEM_OVERRIDES + 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 = [ f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}", From 21835afebeb46c6209dd37fa5c3613da3812a71d Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 15:32:58 +0200 Subject: [PATCH 04/10] analyze: send the live toggle lang from the frontend, log resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/routers/universe.py | 13 +++++++++++-- app/static/js/portfolio.js | 8 ++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/routers/universe.py b/app/routers/universe.py index d8d64ee..ea1d633 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -362,10 +362,19 @@ async def analyze_portfolio( except Exception: 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" ) - 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: req = portfolio_analysis.parse_request(payload) diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index 767f078..7ab75f5 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -469,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 { const r = await fetch('/api/analyze', { method: 'POST', @@ -478,6 +485,7 @@ positions: pie.positions, prices: prices, base_currency: pie.base_currency || 'GBP', + lang: userLang, }), }); const data = await r.json(); From dbb14340db7373b4abee2aadfc0d3e88ced559df Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 15:34:45 +0200 Subject: [PATCH 05/10] fix: ascii quotes in settings.html script tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two - + + {% endif %}