diff --git a/app/main.py b/app/main.py index 62bb5bd..43dcbf5 100644 --- a/app/main.py +++ b/app/main.py @@ -2228,6 +2228,46 @@ async def api_draft_versions(drucksache: str, news_url: str): } +@app.get("/api/auswertungen/score-histogram") +async def auswertungen_score_histogram( + bundesland: Optional[str] = None, + wahlperiode: Optional[str] = None, +): + """GWÖ-Score-Verteilung (0–10) ueber alle Bewertungen. + + Liefert ein Bucket-Array fuer einen Histogramm-Chart. Filterbar + ueber Bundesland + Wahlperiode (gleicher Pattern wie /matrix). + """ + import sqlite3 + from .auswertungen import _load_assessments + rows = _load_assessments() + from .wahlperioden import wahlperiode_for + + buckets = [0] * 11 + total = 0 + for r in rows: + if bundesland and r["bundesland"] != bundesland: + continue + if wahlperiode is not None: + wp = wahlperiode_for(r["datum"], r["bundesland"]) + if wp != wahlperiode: + continue + score = r["gwoe_score"] + if score is None: + continue + bucket = min(10, max(0, int(score))) + buckets[bucket] += 1 + total += 1 + return { + "buckets": [ + {"score_min": i, "score_max": i + 1, "count": c} + for i, c in enumerate(buckets) + ], + "total": total, + "filter": {"bundesland": bundesland, "wahlperiode": wahlperiode}, + } + + @app.get("/api/auswertungen/matrix") async def auswertungen_matrix( wahlperiode: Optional[str] = None, @@ -2902,6 +2942,126 @@ async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)): }) +@app.get("/v2/admin/stand", response_class=HTMLResponse) +async def v2_admin_stand(request: Request, user: dict = Depends(require_admin)): + """System-Stand-Dashboard — Ueberblick ueber alle Datenmengen.""" + return templates.TemplateResponse("v2/screens/admin_stand.html", { + "request": request, + "v2_active_nav": "admin_stand", + **_v2_template_context(user), + }) + + +@app.get("/api/admin/stand") +async def api_admin_stand(user: dict = Depends(require_admin)): + """Datenstand-Aggregation für das Stand-Dashboard. + + Liefert Gesamt + Per-Quelle + Letzte-7-Tage in einem Roundtrip. + """ + import sqlite3 + from datetime import datetime, timedelta, timezone + + db = sqlite3.connect(str(settings.db_path)) + cutoff_7d = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() + try: + # Assessments + n_ass_total = db.execute( + "SELECT COUNT(*) FROM assessments WHERE gwoe_score IS NOT NULL" + ).fetchone()[0] + n_ass_7d = db.execute( + "SELECT COUNT(*) FROM assessments " + "WHERE gwoe_score IS NOT NULL AND created_at >= ?", + (cutoff_7d,), + ).fetchone()[0] + ass_per_bl = dict(db.execute( + "SELECT bundesland, COUNT(*) FROM assessments " + "WHERE gwoe_score IS NOT NULL GROUP BY bundesland ORDER BY 2 DESC" + ).fetchall()) + + # Score-Verteilung + score_dist = dict(db.execute( + "SELECT CAST(gwoe_score AS INTEGER), COUNT(*) FROM assessments " + "WHERE gwoe_score IS NOT NULL GROUP BY CAST(gwoe_score AS INTEGER)" + ).fetchall()) + + # Plenum-Votes + n_votes = db.execute("SELECT COUNT(*) FROM plenum_vote_results").fetchone()[0] + votes_per_bl = dict(db.execute( + "SELECT bundesland, COUNT(*) FROM plenum_vote_results " + "GROUP BY bundesland ORDER BY 2 DESC" + ).fetchall()) + + # Match (assessment ∩ vote) + n_match = db.execute(""" + SELECT COUNT(DISTINCT a.drucksache) FROM assessments a + INNER JOIN plenum_vote_results p + ON a.bundesland=p.bundesland AND a.drucksache=p.drucksache + WHERE a.gwoe_score IS NOT NULL + """).fetchone()[0] + n_orphans = db.execute(""" + SELECT COUNT(DISTINCT p.bundesland || '/' || p.drucksache) + FROM plenum_vote_results p + LEFT JOIN assessments a + ON a.bundesland=p.bundesland AND a.drucksache=p.drucksache + WHERE a.drucksache IS NULL + """).fetchone()[0] + + # News + n_news = db.execute("SELECT COUNT(*) FROM news_articles").fetchone()[0] + n_news_emb = db.execute( + "SELECT COUNT(*) FROM news_articles WHERE summary_embedding IS NOT NULL" + ).fetchone()[0] + n_news_7d = db.execute( + "SELECT COUNT(*) FROM news_articles WHERE datum >= ?", (cutoff_7d,), + ).fetchone()[0] + news_per_source = dict(db.execute( + "SELECT source, COUNT(*) FROM news_articles GROUP BY source" + ).fetchall()) + + # PM-Drafts + n_drafts = db.execute("SELECT COUNT(*) FROM presse_drafts").fetchone()[0] + n_drafts_7d = db.execute( + "SELECT COUNT(*) FROM presse_drafts WHERE created_at >= ?", + (cutoff_7d,), + ).fetchone()[0] + + # Bookmarks + try: + n_bookmarks = db.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0] + except sqlite3.OperationalError: + n_bookmarks = 0 + finally: + db.close() + + return { + "assessments": { + "total": n_ass_total, + "last_7_days": n_ass_7d, + "by_bundesland": ass_per_bl, + "score_distribution": score_dist, + }, + "plenum_votes": { + "total": n_votes, + "by_bundesland": votes_per_bl, + }, + "match": { + "with_assessment_and_vote": n_match, + "vote_orphans": n_orphans, + }, + "news": { + "total": n_news, + "embedded": n_news_emb, + "last_7_days": n_news_7d, + "by_source": news_per_source, + }, + "presse_drafts": { + "total": n_drafts, + "last_7_days": n_drafts_7d, + }, + "bookmarks": n_bookmarks, + } + + @app.get("/v2/admin/abos", response_class=HTMLResponse) async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)): """Abo-Verwaltung — alle E-Mail-Abonnements (Admin).""" diff --git a/app/news_aggregator.py b/app/news_aggregator.py index ee1c324..35b7d84 100644 --- a/app/news_aggregator.py +++ b/app/news_aggregator.py @@ -344,4 +344,12 @@ def run_aggregator(db_path: Optional[Path] = None, embed: bool = True) -> dict: geworfen. """ articles = fetch_all() - return upsert_articles(articles, db_path=db_path, embed=embed) + result = upsert_articles(articles, db_path=db_path, embed=embed) + # Cache invalidieren, damit das Dashboard die neuen News sofort zeigt. + if result.get("inserted", 0) > 0 or result.get("embedded", 0) > 0: + try: + from . import themen_matching + themen_matching.cache_clear() + except Exception: + logger.exception("themen_matching cache_clear failed") + return result diff --git a/app/templates/v2/base.html b/app/templates/v2/base.html index a191473..f41bba4 100644 --- a/app/templates/v2/base.html +++ b/app/templates/v2/base.html @@ -66,6 +66,7 @@ {% if is_admin %}
— Administration
+ {{ icon("info", 14) }} Stand {{ icon("user-check", 14) }} Freischaltungen {{ icon("list-checks", 14) }} Queue {{ icon("envelope-simple", 14) }} Abos diff --git a/app/templates/v2/screens/admin_stand.html b/app/templates/v2/screens/admin_stand.html new file mode 100644 index 0000000..8c22f94 --- /dev/null +++ b/app/templates/v2/screens/admin_stand.html @@ -0,0 +1,242 @@ +{% extends "v2/base.html" %} + +{% block title %}System-Stand — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "admin_stand" %} + +{% block head_extra %} + + +{% endblock %} + +{% block main %} +
+

System-Stand

+

+ Datenüberblick · automatische Aktualisierung alle 30 s +

+
+ +
Lade Stand …
+ + + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/aktuelle-themen.html b/app/templates/v2/screens/aktuelle-themen.html index 0a78cf8..63afa61 100644 --- a/app/templates/v2/screens/aktuelle-themen.html +++ b/app/templates/v2/screens/aktuelle-themen.html @@ -665,10 +665,50 @@ async function showDraftFromData(d) { DS ${d.drucksache} (${d.bundesland}) · Bezug zu: ${d.news_titel}
` + actionRow + - `
${d.body.replace(/`; + `
${renderPmBody(d.body)}
`; backdrop.style.display = 'flex'; } +// Mini-Markdown-Renderer fuer PM-Body — interpretiert **bold**, *italic*, +// __bold__, _italic_ + Listen + Paragraphen-Breaks. KEIN externer Markdown- +// Parser noetig (kein dependency, schnell, isoliert testbar). +function renderPmBody(body) { + if (!body) return ''; + // 1. HTML escapen + let s = body.replace(/&/g, '&').replace(//g, '>'); + // 2. **bold** + __bold__ + s = s.replace(/\*\*([^*\n]+?)\*\*/g, '$1'); + s = s.replace(/__([^_\n]+?)__/g, '$1'); + // 3. *italic* + _italic_ (vorsichtig — nur wenn nicht zw. Ziffern) + s = s.replace(/(?$1'); + s = s.replace(/(?$1'); + // 4. Listen: "- " oder "* " am Zeilenanfang → Bullet-Punkte + // (vorsichtig: Bindestrich mitten im Wort soll bleiben) + const lines = s.split('\n'); + const out = []; + let inList = false; + for (const line of lines) { + if (/^\s*[-*]\s+/.test(line)) { + if (!inList) { out.push(''); inList = false; } + out.push(line); + } + } + if (inList) out.push(''); + s = out.join('\n'); + // 5. Doppel-Newlines →

+ const paras = s.split(/\n\s*\n/); + return paras.map(p => { + const trimmed = p.trim(); + if (!trimmed) return ''; + // Wenn der Block schon mit Tag startet (z.B.

@@ -366,7 +367,19 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
- + +
+

+ Verteilung der GWÖ-Scores (0–10) ueber alle Bewertungen — zeigt + auf einen Blick, wo der Antrags-Pool inhaltlich liegt. +

+
+ +
+
+
+ +

Cluster-Ansicht

@@ -397,7 +410,8 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; } {% block body_scripts %}