diff --git a/app/routers/chat.py b/app/routers/chat.py index 6e12a8e..20f99e5 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 language_directive_lead, respond_in_clause +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 @@ -165,17 +165,13 @@ async def chat( headlines=headlines, reference_line=REFERENCE_LINE, ) - # 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. + # 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 = ( - language_directive_lead(user_lang) - + system_prompt - + respond_in_clause(user_lang) - ) + system_prompt = 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 ea1d633..d8d64ee 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -362,19 +362,10 @@ async def analyze_portfolio( except Exception: raise HTTPException(status_code=400, detail="malformed JSON body") - # 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 = ( + user_lang = ( principal.user.lang if (principal.user and principal.user.lang) else "en" ) - 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"]) + payload["lang"] = user_lang try: req = portfolio_analysis.parse_request(payload) diff --git a/app/services/i18n.py b/app/services/i18n.py index 55d00da..742373d 100644 --- a/app/services/i18n.py +++ b/app/services/i18n.py @@ -46,31 +46,3 @@ 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 af96ffa..4fbb2fb 100644 --- a/app/services/output_review.py +++ b/app/services/output_review.py @@ -47,18 +47,6 @@ 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 @@ -68,9 +56,7 @@ 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. 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. + 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". @@ -101,38 +87,6 @@ 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 @@ -140,32 +94,18 @@ class Verdict: cost_usd: float | None # cost of the review call itself, for the ledger -async def review_read( - client: httpx.AsyncClient, - candidate: str, - surface: str | None = None, -) -> Verdict: +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. - - `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.""" + 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) - 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 bd51f89..1f6bea7 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, language_directive_lead, respond_in_clause +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 ( @@ -257,14 +257,18 @@ 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. -# 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. +# 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. """ @@ -278,18 +282,7 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]: head = enriched[:MAX_POSITIONS_INLINED] tail_count = max(0, len(enriched) - MAX_POSITIONS_INLINED) - # 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) - ) + system = 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')}", @@ -340,17 +333,7 @@ async def analyse( {"role": "system", "content": system}, {"role": "user", "content": user}, ], - # 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, + max_tokens=2000, ) status = "ok" error_msg = None @@ -365,14 +348,8 @@ 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, surface="portfolio") + verdict = await review_read(client, llm.content) 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 a66d5bb..6acaf6c 100644 --- a/app/static/css/layout.css +++ b/app/static/css/layout.css @@ -246,20 +246,6 @@ 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; } @@ -267,25 +253,10 @@ body.drawer-open .drawer-backdrop { opacity: 1; } #portfolio-panel { grid-area: portfolio; } #log-panel { grid-area: log; - /* 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; + /* 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; } diff --git a/app/static/css/portfolio.css b/app/static/css/portfolio.css index 83b13d0..cdf0417 100644 --- a/app/static/css/portfolio.css +++ b/app/static/css/portfolio.css @@ -86,13 +86,6 @@ .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; @@ -114,24 +107,6 @@ 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 7ab75f5..0f3ecb4 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -372,24 +372,13 @@ '' + '
' + 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. We’ll parse it and show
- a preview before importing anywhere.
+ 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.
T212 export path:
Investing → Your Pie → ··· → Export.