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.jobs._market_context import REFERENCE_LINE
|
||||||
from app.models import AICall, Headline, Quote, StrategicLog
|
from app.models import AICall, Headline, Quote, StrategicLog
|
||||||
from app.routers.api import _md_to_html
|
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.llm_prompts import build_chat_system_prompt
|
||||||
from app.services.openrouter import call_llm, month_start
|
from app.services.openrouter import call_llm, month_start
|
||||||
from app.services.output_review import review_read
|
from app.services.output_review import review_read
|
||||||
|
|
@ -165,13 +165,17 @@ async def chat(
|
||||||
headlines=headlines,
|
headlines=headlines,
|
||||||
reference_line=REFERENCE_LINE,
|
reference_line=REFERENCE_LINE,
|
||||||
)
|
)
|
||||||
# Respect the user's interface language preference: append a single
|
# Respect the user's interface language preference. The tail
|
||||||
# localized "respond in" nudge so the assistant answers in IT when
|
# "Respond in X" clause is easy for the model to drop when the
|
||||||
# the user has lang=it. The prompt + history (which includes the
|
# rest of the prompt is English (long log content, English
|
||||||
# user's own question, often in their language) are usually enough,
|
# market data, English headlines), so we ALSO prepend a stronger
|
||||||
# but the nudge guarantees the first reply lands correctly.
|
# language directive at the top — see services/i18n.
|
||||||
user_lang = principal.user.lang if principal and principal.user else "en"
|
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}]
|
msgs = [{"role": "system", "content": system_prompt}]
|
||||||
for m in history:
|
for m in history:
|
||||||
|
|
|
||||||
|
|
@ -362,10 +362,19 @@ async def analyze_portfolio(
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="malformed JSON body")
|
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"
|
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:
|
try:
|
||||||
req = portfolio_analysis.parse_request(payload)
|
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:
|
if not lang or lang == "en" or lang not in LANGUAGES:
|
||||||
return ""
|
return ""
|
||||||
return f"\n\nRespond in {LANGUAGES[lang]}."
|
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
|
Mark CLEAN only if the text reads like finished editorial commentary
|
||||||
a reader could see on a public dashboard without confusion.
|
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:
|
Mark UNCLEAN if the text contains ANY of:
|
||||||
- Chain-of-thought / scratchpad markers — the author thinking on the
|
- Chain-of-thought / scratchpad markers — the author thinking on the
|
||||||
page rather than presenting finished commentary. Phrases like
|
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.
|
front of the reader (self-questioning) are not.
|
||||||
- Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?",
|
- Self-questioning parentheticals: "Q1 2026? Actually Q4 2025?",
|
||||||
"is it X or Y?", any place where the author appears to be working
|
"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
|
- Meta-commentary about the task, output format, word limits, or
|
||||||
instructions — e.g. "as required by the constraints", "the prompt
|
instructions — e.g. "as required by the constraints", "the prompt
|
||||||
asks", "let me address each".
|
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)
|
@dataclass(frozen=True)
|
||||||
class Verdict:
|
class Verdict:
|
||||||
clean: bool
|
clean: bool
|
||||||
|
|
@ -94,18 +140,32 @@ class Verdict:
|
||||||
cost_usd: float | None # cost of the review call itself, for the ledger
|
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.
|
"""Ask the LLM whether `candidate` is a publishable read.
|
||||||
|
|
||||||
Returns Verdict(clean, reason, cost). Any error — provider failure,
|
Returns Verdict(clean, reason, cost). Any error — provider failure,
|
||||||
JSON parse failure, missing field, wrong type — yields a CONSERVATIVE
|
JSON parse failure, missing field, wrong type — yields a CONSERVATIVE
|
||||||
verdict (clean=False) so the caller drops the candidate. The
|
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():
|
if not candidate or not candidate.strip():
|
||||||
return Verdict(clean=False, reason="empty candidate", cost_usd=0.0)
|
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 = [
|
messages = [
|
||||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
{"role": "system", "content": system_prompt},
|
||||||
# Sent as a fenced user turn so the model can't confuse the
|
# Sent as a fenced user turn so the model can't confuse the
|
||||||
# candidate with instructions, even if the candidate happens to
|
# candidate with instructions, even if the candidate happens to
|
||||||
# contain prompt-like prose.
|
# contain prompt-like prose.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from app.config import get_settings
|
||||||
from app.db import utcnow
|
from app.db import utcnow
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
from app.models import AICall
|
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.llm_prompts import build_system_prompt
|
||||||
from app.services.output_review import review_read
|
from app.services.output_review import review_read
|
||||||
from app.services.openrouter import (
|
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.
|
- ~350 words. No bullet lists. No buy/sell recommendations.
|
||||||
- Do not repeat the input data verbatim — interpret it.
|
- Do not repeat the input data verbatim — interpret it.
|
||||||
|
|
||||||
# Rational vs irrational lens (mandatory)
|
# DO NOT include in this surface (overrides the base prompt)
|
||||||
Carry the base prompt's rational-vs-irrational framing through to every
|
- No "Rational vs irrational" framing, no "Rational:" / "Irrational:"
|
||||||
paragraph of the portfolio read. For each section above, contrast:
|
section labels, no parallel contrast lists. The base prompt asks
|
||||||
- The RATIONAL read: what the underlying factors (fundamentals,
|
for this framework elsewhere; this surface is plain declarative
|
||||||
macro/policy regime, valuation, currency dynamics) justify for this
|
commentary on the holdings, not a comparative essay.
|
||||||
exposure;
|
- No "System temperature:" closing line. That artefact belongs to the
|
||||||
- The IRRATIONAL read: what positioning, narrative momentum, sentiment
|
daily strategic log; here the analysis ends with the last paragraph.
|
||||||
or flows are doing to that same exposure right now.
|
- No "Update mode" headers, no anchor-date callouts, no watch list.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -282,7 +278,18 @@ def build_prompt(req: AnalysisRequest) -> tuple[str, str]:
|
||||||
head = enriched[:MAX_POSITIONS_INLINED]
|
head = enriched[:MAX_POSITIONS_INLINED]
|
||||||
tail_count = max(0, len(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 = [
|
user_parts = [
|
||||||
f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}",
|
f"# Portfolio commentary request — {utcnow().strftime('%Y-%m-%d')}",
|
||||||
|
|
@ -333,7 +340,17 @@ async def analyse(
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": user},
|
{"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"
|
status = "ok"
|
||||||
error_msg = None
|
error_msg = None
|
||||||
|
|
@ -348,8 +365,14 @@ async def analyse(
|
||||||
# buy/sell or allocation language is a regulatory hazard. Drop
|
# buy/sell or allocation language is a regulatory hazard. Drop
|
||||||
# the response on a reject and surface a retry-able error to the
|
# the response on a reject and surface a retry-able error to the
|
||||||
# caller; no analysis is ever persisted server-side anyway.
|
# 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:
|
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
|
review_cost = verdict.cost_usd or 0.0
|
||||||
if not verdict.clean:
|
if not verdict.clean:
|
||||||
status = "leaked"
|
status = "leaked"
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,20 @@ body.drawer-open .drawer-backdrop { opacity: 1; }
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-areas: "header" "indicators" "portfolio" "log" "news";
|
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; }
|
#dash-header-container { grid-area: header; }
|
||||||
|
|
@ -253,10 +267,25 @@ body.drawer-open .drawer-backdrop { opacity: 1; }
|
||||||
#portfolio-panel { grid-area: portfolio; }
|
#portfolio-panel { grid-area: portfolio; }
|
||||||
#log-panel {
|
#log-panel {
|
||||||
grid-area: log;
|
grid-area: log;
|
||||||
/* Stretch (default align-self) so the log panel's border reaches the
|
/* Bottom-align with the portfolio panel WITHOUT padding the inside
|
||||||
bottom of the portfolio next to it — the two right-hand panels
|
of either box. The key is `contain: size`: a grid item with this
|
||||||
align cleanly. The log body itself sits at the top of the panel;
|
contracts to declare "my contents do not contribute to my own
|
||||||
any height beyond its content is empty padding inside the box. */
|
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; }
|
#news-panel { grid-area: news; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,13 @@
|
||||||
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.pf-secondary { color: var(--muted); }
|
.pf-secondary { color: var(--muted); }
|
||||||
.pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
|
.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 {
|
.pf-analysis {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
|
|
@ -107,6 +114,24 @@
|
||||||
list-style: none; /* hide native marker in Firefox */
|
list-style: none; /* hide native marker in Firefox */
|
||||||
}
|
}
|
||||||
.pf-analysis__head::-webkit-details-marker { display: none; }
|
.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 {
|
.pf-analysis__head-left::before {
|
||||||
content: "▸ ";
|
content: "▸ ";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -372,13 +372,24 @@
|
||||||
'</tr></thead>' +
|
'</tr></thead>' +
|
||||||
'<tbody>' + rows + '</tbody>' +
|
'<tbody>' + rows + '</tbody>' +
|
||||||
'</table>' +
|
'</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">' +
|
'<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>' +
|
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="pf-analysis" class="pf-analysis" hidden></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', () => {
|
document.getElementById('pf-forget').addEventListener('click', () => {
|
||||||
if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) {
|
if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) {
|
||||||
clearPie();
|
clearPie();
|
||||||
|
|
@ -390,13 +401,16 @@
|
||||||
// wipe it. Rendered expanded so the user keeps seeing the body they
|
// wipe it. Rendered expanded so the user keeps seeing the body they
|
||||||
// just generated — collapsing it under their cursor every minute
|
// just generated — collapsing it under their cursor every minute
|
||||||
// reads as "the analysis disappeared". They can still click the
|
// 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) {
|
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');
|
const out = document.getElementById('pf-analysis');
|
||||||
if (!out) return;
|
if (!out) return;
|
||||||
const openAttr = (opts && opts.open) ? ' open' : '';
|
const openAttr = (opts && opts.open) ? ' open' : '';
|
||||||
|
|
@ -407,20 +421,43 @@
|
||||||
'<span class="pf-analysis__head-left">' +
|
'<span class="pf-analysis__head-left">' +
|
||||||
'AI analysis' +
|
'AI analysis' +
|
||||||
'</span>' +
|
'</span>' +
|
||||||
|
'<span class="pf-analysis__head-right">' +
|
||||||
'<span class="pf-as-of">' +
|
'<span class="pf-as-of">' +
|
||||||
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
|
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
|
||||||
' UTC</span>' +
|
' 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>' +
|
'</summary>' +
|
||||||
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
|
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
|
||||||
'</details>';
|
'</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) {
|
async function runAnalysis(pie, enriched) {
|
||||||
const out = document.getElementById('pf-analysis');
|
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.hidden = false;
|
||||||
out.innerHTML = '<div class="empty">generating…</div>';
|
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
|
// Build the prices payload from the universe cache so the server
|
||||||
// doesn't have to re-fetch.
|
// 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 {
|
try {
|
||||||
const r = await fetch('/api/analyze', {
|
const r = await fetch('/api/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -441,6 +485,7 @@
|
||||||
positions: pie.positions,
|
positions: pie.positions,
|
||||||
prices: prices,
|
prices: prices,
|
||||||
base_currency: pie.base_currency || 'GBP',
|
base_currency: pie.base_currency || 'GBP',
|
||||||
|
lang: userLang,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
|
|
@ -458,11 +503,17 @@
|
||||||
}
|
}
|
||||||
// Persist before rendering so auto-refresh can re-hydrate.
|
// Persist before rendering so auto-refresh can re-hydrate.
|
||||||
saveAnalysis(data);
|
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) {
|
} catch (e) {
|
||||||
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
|
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
|
||||||
} finally {
|
} 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>
|
<details class="settings-section" id="import" open>
|
||||||
<summary class="settings-section__head">Import portfolio (CSV)</summary>
|
<summary class="settings-section__head">Import portfolio (CSV)</summary>
|
||||||
<p class="settings-section__lede">
|
<p class="settings-section__lede">
|
||||||
Drop a portfolio CSV from any broker — Trading 212 is recognised
|
Drop a portfolio CSV from any broker. We’ll parse it and show
|
||||||
natively and other formats (IBKR, Fidelity, Schwab…) are
|
a preview before importing anywhere.
|
||||||
auto-detected. We’ll parse it and show a preview before importing
|
|
||||||
anywhere.
|
|
||||||
<br><span class="muted">T212 export path:
|
<br><span class="muted">T212 export path:
|
||||||
<span class="neu">Investing → Your Pie → ··· → Export</span>.</span>
|
<span class="neu">Investing → Your Pie → ··· → Export</span>.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -332,8 +330,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for(‘static’, path=’/js/portfolio-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’) }}" defer></script>
|
<script src="{{ url_for('static', path='/js/settings-sync.js') }}?v={{ ASSET_VERSION }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ def stub_reviewer(monkeypatch):
|
||||||
"""
|
"""
|
||||||
from app.services.output_review import Verdict
|
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)
|
return Verdict(clean=True, reason="stubbed-by-conftest", cost_usd=0.0)
|
||||||
|
|
||||||
for mod_path in (
|
for mod_path in (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue