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.
407 lines
14 KiB
HTML
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 & 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, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
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 %}
|