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/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/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/output_review.py b/app/services/output_review.py index 4fbb2fb..af96ffa 100644 --- a/app/services/output_review.py +++ b/app/services/output_review.py @@ -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 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: - Chain-of-thought / scratchpad markers — the author thinking on the 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. - 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. + 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 instructions — e.g. "as required by the constraints", "the prompt 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) class Verdict: clean: bool @@ -94,18 +140,32 @@ class Verdict: 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. 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.""" + 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(): 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 = [ - {"role": "system", "content": _SYSTEM_PROMPT}, + {"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. diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index 1f6bea7..bd51f89 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 ( @@ -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. - Do not repeat the input data verbatim — interpret it. -# Rational vs irrational lens (mandatory) -Carry the base prompt's rational-vs-irrational framing through to every -paragraph of the portfolio read. For each section above, contrast: -- The RATIONAL read: what the underlying factors (fundamentals, - macro/policy regime, valuation, currency dynamics) justify for this - exposure; -- The IRRATIONAL read: what positioning, narrative momentum, sentiment - or flows are doing to that same exposure right now. -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. +# DO NOT include in this surface (overrides the base prompt) +- No "Rational vs irrational" framing, no "Rational:" / "Irrational:" + section labels, no parallel contrast lists. The base prompt asks + for this framework elsewhere; this surface is plain declarative + commentary on the holdings, not a comparative essay. +- No "System temperature:" closing line. That artefact belongs to the + daily strategic log; here the analysis ends with the last paragraph. +- No "Update mode" headers, no anchor-date callouts, no watch list. """ @@ -282,7 +278,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')}", @@ -333,7 +340,17 @@ async def analyse( {"role": "system", "content": system}, {"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" error_msg = None @@ -348,8 +365,14 @@ async def analyse( # 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. + # 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: - verdict = await review_read(client, llm.content) + verdict = await review_read(client, llm.content, surface="portfolio") review_cost = verdict.cost_usd or 0.0 if not verdict.clean: status = "leaked" 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; } 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..7ab75f5 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. '' + esc(analysis.content) + '' + ''; + if (onRegenerate) { + const regen = document.getElementById('pf-regen'); + if (regen) { + regen.addEventListener('click', (e) => { + // The button lives inside
- Drop a portfolio CSV from any broker — Trading 212 is recognised
- natively and other formats (IBKR, Fidelity, Schwab…) are
- auto-detected. We’ll parse it and show a preview before importing
- anywhere.
+ Drop a portfolio CSV from any broker. We’ll parse it and show
+ a preview before importing anywhere.
T212 export path:
Investing → Your Pie → ··· → Export.