phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
|
|
@ -15,6 +15,40 @@
|
|||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
|
||||
<script>
|
||||
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
|
||||
// HTMX request so AI-generated panels resolve to the right cached
|
||||
// variant. Preference persists in localStorage; see toggle in header.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
function currentTone() {
|
||||
try {
|
||||
var t = localStorage.getItem('cassandra.tone');
|
||||
return (t === 'NOVICE' || t === 'INTERMEDIATE') ? t : 'INTERMEDIATE';
|
||||
} catch (e) { return 'INTERMEDIATE'; }
|
||||
}
|
||||
document.body.addEventListener('htmx:configRequest', function (evt) {
|
||||
evt.detail.parameters.tone = currentTone();
|
||||
});
|
||||
// Reflect the saved value in the toggle on load.
|
||||
var pill = document.getElementById('tone-toggle');
|
||||
if (pill) pill.dataset.tone = currentTone();
|
||||
});
|
||||
|
||||
window.cassandraSetTone = function (newTone) {
|
||||
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
|
||||
var pill = document.getElementById('tone-toggle');
|
||||
if (pill) pill.dataset.tone = newTone;
|
||||
// Trigger a re-fetch of every AI-driven HTMX target on the page.
|
||||
// Easiest: dispatch a custom event that the relevant elements
|
||||
// listen to. Simpler still: fire htmx.trigger on the well-known
|
||||
// panels. We use the simple path.
|
||||
['#dash-header-container', '#log-panel .panel-body',
|
||||
'#indicators-body'].forEach(function (sel) {
|
||||
var el = document.querySelector(sel);
|
||||
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// Render any <time datetime="..."> in the browser's local timezone.
|
||||
// Re-runs after every HTMX swap so freshly-loaded news rows pick up too.
|
||||
|
|
@ -48,6 +82,13 @@
|
|||
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
||||
role="group" aria-label="Explanation level">
|
||||
<button type="button" data-value="NOVICE"
|
||||
onclick="cassandraSetTone('NOVICE')">Novice</button>
|
||||
<button type="button" data-value="INTERMEDIATE"
|
||||
onclick="cassandraSetTone('INTERMEDIATE')">Intermediate</button>
|
||||
</div>
|
||||
<button class="theme-toggle" type="button" aria-label="Toggle theme"
|
||||
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
||||
<span class="theme-toggle__label"></span>
|
||||
|
|
@ -66,12 +107,110 @@
|
|||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="app-footer"
|
||||
hx-get="/api/health"
|
||||
hx-trigger="load, every 30s"
|
||||
{# Shared glossary tooltip (Novice mode). Single floating element
|
||||
positioned by JS to escape sticky-bar stacking and viewport edges. #}
|
||||
<div id="glossary-tooltip" role="tooltip" hidden></div>
|
||||
<script>
|
||||
(function () {
|
||||
const tip = document.getElementById('glossary-tooltip');
|
||||
let activeEl = null;
|
||||
|
||||
function position(el) {
|
||||
// Measure after content is set so dimensions are accurate.
|
||||
tip.style.left = '0px';
|
||||
tip.style.top = '0px';
|
||||
tip.hidden = false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const tipRect = tip.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
|
||||
// Decide above or below based on available space.
|
||||
const spaceAbove = rect.top - margin;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - margin;
|
||||
let top = (spaceAbove >= tipRect.height || spaceAbove >= spaceBelow)
|
||||
? rect.top - tipRect.height - 6
|
||||
: rect.bottom + 6;
|
||||
|
||||
// Clamp top into the viewport.
|
||||
if (top < margin) top = margin;
|
||||
if (top + tipRect.height > window.innerHeight - margin) {
|
||||
top = window.innerHeight - tipRect.height - margin;
|
||||
}
|
||||
|
||||
// Horizontal: anchor to term's left edge, clamp to viewport.
|
||||
let left = rect.left;
|
||||
if (left + tipRect.width > window.innerWidth - margin) {
|
||||
left = window.innerWidth - tipRect.width - margin;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
tip.setAttribute('data-visible', '1');
|
||||
}
|
||||
|
||||
function show(el) {
|
||||
const def = el.getAttribute('data-def');
|
||||
if (!def) return;
|
||||
activeEl = el;
|
||||
tip.textContent = def;
|
||||
position(el);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
activeEl = null;
|
||||
tip.removeAttribute('data-visible');
|
||||
tip.hidden = true;
|
||||
}
|
||||
|
||||
// Event delegation with capture so we catch elements HTMX swaps in
|
||||
// after page load.
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
const el = e.target.closest && e.target.closest('.glossary');
|
||||
if (el) show(el);
|
||||
else if (activeEl && !e.target.closest('.glossary')) hide();
|
||||
}, true);
|
||||
document.addEventListener('focusin', function (e) {
|
||||
if (e.target.classList && e.target.classList.contains('glossary')) {
|
||||
show(e.target);
|
||||
}
|
||||
});
|
||||
document.addEventListener('focusout', function (e) {
|
||||
if (e.target.classList && e.target.classList.contains('glossary')) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile / touch: tap to toggle, tap-elsewhere to dismiss.
|
||||
document.addEventListener('click', function (e) {
|
||||
const el = e.target.closest && e.target.closest('.glossary');
|
||||
if (el) {
|
||||
// Re-show (or toggle off if it's the currently-active one).
|
||||
if (activeEl === el) hide();
|
||||
else show(el);
|
||||
e.preventDefault();
|
||||
} else if (activeEl) {
|
||||
hide();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Hide on scroll / resize so the tooltip doesn't drift away from
|
||||
// its term.
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
// Hide when HTMX swaps content (term may have been replaced).
|
||||
document.body.addEventListener('htmx:beforeSwap', hide);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<footer class="markets-bar"
|
||||
hx-get="/api/markets-bar?as=html"
|
||||
hx-trigger="load, every 60s"
|
||||
hx-swap="innerHTML"
|
||||
id="ops-footer">
|
||||
<span class="led idle"></span> awaiting status…
|
||||
id="markets-bar">
|
||||
<div class="markets-bar__inner">
|
||||
<div class="markets-bar__list"><span class="empty">awaiting markets…</span></div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue