news: auto-tag headlines + market-aware cadence + filter UI

- Move news_job from hourly to 3x/hour (cron 10,30,50), with a CadencePolicy
  gate that throttles to active hours (07-21 UTC weekdays at 20 min), off-hours
  (3 h), weekends (6 h). Keeps the daytime feed fresh without spamming RSS
  sources overnight.
- Tag each headline on ingestion via DeepSeek (BATCH_SIZE=25, max_tokens=4000,
  json.JSONDecoder().raw_decode + per-row regex recovery for resilient parsing).
  Vocabulary: 16 tags including new EU / USA / AI / Conflict. NULL tags are
  picked up automatically on the next news_job run, so back-tagging is implicit
  rather than a separate migration step.
- Tag UI: pill bar above the feed with off → include → exclude cycle on click;
  shift-click jumps straight to exclude. State persists in localStorage and is
  injected into /api/news requests via htmx:configRequest. Per-row chips sit to
  the right of the headline (new 5-column grid: age | source | title | tags |
  UTC) so vertical density stays high.
- Strategic log header bug: model was hallucinating "(Updated 21:30 UTC)" in
  future tense. Bumped PROMPT_VERSION 6→7, added explicit ban on time-of-day
  clauses, and supply the actual current UTC time in the user prompt so the
  model has no need to invent one.

Migration 0012 adds headlines.tags (JSON, nullable). Tests cover vocabulary
integrity, validation/normalisation, and the JSON-recovery parser (17 tests).
This commit is contained in:
Giorgio Gilestro 2026-05-21 23:25:03 +01:00
parent 6e7f57c6b2
commit 2013bfa8cc
15 changed files with 745 additions and 25 deletions

View file

@ -28,6 +28,69 @@
}
document.body.addEventListener('htmx:configRequest', function (evt) {
evt.detail.parameters.tone = currentTone();
// News tag filters — only attach to /api/news requests.
if ((evt.detail.path || '').indexOf('/api/news') === 0) {
var inc = newsTags('include');
var exc = newsTags('exclude');
if (inc.length) evt.detail.parameters.tags = inc.join(',');
if (exc.length) evt.detail.parameters.exclude_tags = exc.join(',');
}
});
// News tag preference: include / exclude sets persisted in
// localStorage. Click cycles include → exclude → off;
// shift-click goes straight to exclude.
function newsTags(kind) {
try {
var raw = localStorage.getItem('cassandra.news.' + kind);
var arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (e) { return []; }
}
function setNewsTags(kind, arr) {
try { localStorage.setItem('cassandra.news.' + kind, JSON.stringify(arr)); }
catch (e) {}
}
function refreshNewsPanels() {
document.querySelectorAll('[hx-get*="/api/news"]').forEach(function (el) {
if (window.htmx) window.htmx.trigger(el, 'tags-changed');
});
}
// Event delegation so HTMX-swapped pills work without rebinding.
document.addEventListener('click', function (e) {
var el = e.target.closest && e.target.closest('.news-tag');
if (!el) return;
e.preventDefault();
var tag = el.getAttribute('data-tag') || '';
if (el.classList.contains('news-tag--clear')) {
setNewsTags('include', []);
setNewsTags('exclude', []);
refreshNewsPanels();
return;
}
var inc = newsTags('include');
var exc = newsTags('exclude');
var inInc = inc.indexOf(tag);
var inExc = exc.indexOf(tag);
if (e.shiftKey) {
// Shift-click → toggle exclude membership; remove from include.
if (inInc >= 0) inc.splice(inInc, 1);
if (inExc >= 0) exc.splice(inExc, 1);
else exc.push(tag);
} else {
// Plain click → cycle: off → include → exclude → off.
if (inInc >= 0) {
inc.splice(inInc, 1);
exc.push(tag);
} else if (inExc >= 0) {
exc.splice(inExc, 1);
} else {
inc.push(tag);
}
}
setNewsTags('include', inc);
setNewsTags('exclude', exc);
refreshNewsPanels();
});
// Reflect the saved value in the toggle on load.
var pill = document.getElementById('tone-toggle');

View file

@ -77,7 +77,7 @@
</div>
<div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=40"
hx-trigger="load, every 60s"
hx-trigger="load, every 60s, tags-changed"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>

View file

@ -9,7 +9,7 @@
</div>
<div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=200"
hx-trigger="load, every 60s"
hx-trigger="load, every 60s, tags-changed"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>

View file

@ -1,11 +1,30 @@
{% if tag_vocabulary %}
<div class="news-tags" data-include="{{ active_include|join(',') }}" data-exclude="{{ active_exclude|join(',') }}">
{% for tag in tag_vocabulary %}
<button type="button" class="news-tag"
data-tag="{{ tag }}"
{% if tag in active_include %}data-state="include"{% elif tag in active_exclude %}data-state="exclude"{% endif %}
title="{{ tag_labels.get(tag, tag) }} — click to include only, shift-click to exclude">
{{ tag_labels.get(tag, tag) }}
</button>
{% endfor %}
{% if active_include or active_exclude %}
<button type="button" class="news-tag news-tag--clear" data-tag="" title="Clear all filters">clear</button>
{% endif %}
</div>
{% endif %}
{% if not headlines %}
<div class="empty">no headlines in window</div>
<div class="empty">no headlines in window{% if active_include or active_exclude %} (after tag filter){% endif %}</div>
{% else %}
{% for h in headlines %}
<div class="news-row">
<span class="age">{{ h.age }}</span>
<span class="source">{{ h.source }}</span>
<a class="title" href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
<span class="news-row__tags">
{% for t in h.tags or [] %}<span class="tag-chip" data-tag="{{ t }}">{{ tag_labels.get(t, t) }}</span>{% endfor %}
</span>
{% if h.iso %}
<time class="local" datetime="{{ h.iso }}" title="{{ h.iso }}">{{ h.utc_short }}</time>
{% else %}