Compare commits

..

No commits in common. "0060166d324dbe4e4978b6fd65fbe0fff7c612e1" and "f9534f7ad697d8330aa1593fba1faf5baec1b8c2" have entirely different histories.

10 changed files with 52 additions and 279 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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"
)

View file

@ -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.

View file

@ -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"

View file

@ -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; }

View file

@ -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;

View file

@ -372,24 +372,13 @@
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</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">' +
(pie.analysis && pie.analysis.content
? ''
: '<button id="pf-analyze" type="button">Generate AI analysis</button>') +
'<button id="pf-analyze" type="button">Generate AI analysis</button>' +
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
'</div>' +
'<div id="pf-analysis" class="pf-analysis" hidden></div>';
const analyzeBtn = document.getElementById('pf-analyze');
if (analyzeBtn) {
analyzeBtn.addEventListener('click', () => runAnalysis(pie, enriched));
}
document.getElementById('pf-analyze').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();
@ -401,16 +390,13 @@
// 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. The
// regenerate callback closes over the current pie/enriched so a
// click rebuilds the analysis with the same context that drove
// the initial render.
// header to collapse manually within a single refresh window.
if (pie.analysis && pie.analysis.content) {
showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched));
showAnalysis(pie.analysis, { open: true });
}
}
function showAnalysis(analysis, opts, onRegenerate) {
function showAnalysis(analysis, opts) {
const out = document.getElementById('pf-analysis');
if (!out) return;
const openAttr = (opts && opts.open) ? ' open' : '';
@ -421,43 +407,20 @@
'<span class="pf-analysis__head-left">' +
'AI analysis' +
'</span>' +
'<span class="pf-analysis__head-right">' +
'<span class="pf-as-of">' +
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>' +
'<span class="pf-as-of">' +
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
' UTC</span>' +
'</summary>' +
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
'</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) {
const out = document.getElementById('pf-analysis');
// 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');
const btn = document.getElementById('pf-analyze');
out.hidden = false;
out.innerHTML = '<div class="empty">generating…</div>';
if (btn) btn.disabled = true;
btn.disabled = true;
// Build the prices payload from the universe cache so the server
// doesn't have to re-fetch.
@ -469,13 +432,6 @@
}
}
// 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',
@ -485,7 +441,6 @@
positions: pie.positions,
prices: prices,
base_currency: pie.base_currency || 'GBP',
lang: userLang,
}),
});
const data = await r.json();
@ -503,17 +458,11 @@
}
// Persist before rendering so auto-refresh can re-hydrate.
saveAnalysis(data);
// 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));
showAnalysis(data, { open: true });
} catch (e) {
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
} finally {
// 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;
btn.disabled = false;
}
}

View file

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

View file

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