diff --git a/app/database.py b/app/database.py index 5d7dd00..b01fbe5 100644 --- a/app/database.py +++ b/app/database.py @@ -334,6 +334,26 @@ async def init_db(): "ON presse_drafts(created_at DESC)" ) + # auto_rate_runs (#173 Phase 3) — Tracking der Vote-Orphans-Auto-Bewertung + await db.execute(""" + CREATE TABLE IF NOT EXISTS auto_rate_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + source TEXT NOT NULL, -- 'cron'|'manual'|'api' + bundesland TEXT, -- NULL = ALL + limit_requested INTEGER NOT NULL, + n_attempted INTEGER NOT NULL DEFAULT 0, + n_succeeded INTEGER NOT NULL DEFAULT 0, + n_failed INTEGER NOT NULL DEFAULT 0, + n_skipped INTEGER NOT NULL DEFAULT 0, + error_summary TEXT + ) + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_auto_rate_runs_started " + "ON auto_rate_runs(started_at DESC)" + ) + await db.commit() @@ -667,6 +687,70 @@ async def get_votes(drucksache: str, user_id: str = None) -> dict: return {"counts": counts, "my_votes": my_votes} +# ─── auto_rate_runs (#173) ────────────────────────────────────────────────── + +async def record_auto_rate_run( + source: str, + limit_requested: int, + bundesland: Optional[str] = None, + n_attempted: int = 0, + n_succeeded: int = 0, + n_failed: int = 0, + n_skipped: int = 0, + error_summary: Optional[str] = None, +) -> int: + """Schreibt einen Run-Eintrag in auto_rate_runs und liefert die id.""" + async with aiosqlite.connect(settings.db_path) as db: + cur = await db.execute( + """ + INSERT INTO auto_rate_runs + (source, bundesland, limit_requested, n_attempted, + n_succeeded, n_failed, n_skipped, error_summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (source, bundesland, limit_requested, n_attempted, + n_succeeded, n_failed, n_skipped, error_summary), + ) + await db.commit() + return cur.lastrowid + + +async def list_auto_rate_runs(limit: int = 20) -> list[dict]: + """Letzte N Runs (neueste zuerst).""" + async with aiosqlite.connect(settings.db_path) as db: + db.row_factory = aiosqlite.Row + rows = await db.execute( + """ + SELECT id, started_at, source, bundesland, limit_requested, + n_attempted, n_succeeded, n_failed, n_skipped, error_summary + FROM auto_rate_runs + ORDER BY started_at DESC LIMIT ? + """, + (limit,), + ) + return [dict(r) for r in await rows.fetchall()] + + +async def auto_rate_today_total() -> dict: + """Aggregat fuer den aktuellen Tag (UTC) — fuer Cron-Throttling.""" + async with aiosqlite.connect(settings.db_path) as db: + cur = await db.execute( + """ + SELECT COUNT(*) AS n_runs, + COALESCE(SUM(n_attempted), 0) AS total_attempted, + COALESCE(SUM(n_succeeded), 0) AS total_succeeded + FROM auto_rate_runs + WHERE date(started_at) = date('now') + """ + ) + row = await cur.fetchone() + return { + "n_runs": row[0], + "total_attempted": row[1], + "total_succeeded": row[2], + } + + async def create_job( job_id: str, input_preview: str, diff --git a/app/main.py b/app/main.py index 2a51cca..f96f8e0 100644 --- a/app/main.py +++ b/app/main.py @@ -2735,6 +2735,8 @@ async def api_auto_rate_vote_orphans( request: Request, bundesland: Optional[str] = Form(None), limit: int = Form(10), + source: str = Form("manual"), + daily_cap: int = Form(200), user: dict = Depends(require_admin), ): """Bulk-Auto-Bewerten der Top-N Vote-Orphans (#172). @@ -2742,13 +2744,38 @@ async def api_auto_rate_vote_orphans( Admin-only + rate-limited. Nimmt die neuesten Drucksachen aus `vote-orphans`, laedt den Antragstext per Adapter herunter und enqueued einen Job pro Drucksache. Konservatives Default-Limit 10. + + `source` = 'manual'|'cron'|'api' wird in auto_rate_runs persistiert. + `daily_cap` = max. Tagessumme an Auto-Bewertungen (Default 200), wird + gegen die Run-Historie geprueft. """ if limit < 1 or limit > 50: raise HTTPException(status_code=400, detail="limit muss 1-50 sein") from .auswertungen import get_vote_orphans + from .database import ( + record_auto_rate_run, + auto_rate_today_total, + ) from .queue import enqueue, QueueFullError + today = await auto_rate_today_total() + if today["total_attempted"] + limit > daily_cap: + remaining = max(0, daily_cap - today["total_attempted"]) + if remaining == 0: + await record_auto_rate_run( + source=source, limit_requested=limit, bundesland=bundesland, + n_attempted=0, n_succeeded=0, n_failed=0, n_skipped=0, + error_summary=f"daily_cap_reached:{daily_cap}", + ) + return { + "status": "skipped", + "reason": "daily_cap_reached", + "today": today, + "daily_cap": daily_cap, + } + limit = remaining + orphans = get_vote_orphans(filter_bl=bundesland, limit=limit) enqueued = [] @@ -2796,14 +2823,44 @@ async def api_auto_rate_vote_orphans( except QueueFullError: skipped.append({"drucksache": ds, "reason": "queue_full"}) break + + # Run in auto_rate_runs persistieren — auch wenn enqueued=0 ist. + error_summary = None + if skipped: + error_summary = ", ".join( + f"{s['drucksache']}:{s['reason'][:30]}" for s in skipped[:3] + ) + if len(skipped) > 3: + error_summary += f", … (+{len(skipped) - 3} weitere)" + run_id = await record_auto_rate_run( + source=source, limit_requested=limit, bundesland=bundesland, + n_attempted=len(orphans["items"]), + n_succeeded=len(enqueued), + n_failed=0, # Job-Failures kommen nach Worker-Run, nicht hier + n_skipped=len(skipped), + error_summary=error_summary, + ) return { "status": "auto_rate_enqueued", + "run_id": run_id, "enqueued": len(enqueued), "skipped": skipped, "jobs": enqueued, } +@app.get("/api/auto-rate-runs") +async def api_auto_rate_runs( + limit: int = 20, + user: dict = Depends(require_admin), +): + """Letzte N Runs der Vote-Orphans-Auto-Bewertung (admin-only).""" + from .database import list_auto_rate_runs, auto_rate_today_total + runs = await list_auto_rate_runs(limit=limit) + today = await auto_rate_today_total() + return {"runs": runs, "today": today} + + @app.get("/api/auswertungen/empfehlungs-konsistenz") async def auswertungen_empfehlungs_konsistenz( bundesland: Optional[str] = None, @@ -3084,6 +3141,25 @@ async def api_admin_stand(user: dict = Depends(require_admin)): n_bookmarks = db.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0] except sqlite3.OperationalError: n_bookmarks = 0 + + # Auto-Rate-Runs (#173) + try: + auto_rate_today = db.execute(""" + SELECT + COUNT(*) AS n_runs, + COALESCE(SUM(n_attempted), 0) AS total_attempted, + COALESCE(SUM(n_succeeded), 0) AS total_succeeded + FROM auto_rate_runs + WHERE date(started_at) = date('now') + """).fetchone() + auto_rate_recent = list(db.execute(""" + SELECT id, started_at, source, bundesland, limit_requested, + n_attempted, n_succeeded, n_failed, n_skipped, error_summary + FROM auto_rate_runs ORDER BY started_at DESC LIMIT 5 + """).fetchall()) + except sqlite3.OperationalError: + auto_rate_today = (0, 0, 0) + auto_rate_recent = [] finally: db.close() @@ -3113,6 +3189,21 @@ async def api_admin_stand(user: dict = Depends(require_admin)): "last_7_days": n_drafts_7d, }, "bookmarks": n_bookmarks, + "auto_rate": { + "today_runs": auto_rate_today[0], + "today_attempted": auto_rate_today[1], + "today_succeeded": auto_rate_today[2], + "recent": [ + { + "id": r[0], "started_at": r[1], "source": r[2], + "bundesland": r[3], "limit_requested": r[4], + "n_attempted": r[5], "n_succeeded": r[6], + "n_failed": r[7], "n_skipped": r[8], + "error_summary": r[9], + } + for r in auto_rate_recent + ], + }, } diff --git a/app/templates/v2/screens/admin_stand.html b/app/templates/v2/screens/admin_stand.html index 8c22f94..d9d5346 100644 --- a/app/templates/v2/screens/admin_stand.html +++ b/app/templates/v2/screens/admin_stand.html @@ -145,6 +145,27 @@ + +
+ Heute: —. + Cron läuft alle 6h und enqueued bis zu 30 Orphans pro Lauf, max. 200 Anträge/Tag. +
+| Zeitpunkt | Quelle | BL | +Versucht | +Enqueued | +Skipped | +Notiz | +
|---|