gwoe-antragspruefer/app/templates/v2/screens/quellen.html
Dotty Dotter 501f32b9ae fix(quellen): Suchformular bricht fetch durch Form-Submit ab
Bug: ``runSearch`` ist async und returnt damit Promise<false>. Im
Inline-Handler ``onsubmit="return runSearch(event)"`` interpretiert der
Browser den Promise als truthy → Default-Form-Submit läuft an, die
Page navigiert, und der gerade abgesetzte fetch bricht mit
"Failed to fetch" ab. Im echten Browser merkt man's beim Klick auf
"Suchen" oder Enter im Suchfeld: Status bleibt auf "Suche läuft …"
hängen, keine Ergebnisse erscheinen.

Fix: Bindung über ``addEventListener('submit', ...)`` mit
``event.preventDefault()`` synchron vor dem async runSearch-Aufruf.
JS-Direktaufruf von ``runSearch()`` (z.B. bei Filter-Wechsel)
funktioniert weiterhin.

Gefunden bei E2E-Browser-Smoketest mit Playwright.
2026-05-09 00:58:30 +02:00

407 lines
14 KiB
HTML

{% extends "v2/base.html" %}
{% block title %}Quellen — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "" %}
{% block head_extra %}
<style>
.quellen-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
margin-bottom: 1.5rem;
}
.prog-card {
display: flex;
gap: 12px;
padding: 14px;
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 6px;
}
.prog-card img {
width: 64px;
height: auto;
border: 1px solid var(--ecg-border);
border-radius: 3px;
flex-shrink: 0;
object-fit: cover;
}
.prog-meta { font-size: 11px; opacity: 0.6; margin-top: 2px; margin-bottom: 6px; }
.prog-badge {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
margin-right: 4px;
font-family: var(--font-mono);
}
.badge-spd { background: #e3000f; color: #fff; }
.badge-cdu { background: #2c2c2c; color: #fff; }
.badge-gruene,.badge-grüne { background: #46962b; color: #fff; }
.badge-fdp { background: #ffed00; color: #222; }
.badge-afd { background: #009ee0; color: #fff; }
.badge-linke { background: #be3075; color: #fff; }
.badge-type-wp { background: var(--ecg-teal); color: #fff; }
.badge-type-pp { background: var(--ecg-green); color: #fff; }
.indexed-ok { color: var(--ecg-green); font-size: 11px; font-family: var(--font-mono); }
.indexed-no { color: var(--ecg-dark); opacity: 0.4; font-size: 11px; font-family: var(--font-mono); }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
margin: 10px 0;
}
.stat-cell {
text-align: center;
padding: 12px;
background: var(--ecg-bg-subtle);
border-radius: 4px;
}
.stat-val { font-family: var(--font-display); font-size: 28px; color: var(--ecg-teal); font-weight: 900; }
.stat-lbl { font-size: 11px; opacity: 0.6; margin-top: 2px; }
.section-h2 {
font-family: var(--font-display);
font-size: 16px;
color: var(--ecg-teal);
margin: 1.5rem 0 0.75rem;
padding-bottom: 4px;
border-bottom: 2px solid var(--ecg-teal);
}
.search-box {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin: 8px 0 4px;
}
.search-box input[type="text"] {
flex: 1 1 240px;
min-width: 200px;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 13px;
border: 1px solid var(--ecg-border);
border-radius: 4px;
background: var(--ecg-card-bg);
color: var(--ecg-dark);
}
.search-box button {
padding: 8px 16px;
font-family: var(--font-mono);
font-size: 12px;
background: var(--ecg-teal);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.search-box button:disabled { opacity: 0.5; cursor: wait; }
.search-filter {
display: inline-flex;
gap: 14px;
font-size: 12px;
font-family: var(--font-mono);
}
.search-filter label { cursor: pointer; }
.search-results { margin-top: 12px; }
.search-hit {
padding: 10px 12px;
margin-bottom: 8px;
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 4px;
border-left: 3px solid var(--ecg-teal);
}
.search-hit.historic { border-left-color: var(--ecg-dark); opacity: 0.85; }
.search-hit-meta {
font-family: var(--font-mono);
font-size: 11px;
opacity: 0.7;
margin-top: 2px;
margin-bottom: 6px;
}
.search-hit-text {
font-size: 13px;
line-height: 1.5;
color: var(--ecg-dark);
}
.search-hit-actions {
margin-top: 6px;
font-size: 11px;
font-family: var(--font-mono);
}
.search-hit-actions a { color: var(--ecg-teal); margin-right: 12px; }
.search-status { font-size: 12px; opacity: 0.7; margin-top: 8px; font-family: var(--font-mono); }
.gueltig-pill {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-family: var(--font-mono);
border-radius: 3px;
margin-left: 4px;
}
.gueltig-pill.aktuell { background: var(--ecg-green); color: #fff; }
.gueltig-pill.historisch { background: var(--ecg-dark); color: #fff; opacity: 0.7; }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 2rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Quellen &amp; Referenzdokumente</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Wahl- und Grundsatzprogramme · semantisch indexiert
</p>
</div>
<div class="v2-kasten outline-blue" style="margin-bottom:1.5rem;">
<p>
Der GWÖ-Antragsprüfer vergleicht parlamentarische Anträge mit den Wahl- und Grundsatzprogrammen
der Parteien. Hier finden Sie alle verwendeten Originaldokumente zum Download.
</p>
<p style="font-size:11px;opacity:0.7;margin-top:6px;">
Die Programme werden semantisch indexiert, um relevante Passagen für jeden Antrag zu finden.
</p>
</div>
<!-- Indexierungsstatus -->
<div class="v2-kasten outline-green" style="margin-bottom:1.5rem;">
<h4>Indexierungsstatus</h4>
<div class="stat-grid">
<div class="stat-cell">
<div class="stat-val">{{ status.indexed }}</div>
<div class="stat-lbl">Indexiert</div>
</div>
<div class="stat-cell">
<div class="stat-val">{{ status.total }}</div>
<div class="stat-lbl">Gesamt</div>
</div>
</div>
<div style="margin-top:8px;">
<button onclick="indexAll()"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;background:var(--ecg-teal);color:#fff;border:none;border-radius:3px;cursor:pointer;">
Alle Programme indexieren (Admin)
</button>
<span id="index-status" style="margin-left:10px;font-size:11px;opacity:0.7;"></span>
</div>
</div>
<!-- Volltextsuche (semantisch) -->
<div class="v2-kasten outline-blue" style="margin-bottom:1.5rem;">
<h4>Volltextsuche durch alle Programme</h4>
<p style="font-size:12px;opacity:0.75;margin:4px 0 8px;">
Semantische Suche über alle indizierten Wahl- und Grundsatzprogramme.
Wortunscharf — Endungen sind egal, verwandte Begriffe werden ebenfalls
gefunden.
</p>
<form id="quellen-search-form" class="search-box">
<input type="text" id="quellen-q" name="q" placeholder="z.B. Klimaschutz, soziale Gerechtigkeit, Mietpreisbremse"
autocomplete="off" minlength="2" maxlength="200">
<button type="submit" id="quellen-q-btn">Suchen</button>
</form>
<div class="search-filter" style="margin-top:6px;">
<label><input type="radio" name="qfilter" value="current" checked> nur aktuelle Programme</label>
<label><input type="radio" name="qfilter" value="all"> auch historische</label>
</div>
<div id="quellen-search-status" class="search-status"></div>
<div id="quellen-search-results" class="search-results"></div>
</div>
<!-- Wahlprogramme nach BL -->
{% for bl_name, bl_progs in wahlprogramme_grouped %}
<h2 class="section-h2">{{ bl_name }}</h2>
<div class="quellen-grid">
{% for prog in bl_progs %}
<div class="prog-card">
<a href="{{ prog.pdf_url }}" target="_blank" style="flex-shrink:0;">
<img src="/api/programme/thumbnail/{{ prog.id }}" alt="{{ prog.name }}"
loading="lazy" onerror="this.style.display='none'">
</a>
<div style="min-width:0;">
<div style="font-family:var(--font-display);font-size:13px;margin-bottom:3px;">
<span class="prog-badge badge-{{ prog.partei|lower|replace('ü','ue')|replace('ä','ae')|replace('ö','oe') }}">{{ prog.partei }}</span>
{{ prog.name }}
</div>
<div class="prog-meta">
<span class="prog-badge badge-type-wp">Wahlprogramm</span>
{% if prog.bundesland %}{{ prog.bundesland }}{% endif %}
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<a href="{{ prog.pdf_url }}" target="_blank"
style="font-size:11px;font-family:var(--font-mono);color:var(--ecg-teal);">
PDF herunterladen
</a>
{% for s in status.programmes if s.id == prog.id %}
{% if s.indexed %}
<span class="indexed-ok">✓ {{ s.chunks }} Chunks</span>
{% else %}
<span class="indexed-no">○ Nicht indexiert</span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<!-- Grundsatzprogramme -->
<h2 class="section-h2">Grundsatzprogramme (Bundesebene)</h2>
<div class="quellen-grid">
{% for prog in grundsatzprogramme %}
<div class="prog-card">
<a href="{{ prog.pdf_url }}" target="_blank" style="flex-shrink:0;">
<img src="/api/programme/thumbnail/{{ prog.id }}" alt="{{ prog.name }}"
loading="lazy" onerror="this.style.display='none'">
</a>
<div style="min-width:0;">
<div style="font-family:var(--font-display);font-size:13px;margin-bottom:3px;">
<span class="prog-badge badge-{{ prog.partei|lower|replace('ü','ue')|replace('ä','ae')|replace('ö','oe') }}">{{ prog.partei }}</span>
{{ prog.name }}
</div>
<div class="prog-meta">
<span class="prog-badge badge-type-pp">Grundsatzprogramm</span>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<a href="{{ prog.pdf_url }}" target="_blank"
style="font-size:11px;font-family:var(--font-mono);color:var(--ecg-teal);">
PDF herunterladen
</a>
{% for s in status.programmes if s.id == prog.id %}
{% if s.indexed %}
<span class="indexed-ok">✓ {{ s.chunks }} Chunks</span>
{% else %}
<span class="indexed-no">○ Nicht indexiert</span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="v2-kasten outline-green" style="margin-top:1rem;">
<h4>Hinweise zur Methodik</h4>
<ul style="margin:6px 0 0 1.2rem;font-size:12px;">
<li>Die Programme werden in semantische Chunks aufgeteilt (~400 Wörter)</li>
<li>Jeder Chunk wird mit einem Embedding-Modell vektorisiert</li>
<li>Bei der Analyse wird der Antrag ebenfalls vektorisiert</li>
<li>Die ähnlichsten Passagen werden als Kontext an das LLM übergeben</li>
<li>Das LLM zitiert nur, wenn eine Passage wirklich zur Argumentation passt</li>
</ul>
</div>
{% endblock %}
{% block body_scripts %}
<script>
async function indexAll() {
const statusEl = document.getElementById('index-status');
statusEl.textContent = 'Indexierung gestartet …';
try {
const fd = new FormData();
fd.append('all_programmes', 'true');
const resp = await fetch('/api/programme/index', { method: 'POST', body: fd });
const data = await resp.json();
statusEl.textContent = 'Indexierung läuft für ' + data.programmes.length + ' Programme';
setTimeout(() => location.reload(), 30000);
} catch (e) {
statusEl.textContent = 'Fehler: ' + e.message;
}
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function runSearch(ev) {
if (ev) ev.preventDefault();
const q = document.getElementById('quellen-q').value.trim();
const filter = (document.querySelector('input[name="qfilter"]:checked') || {}).value || 'current';
const statusEl = document.getElementById('quellen-search-status');
const resultsEl = document.getElementById('quellen-search-results');
const btn = document.getElementById('quellen-q-btn');
if (q.length < 2) {
statusEl.textContent = 'Bitte mindestens 2 Zeichen eingeben.';
return false;
}
btn.disabled = true;
statusEl.textContent = 'Suche läuft …';
resultsEl.innerHTML = '';
try {
const params = new URLSearchParams({ q: q, filter: filter, top_k: '20' });
const resp = await fetch('/api/quellen/search?' + params.toString());
if (!resp.ok) {
statusEl.textContent = 'Fehler: ' + resp.status + ' ' + resp.statusText;
return false;
}
const data = await resp.json();
if (!data.results || data.results.length === 0) {
statusEl.textContent = 'Keine Treffer.';
return false;
}
statusEl.textContent = data.n_results + ' Treffer'
+ (filter === 'current' ? ' in aktuellen Programmen' : ' (alle Programme)') + '.';
const html = data.results.map(r => {
const aktuell = r.gueltig_bis === null;
const cls = aktuell ? '' : ' historic';
const pill = aktuell
? '<span class="gueltig-pill aktuell">aktuell</span>'
: '<span class="gueltig-pill historisch">' + escHtml(r.gueltig_ab) + ' bis ' + escHtml(r.gueltig_bis) + '</span>';
const sim = (r.similarity * 100).toFixed(0) + '%';
const partei = escHtml(r.partei || '');
const bl = escHtml(r.bundesland || '');
const wp = r.wp ? ' · WP' + escHtml(r.wp) : '';
const seite = escHtml(r.seite);
return '<div class="search-hit' + cls + '">'
+ '<div style="font-family:var(--font-display);font-size:13px;">'
+ '<span class="prog-badge badge-' + partei.toLowerCase().replace(/ü/g,'ue').replace(/ä/g,'ae').replace(/ö/g,'oe') + '">' + partei + '</span> '
+ escHtml(r.name) + ' ' + pill
+ '</div>'
+ '<div class="search-hit-meta">'
+ 'Seite ' + seite + ' · ' + bl + wp + ' · Relevanz ' + sim
+ '</div>'
+ '<div class="search-hit-text">' + escHtml(r.text) + '</div>'
+ '<div class="search-hit-actions">'
+ '<a href="' + escHtml(r.pdf_url) + '" target="_blank">→ Stelle im PDF anzeigen</a>'
+ '</div>'
+ '</div>';
}).join('');
resultsEl.innerHTML = html;
} catch (e) {
statusEl.textContent = 'Fehler: ' + e.message;
} finally {
btn.disabled = false;
}
return false;
}
// Form-Submit + Filter-Wechsel binden
document.addEventListener('DOMContentLoaded', () => {
// Wichtig: `runSearch` ist async, returnt Promise<false>. Das ist truthy,
// also würde `onsubmit="return runSearch(event)"` das Default-Submit
// NICHT verhindern und der fetch würde mit "Failed to fetch" abbrechen,
// weil die Page navigiert. Stattdessen via addEventListener binden — da
// ist `event.preventDefault()` synchron geblieben, bevor der Promise
// resolvt.
const form = document.getElementById('quellen-search-form');
if (form) {
form.addEventListener('submit', (ev) => {
ev.preventDefault();
runSearch();
});
}
document.querySelectorAll('input[name="qfilter"]').forEach(r => {
r.addEventListener('change', () => {
const q = (document.getElementById('quellen-q') || {}).value || '';
if (q.trim().length >= 2) runSearch();
});
});
});
</script>
{% endblock %}