Compare commits
10 commits
f9534f7ad6
...
0060166d32
| Author | SHA1 | Date | |
|---|---|---|---|
| 0060166d32 | |||
| d47b310898 | |||
| de3a9bfa66 | |||
| 8e7ea673ce | |||
| 838f227175 | |||
| dbb14340db | |||
| 21835afebe | |||
| 13dd3a8330 | |||
| 736d161990 | |||
| 652995feea |
10 changed files with 279 additions and 52 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -372,13 +372,24 @@
|
|||
'</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">' +
|
||||
'<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>' +
|
||||
'</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', () => {
|
||||
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 @@
|
|||
'<span class="pf-analysis__head-left">' +
|
||||
'AI analysis' +
|
||||
'</span>' +
|
||||
'<span class="pf-as-of">' +
|
||||
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
|
||||
' UTC</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>' +
|
||||
'</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');
|
||||
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 = '<div class="empty">generating…</div>';
|
||||
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.
|
||||
|
|
@ -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 {
|
||||
const r = await fetch('/api/analyze', {
|
||||
method: 'POST',
|
||||
|
|
@ -441,6 +485,7 @@
|
|||
positions: pie.positions,
|
||||
prices: prices,
|
||||
base_currency: pie.base_currency || 'GBP',
|
||||
lang: userLang,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
|
|
@ -458,11 +503,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 = '<div class="pf-warn">' + esc(e.message) + '</div>';
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,10 +98,8 @@
|
|||
<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 — 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.
|
||||
<br><span class="muted">T212 export path:
|
||||
<span class="neu">Investing → Your Pie → ··· → Export</span>.</span>
|
||||
</p>
|
||||
|
|
@ -332,8 +330,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for(‘static’, path=’/js/portfolio-sync.js’) }}" defer></script>
|
||||
<script src="{{ url_for(‘static’, path=’/js/settings-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') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def stub_reviewer(monkeypatch):
|
|||
"""
|
||||
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)
|
||||
|
||||
for mod_path in (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue