feat(#170): Chart-Click-Tag-Filter + Transparenz-Banner + top_k 50 default

User-Feedback: "Welche Meldungen werden da angezeigt? Es wurden ja viel
mehr indiziert."

**1. Transparenz-Banner im News-Tab**

Zeigt jetzt explizit:
- "X News angezeigt"
- "Y News im Zeitraum (mit Embedding)"
- "Z News insgesamt embedded"
- Hinweis wenn only_relevant aktiv ist
- Hinweis wenn top_k limitierend ist

**2. Chart als Filter** — Klick auf einen Tag im News-Volumen-Chart
wechselt zum News-Tab und filtert auf diesen Tag.

- Chart bekommt onClick-Handler ueber getElementsAtEventForMode
- Cursor wechselt bei Hover ueber Datenpunkte
- Im News-Tab erscheint Pill "Tag: 2026-05-01 [× Tag-Filter entfernen]"

**3. Backend `single_date`-Param**

`aggregate_top_themen(single_date="YYYY-MM-DD")` filtert auf genau
diesen Tag (overrides days_window). Endpoint: `/api/aktuelle-themen/top
?date=YYYY-MM-DD`. Response neu: `n_in_window`, `n_shown`,
`filter.single_date`.

**4. Default top_k 20 → 50** (max 200), damit weniger oft auf
"top_k limitierend" gestoßen wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-03 21:24:38 +02:00
parent 3bf1de15b5
commit 7c1e0fa0b0
3 changed files with 69 additions and 6 deletions

View File

@ -2031,11 +2031,15 @@ async def api_aktuelle_themen_top(
min_similarity: float = 0.4,
matches_per_news: int = 3,
only_relevant: bool = False,
date: Optional[str] = None,
):
"""Top-K News der letzten N Tage mit Antrags-Match.
Mit `only_relevant=true` werden News mit Relevance-Level "low" oder
"none" rausgefiltert.
Mit `date=YYYY-MM-DD` werden nur News dieses Tages angezeigt
(overrides `days`).
"""
from .themen_matching import aggregate_top_themen
return aggregate_top_themen(
@ -2044,6 +2048,7 @@ async def api_aktuelle_themen_top(
min_similarity=min_similarity,
matches_per_news=matches_per_news,
only_relevant=only_relevant,
single_date=date,
)

View File

@ -168,7 +168,7 @@
<option value="30">30 Tage</option>
</select>
<label for="at-topk">Top-N News:</label>
<input type="number" id="at-topk" value="20" min="3" max="50" style="width:60px;" onchange="loadActiveTab()" />
<input type="number" id="at-topk" value="50" min="3" max="200" style="width:60px;" onchange="loadActiveTab()" />
<label for="at-minsim">Min. Similarity:</label>
<select id="at-minsim" onchange="loadActiveTab()">
<option value="0.30">0.30 (locker)</option>
@ -193,6 +193,7 @@
<!-- Tab Panels -->
<div id="at-tab-news" class="at-panel">
<div id="at-news-banner" style="font-family:var(--font-mono);font-size:11px;opacity:0.65;margin-bottom:1rem;"></div>
<div id="at-themen-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
@ -253,6 +254,7 @@
<script>
let _atZeitreiheChart = null;
let _atActiveTab = 'news';
let _atSingleDate = null; // YYYY-MM-DD wenn aus Chart geklickt
function atScoreClass(score) {
if (score == null) return '';
@ -303,20 +305,47 @@ function loadActiveTab() {
}
}
function clearDateFilter() {
_atSingleDate = null;
loadActiveTab();
}
async function loadThemen() {
const days = document.getElementById('at-days').value;
const topk = document.getElementById('at-topk').value;
const minsim = document.getElementById('at-minsim').value;
const onlyRel = document.getElementById('at-only-relevant').checked ? '1' : '0';
const list = document.getElementById('at-themen-list');
const banner = document.getElementById('at-news-banner');
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>';
try {
const r = await fetch(`/api/aktuelle-themen/top?days=${days}&top_k=${topk}&min_similarity=${minsim}&matches_per_news=3&only_relevant=${onlyRel}`);
let url = `/api/aktuelle-themen/top?days=${days}&top_k=${topk}&min_similarity=${minsim}&matches_per_news=3&only_relevant=${onlyRel}`;
if (_atSingleDate) url += `&date=${encodeURIComponent(_atSingleDate)}`;
const r = await fetch(url);
const data = await r.json();
// Banner mit Anzeige-Info
let bannerHtml = '';
if (_atSingleDate) {
bannerHtml += `<span style="display:inline-block;padding:3px 10px;background:rgba(0,157,165,0.18);color:var(--ecg-teal);border-radius:11px;font-weight:700;margin-right:8px;">Tag: ${_atSingleDate}</span>`;
bannerHtml += `<button onclick="clearDateFilter()" style="font-family:var(--font-mono);font-size:11px;padding:2px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);cursor:pointer;">× Tag-Filter entfernen</button>`;
bannerHtml += '<br>';
}
bannerHtml += `<strong>${data.n_shown || 0}</strong> News angezeigt · ${data.n_in_window || 0} News im Zeitraum (mit Embedding) · ${data.n_total_news || 0} insgesamt embedded`;
if (onlyRel === '1' && data.n_in_window > data.n_shown) {
bannerHtml += ` · <em>Filter "nur GWÖ-relevant" aktiv</em>`;
}
if (data.n_in_window > parseInt(topk)) {
bannerHtml += ` · top_k=${topk} ist limitierend, mehr News verfügbar`;
}
banner.innerHTML = bannerHtml;
if (!data.buckets || !data.buckets.length) {
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine News im Zeitfenster ' + (onlyRel === '1' ? '(Filter: nur GWÖ-relevant aktiv — versuch ohne Filter)' : 'oder noch nicht embedded') + '.</div>';
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">' + (
_atSingleDate ? `Keine News am ${_atSingleDate}` :
(onlyRel === '1' ? 'Keine News mit GWÖ-Relevanz im Zeitfenster — versuch Filter aus' : 'Keine News im Zeitfenster oder noch nicht embedded')
) + '.</div>';
return;
}
@ -484,9 +513,27 @@ async function loadZeitreihe() {
data: { labels: data.buckets, datasets: datasets },
options: {
responsive: true,
onClick: (event, elements) => {
// Aus Chart.getElementsAtEventForMode: nehme das X-Achsen-Element
const points = _atZeitreiheChart.getElementsAtEventForMode(
event, 'index', { intersect: false }, false,
);
if (points.length > 0) {
const dayLabel = data.buckets[points[0].index];
if (dayLabel) {
_atSingleDate = dayLabel;
// Auf den News-Tab wechseln
const newsTabBtn = document.querySelector('.at-tab[data-tab="news"]');
if (newsTabBtn) newsTabBtn.click();
}
}
},
onHover: (event, elements) => {
event.native.target.style.cursor = elements.length > 0 ? 'pointer' : 'default';
},
scales: {
y: { beginAtZero: true, stacked: true, title: { display: true, text: 'Artikel/Tag' } },
x: { title: { display: true, text: 'Datum' } },
x: { title: { display: true, text: 'Datum (klick öffnet den News-Tab gefiltert)' } },
},
plugins: {
legend: { position: 'bottom' }
@ -495,7 +542,7 @@ async function loadZeitreihe() {
});
const total = Object.values(data.series).reduce((s, arr) => s + arr.reduce((a, b) => a + b, 0), 0);
meta.textContent = `${total} News-Artikel über ${data.buckets.length} Tage, ${data.sources.length} Quellen.`;
meta.innerHTML = `${total} News-Artikel über ${data.buckets.length} Tage, ${data.sources.length} Quellen. <strong>Klick auf einen Tag</strong> wechselt zum News-Tab gefiltert auf diesen Tag.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}

View File

@ -270,6 +270,7 @@ def aggregate_top_themen(
min_similarity: float = 0.4,
matches_per_news: int = 3,
only_relevant: bool = False,
single_date: Optional[str] = None,
db_path: Optional[Path] = None,
) -> dict:
"""Top-K aktuelle News (letzte N Tage) mit jeweils ihren passendsten
@ -313,10 +314,17 @@ def aggregate_top_themen(
).timestamp()
except (ValueError, AttributeError):
continue
if news_ts < cutoff:
# single_date hat Vorrang: nur News dieses Tages
if single_date:
if not n["datum"].startswith(single_date):
continue
elif news_ts < cutoff:
continue
n["_ts"] = news_ts
fresh.append(n)
n_in_window = len(fresh)
# Nach Datum desc sortieren, top_k cutten
fresh.sort(key=lambda x: x["_ts"], reverse=True)
fresh = fresh[:top_k]
@ -387,12 +395,15 @@ def aggregate_top_themen(
return {
"buckets": buckets,
"n_total_news": len(news_rows),
"n_in_window": n_in_window,
"n_shown": len(buckets),
"filter": {
"days_window": days_window,
"top_k": top_k,
"min_similarity": min_similarity,
"matches_per_news": matches_per_news,
"only_relevant": only_relevant,
"single_date": single_date,
},
}