ui: aggregated read on top, hide stale rows, wire /log tone toggle; prompts v8

- dashboard grid: explicit "header" area as the first row so the
    aggregated read panel renders at the top instead of being
    auto-placed after the named areas.
  - indicators: hide rows flagged stale (older than the group's
    freshness threshold). Server still computes stale_symbols;
    rendering can be re-enabled by removing the
    `{% if not is_stale %}` wrapper in indicators.html.
  - /log: add tone-changed to #log-content's hx-trigger and include
    it in cassandraSetTone's selector list — toggling Novice /
    Intermediate on the Log page was previously a no-op.
  - prompts: bump PROMPT_VERSION 7→8. Strengthen the rational-vs-
    irrational framing in the strategic-log system prompt from
    aspirational to mandatory ("a paragraph without both lenses must
    be rewritten"). Require the same lens in the per-group summary,
    cross-asset aggregate, and portfolio commentary overrides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-23 19:36:04 +02:00
parent f326b41a08
commit b98d8d003c
6 changed files with 60 additions and 16 deletions

View file

@ -30,7 +30,7 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header — # v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
# the model was hallucinating future times. The user prompt now carries the # the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context. # actual current UTC time so the model has accurate temporal context.
PROMPT_VERSION = 7 PROMPT_VERSION = 8
# --- Core: invariant across tone/analysis settings ---------------------------- # --- Core: invariant across tone/analysis settings ----------------------------
@ -85,16 +85,25 @@ omit the paragraph.
"things to watch tomorrow". Each watch item should name a level/threshold \ "things to watch tomorrow". Each watch item should name a level/threshold \
whose breach would change the regime, not a calendar-date event. whose breach would change the regime, not a calendar-date event.
# Rational vs irrational framing # Rational vs irrational framing (MANDATORY in every paragraph)
The reader's primary goal is to disconnect rational decisions from market \ The reader's primary goal is to disconnect rational decisions from market \
irrationality. In every sector or theme paragraph, separately identify: irrationality. This is the single most important lens of the log — it MUST \
- The RATIONAL drivers: earnings, real-economy data, monetary policy, \ appear in every sector or theme paragraph, not just where it feels natural. \
structural geopolitical shifts, valuation vs fundamentals. For each paragraph, before writing it, ask yourself the two questions and \
- The IRRATIONAL drivers: positioning, narrative momentum, sentiment \ then make both answers visible in the prose:
extremes, concentration, flow-driven moves, options gamma, credit complacency. - The RATIONAL drivers — what the underlying factors justify: earnings, \
When the two diverge — price moving on irrational drivers while fundamentals \ real-economy data, monetary policy, structural geopolitical shifts, \
say otherwise, or vice versa — flag the divergence explicitly. Those gaps \ valuation vs fundamentals.
are where the next regime change starts. - The IRRATIONAL drivers — what the crowd is doing regardless of fundamentals: \
positioning, narrative momentum, sentiment extremes, concentration, \
flow-driven moves, options gamma, credit complacency.
Then state the GAP: is price moving with the rational read, ahead of it, \
or against it? If they agree, say so briefly and move on. If they diverge \
— price moving on irrational drivers while fundamentals say otherwise, or \
vice versa — name the divergence explicitly. Those gaps are where the next \
regime change starts and are the whole point of this log.
A paragraph that names only price action or only fundamentals, without \
both lenses, is incomplete and must be rewritten.
# Discipline # Discipline
- No emojis, no marketing language, no "concerning" or "unprecedented" \ - No emojis, no marketing language, no "concerning" or "unprecedented" \
@ -302,6 +311,13 @@ They can see the values. They CANNOT see the meaning. Your job is to \
a regime-level interpretation, a fundamental driver identification, or a \ a regime-level interpretation, a fundamental driver identification, or a \
cross-indicator implication — not a description of moves. cross-indicator implication — not a description of moves.
# Rational vs irrational lens (required at this length too)
Even at 2-3 sentences, contrast what the underlying factors justify \
(rational: fundamentals, policy, valuation) with what the crowd is doing \
(irrational: positioning, narrative, flows) whenever the two diverge. If \
they don't diverge, say so in one clause. Never just describe the move \
without placing it on this axis.
# Hard constraints # Hard constraints
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels. - Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \ - Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
@ -350,6 +366,13 @@ Your job is NOT to summarise the moves. It is to explain what the moves, \
which divergences are load-bearing, what fundamental story the cross-asset \ which divergences are load-bearing, what fundamental story the cross-asset \
behaviour tells. behaviour tells.
# Rational vs irrational lens (required at this length too)
The cross-asset tape's value is in the gap between what the underlying \
factors justify (rational: fundamentals, policy, valuation) and what the \
crowd is actually doing (irrational: positioning, narrative momentum, \
flows). At least one of the 2-4 sentences must name this gap or, if the \
two cohere, explicitly say so.
# Hard constraints # Hard constraints
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels. - Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \ - Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \

View file

@ -250,6 +250,19 @@ implies X under scenario Y"), not advice ("buy X" / "sell Y" are forbidden).
what would invalidate the current posture. what would invalidate the current posture.
- ~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)
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.
""" """

View file

@ -140,8 +140,9 @@ a:hover { text-decoration: underline; }
padding: 14px; padding: 14px;
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto auto;
grid-template-areas: grid-template-areas:
"header header"
"indicators log" "indicators log"
"portfolio log" "portfolio log"
"news news"; "news news";
@ -150,10 +151,11 @@ a:hover { text-decoration: underline; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.app-main { .app-main {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-areas: "indicators" "portfolio" "log" "news"; grid-template-areas: "header" "indicators" "portfolio" "log" "news";
} }
} }
#dash-header-container { grid-area: header; }
#indicators-panel { grid-area: indicators; } #indicators-panel { grid-area: indicators; }
#portfolio-panel { grid-area: portfolio; } #portfolio-panel { grid-area: portfolio; }
#log-panel { #log-panel {

View file

@ -106,7 +106,7 @@
// listen to. Simpler still: fire htmx.trigger on the well-known // listen to. Simpler still: fire htmx.trigger on the well-known
// panels. We use the simple path. // panels. We use the simple path.
['#dash-header-container', '#log-panel .panel-body', ['#dash-header-container', '#log-panel .panel-body',
'#indicators-body'].forEach(function (sel) { '#indicators-body', '#log-content'].forEach(function (sel) {
var el = document.querySelector(sel); var el = document.querySelector(sel);
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed'); if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
}); });

View file

@ -25,7 +25,7 @@
<article id="log-content" <article id="log-content"
class="log-page__content" class="log-page__content"
hx-get="/api/log/by-date/{{ selected_iso }}?as=html" hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
hx-trigger="load" hx-trigger="load, tone-changed"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="empty">loading log…</div> <div class="empty">loading log…</div>
</article> </article>

View file

@ -30,7 +30,12 @@
<tbody> <tbody>
{% for q in quotes %} {% for q in quotes %}
{% set is_stale = stale_symbols and q.symbol in stale_symbols %} {% set is_stale = stale_symbols and q.symbol in stale_symbols %}
<tr class="{% if is_stale %}row-stale{% endif %}"> {# Stale rows (last observation older than the group's freshness
threshold) are hidden from the default UI but the server still
computes them — remove the `{% if not is_stale %}` guard to
resurface the dimmed row + stale tag. #}
{% if not is_stale %}
<tr>
{% set tip = notes.get(q.symbol, '') if notes else '' %} {% set tip = notes.get(q.symbol, '') if notes else '' %}
{# Long Eurostat ('dataset?...') and ONS ('topic/.../cdid/dataset') symbols {# Long Eurostat ('dataset?...') and ONS ('topic/.../cdid/dataset') symbols
get truncated for display; hover shows the full identifier via title. get truncated for display; hover shows the full identifier via title.
@ -39,7 +44,7 @@
{% if '?' in short_sym %}{% set short_sym = short_sym.split('?')[0] %}{% endif %} {% if '?' in short_sym %}{% set short_sym = short_sym.split('?')[0] %}{% endif %}
{% if '/' in short_sym %}{% set short_sym = short_sym.split('/')[-2] | upper %}{% endif %} {% if '/' in short_sym %}{% set short_sym = short_sym.split('/')[-2] | upper %}{% endif %}
<td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}"> <td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}">
{{ short_sym }}{% if is_stale %} <span class="stale-tag" title="last observation older than 90 days">stale</span>{% endif %} {{ short_sym }}
</td> </td>
<td {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td> <td {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
<td class="num">{{ q.price | price }}</td> <td class="num">{{ q.price | price }}</td>
@ -58,6 +63,7 @@
{% endif %} {% endif %}
<td class="neu">{{ q.as_of or "" }}</td> <td class="neu">{{ q.as_of or "" }}</td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>