feat(fuenfvoracht): Anti-Halluzination Auto-5vor8 API und Bot-Benachrichtigungen

- Draft-Status, review_reason, notify-failed Endpoint
- Telegram bei Entwurf, erfolgreichem Auto-Approve und Generierungsfehler
- Nachmittags-Briefing 15:00 statt 10:00, bessere Entwurf-vs-leer Logik
This commit is contained in:
Homelab Cursor 2026-07-31 22:03:06 +02:00
parent 04f69dfd81
commit 668055d260
2 changed files with 391 additions and 80 deletions

View file

@ -2,6 +2,7 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for, R
from functools import wraps from functools import wraps
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import os import os
import html
import asyncio import asyncio
import logging import logging
import requests as req_lib import requests as req_lib
@ -76,8 +77,13 @@ def planning_days(count=7):
return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)] return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)]
def sanitize_telegram_html(text: str) -> str:
"""Escape <>& so Telegram HTML parse_mode does not fail on stray tags from KI/Quellen."""
return html.escape(text or "", quote=False)
def with_branding(content: str) -> str: def with_branding(content: str) -> str:
text = (content or "").rstrip() text = sanitize_telegram_html((content or "").rstrip())
if BRAND_MARKER in text: if BRAND_MARKER in text:
return text return text
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
@ -105,6 +111,50 @@ def notify_all_reviewers(text, reply_markup=None):
return results return results
def _notify_auto5vor8_review(date_str, post_time, title="", review_reason="", has_draft=True):
"""Telegram an Reviewer: Auto-5vor8 braucht manuelle Prüfung."""
title_line = sanitize_telegram_html(title[:120]) if title else "Dr.-Bine-Artikel"
reason_line = sanitize_telegram_html(review_reason) if review_reason else ""
if has_draft:
msg = (
f"⚠️ <b>Auto-5vor8: Bitte manuell prüfen</b>\n\n"
f"📅 Slot: <b>{date_str} {post_time}</b>\n"
f"📰 {title_line}\n"
)
if reason_line:
msg += f"\n❗ <b>Faktencheck:</b> {reason_line}\n"
msg += (
f"\nDer KI-Text hat die automatische Qualitätsprüfung nicht bestanden. "
f"Ein <b>Entwurf</b> liegt im Dashboard — bitte lesen, ggf. anpassen und freigeben.\n\n"
f"👉 https://fuenfvoracht.orbitalo.net"
)
else:
msg = (
f"🚨 <b>Auto-5vor8: Generierung fehlgeschlagen</b>\n\n"
f"📰 {title_line}\n"
)
if reason_line:
msg += f"\n{reason_line}\n"
msg += (
f"\nEs wurde <b>kein</b> Telegram-Text erzeugt. Bitte im Dashboard manuell anlegen.\n\n"
f"👉 https://fuenfvoracht.orbitalo.net"
)
notify_all_reviewers(msg)
def _notify_auto5vor8_scheduled(date_str, post_time, title=""):
"""Telegram: Auto-5vor8 hat erfolgreich eingeplant."""
title_line = sanitize_telegram_html(title[:120]) if title else "Dr.-Bine-Artikel"
msg = (
f"✅ <b>Auto-5vor8: Artikel eingeplant</b>\n\n"
f"📅 <b>{date_str} · {post_time} Uhr</b>\n"
f"📰 {title_line}\n\n"
f"Wird automatisch gepostet — im Dashboard noch editierbar.\n"
f"👉 https://fuenfvoracht.orbitalo.net"
)
notify_all_reviewers(msg)
# ── Main Dashboard ──────────────────────────────────────────────────────────── # ── Main Dashboard ────────────────────────────────────────────────────────────
@app.route('/') @app.route('/')
@ -168,7 +218,7 @@ def index():
@app.route('/history') @app.route('/history')
def history(): def history():
articles = db.get_recent_posted_articles(30) articles = db.get_recent_articles(30)
return render_template('history.html', articles=articles) return render_template('history.html', articles=articles)
@ -355,6 +405,18 @@ def api_save(date_str):
db.update_article_content(date_str, content, post_time=post_time) db.update_article_content(date_str, content, post_time=post_time)
flog.article_saved(date_str, post_time) flog.article_saved(date_str, post_time)
return jsonify({'success': True}) return jsonify({'success': True})
all_today = db.get_articles_by_date(date_str)
candidate = next((a for a in all_today if a['status'] in ('draft', 'scheduled', 'sent_to_bot', 'approved', 'pending_review')), None)
if candidate:
old_time = candidate['post_time']
conn = db.get_conn()
conn.execute("UPDATE articles SET post_time=? WHERE date=? AND post_time=?", (post_time, date_str, old_time))
conn.commit()
conn.close()
db.update_article_content(date_str, content, post_time=post_time)
flog.article_saved(date_str, post_time)
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag vorhanden'})
@app.route('/api/article/<date_str>/schedule', methods=['POST']) @app.route('/api/article/<date_str>/schedule', methods=['POST'])
@ -365,33 +427,38 @@ def api_schedule(date_str):
notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom
notify_at_custom = data.get('notify_at') notify_at_custom = data.get('notify_at')
# Slot-Konflikt prüfen
existing = db.get_article_by_date(date_str, post_time) existing = db.get_article_by_date(date_str, post_time)
if not existing: if not existing:
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Slot'}) all_today = db.get_articles_by_date(date_str)
candidate = next((a for a in all_today if a['status'] in ('draft', 'scheduled', 'sent_to_bot', 'pending_review')), None)
if not candidate:
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag gefunden'})
old_time = candidate['post_time']
conn = db.get_conn()
conn.execute(
"UPDATE articles SET post_time=? WHERE date=? AND post_time=?",
(post_time, date_str, old_time)
)
conn.commit()
conn.close()
# notify_at berechnen conn = db.get_conn()
import pytz ts = datetime.utcnow().isoformat()
from datetime import datetime as dt conn.execute(
berlin = pytz.timezone('Europe/Berlin') "UPDATE articles SET status='approved', scheduled_at=?, notify_at=? WHERE date=? AND post_time=?",
now_berlin = dt.now(berlin) (ts, ts, date_str, post_time)
article_dt = berlin.localize(dt.strptime(f"{date_str} {post_time}", '%Y-%m-%d %H:%M')) )
conn.commit()
conn.close()
if notify_mode == 'sofort': notify_all_reviewers(
notify_at = dt.utcnow().isoformat() f"📅 <b>Artikel eingeplant</b>\n\n"
elif notify_mode == 'custom' and notify_at_custom: f"📆 {date_str} um <b>{post_time} Uhr</b>\n"
notify_at = notify_at_custom f"Wird automatisch gepostet."
else: )
# Auto: wenn heute → sofort, sonst Vortag 17:00
if article_dt.date() == now_berlin.date():
notify_at = dt.utcnow().isoformat()
else:
day_before = (article_dt - timedelta(days=1)).replace(hour=17, minute=0, second=0)
notify_at = day_before.astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S')
db.schedule_article(date_str, post_time, notify_at) flog.article_scheduled(date_str, post_time, ts)
flog.article_scheduled(date_str, post_time, notify_at) return jsonify({'success': True})
return jsonify({'success': True, 'notify_at': notify_at})
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST']) @app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
@ -533,13 +600,35 @@ def api_skip(date_str):
return jsonify({'success': True}) return jsonify({'success': True})
# ── Auto-5vor8 ──────────────────────────────────────────────────────────────── # == Auto-5vor8 (API + Dashboard) ===============================================
STYLE_ROTATION = [
("Standard", "📌 Das Wesentliche"),
("Leicht sarkastisch", "🎭 Worum es wirklich geht"),
("Humorvoll-serioes", "☕ Kurz erklaert"),
("Knapp & direkt", "⚡ In Kuerze"),
("Storytelling", "📖 Die Story"),
("Wissenschaftlich nuechtern", "🔬 Befund"),
]
def _style_for_date(day):
from datetime import date as _date
if isinstance(day, str):
day = _date.fromisoformat(day)
return STYLE_ROTATION[day.toordinal() % len(STYLE_ROTATION)]
def _get_auto5vor8_settings(): def _get_auto5vor8_settings():
conn = db.get_conn() conn = db.get_conn()
rows = conn.execute("SELECT key, value FROM auto_5vor8_settings").fetchall() rows = conn.execute("SELECT key, value FROM auto_5vor8_settings").fetchall()
conn.close() conn.close()
return {r["key"]: r["value"] for r in rows} settings = {r["key"]: r["value"] for r in rows}
if "style_rotation" not in settings:
settings["style_rotation"] = "1"
if "ai_model" not in settings or not settings["ai_model"]:
settings["ai_model"] = "deepseek/deepseek-v4-flash"
return settings
def _set_auto5vor8_setting(key, value): def _set_auto5vor8_setting(key, value):
@ -553,30 +642,128 @@ def _set_auto5vor8_setting(key, value):
conn.close() conn.close()
@app.route('/auto5vor8') def _auto5vor8_style_info():
from datetime import date, timedelta
today = date.today()
tomorrow = today + timedelta(days=1)
t_name, t_intro = _style_for_date(today)
n_name, n_intro = _style_for_date(tomorrow)
return {
"styles": [{"name": n, "intro": i} for n, i in STYLE_ROTATION],
"today_style": t_name,
"today_intro": t_intro,
"tomorrow_style": n_name,
"tomorrow_intro": n_intro,
}
@app.route("/api/article/create", methods=["POST"])
def api_article_create():
"""Erstellt einen Artikel und plant ihn direkt ein (fuer Auto-5vor8)."""
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "Kein JSON-Body"}), 400
content = data.get("content", "").strip()
date_str = data.get("date", "").strip()
post_time = data.get("post_time", POST_TIME)
tag = data.get("tag", "allgemein")
source = data.get("source", "")
image_url = data.get("image_url", "")
if not content or not date_str:
return jsonify({"success": False, "error": "content und date erforderlich"}), 400
if db.slot_is_taken(date_str, post_time):
return jsonify({"success": False, "error": f"Slot {date_str} {post_time} belegt"}), 409
article_status = (data.get("status") or "approved").strip().lower()
if article_status not in ("approved", "draft"):
article_status = "approved"
db.create_article(date_str, source, content, None, tag, post_time)
from datetime import datetime as _dt
ts = _dt.utcnow().isoformat()
conn = db.get_conn()
if article_status == "approved":
conn.execute(
"UPDATE articles SET status='approved', scheduled_at=?, image_url=? WHERE date=? AND post_time=?",
(ts, image_url, date_str, post_time)
)
else:
conn.execute(
"UPDATE articles SET status='draft', image_url=? WHERE date=? AND post_time=?",
(image_url, date_str, post_time)
)
conn.commit()
conn.close()
if article_status == "draft":
_notify_auto5vor8_review(
date_str,
post_time,
title=(data.get("title") or "").strip(),
review_reason=(data.get("review_reason") or "").strip(),
has_draft=True,
)
elif (data.get("title") or "").strip():
_notify_auto5vor8_scheduled(
date_str,
post_time,
title=(data.get("title") or "").strip(),
)
return jsonify({"success": True, "date": date_str, "post_time": post_time, "status": article_status})
@app.route("/api/auto5vor8/notify-failed", methods=["POST"])
def api_auto5vor8_notify_failed():
"""Benachrichtigung wenn Auto-5vor8 gar keinen Text erzeugen konnte."""
data = request.get_json() or {}
_notify_auto5vor8_review(
date_str=(data.get("date") or today_str()).strip(),
post_time=(data.get("post_time") or POST_TIME).strip(),
title=(data.get("title") or "").strip(),
review_reason=(data.get("review_reason") or "KI-Text konnte nicht generiert werden").strip(),
has_draft=False,
)
return jsonify({"success": True})
@app.route("/api/auto5vor8/settings", methods=["GET"])
def api_auto5vor8_settings_get():
settings = _get_auto5vor8_settings()
settings.update(_auto5vor8_style_info())
return jsonify(settings)
@app.route("/api/auto5vor8/settings", methods=["POST"])
def api_auto5vor8_settings_post():
data = request.get_json() or {}
for key, val in data.items():
if val is not None:
_set_auto5vor8_setting(key, str(val).strip())
return jsonify({"success": True})
@app.route("/auto5vor8")
def auto5vor8(): def auto5vor8():
settings = _get_auto5vor8_settings() settings = _get_auto5vor8_settings()
return render_template('auto5vor8.html', settings=settings) style_info = _auto5vor8_style_info()
return render_template("auto5vor8.html", settings=settings, style_info=style_info)
@app.route('/auto5vor8/toggle', methods=['POST']) @app.route("/auto5vor8/toggle", methods=["POST"])
def auto5vor8_toggle(): def auto5vor8_toggle():
current = _get_auto5vor8_settings() current = _get_auto5vor8_settings()
new_val = '0' if current.get('enabled') == '1' else '1' new_val = "0" if current.get("enabled") == "1" else "1"
_set_auto5vor8_setting('enabled', new_val) _set_auto5vor8_setting("enabled", new_val)
state = 'aktiviert' if new_val == '1' else 'deaktiviert' state = "aktiviert" if new_val == "1" else "deaktiviert"
logger.info('Auto-5vor8 %s', state) logger.info("Auto-5vor8 %s", state)
return redirect(url_for('auto5vor8')) return redirect(url_for("auto5vor8"))
@app.route('/auto5vor8/settings', methods=['POST']) @app.route("/auto5vor8/settings", methods=["POST"])
def auto5vor8_save_settings(): def auto5vor8_save_settings():
for field in ('prompt', 'post_time', 'ai_model', 'feed_id', 'tag'): for field in ("prompt", "post_time", "ai_model", "feed_id", "tag", "style_rotation"):
val = request.form.get(field) val = request.form.get(field)
if val is not None: if val is not None:
_set_auto5vor8_setting(field, val.strip()) _set_auto5vor8_setting(field, val.strip())
logger.info('Auto-5vor8 Einstellungen gespeichert') logger.info("Auto-5vor8 Einstellungen gespeichert")
return redirect(url_for('auto5vor8')) return redirect(url_for("auto5vor8"))
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -4,6 +4,10 @@ FünfVorAcht Bot — Review, Scheduling, Briefing, Fehler-Alarm
""" """
import asyncio import asyncio
import os import os
import html
import re
import urllib.error
import urllib.request
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz import pytz
@ -16,6 +20,50 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
import database as db import database as db
import logger as flog import logger as flog
# Pre-Send-Check: jede arakavanews-URL muss vor dem Posten 200 liefern.
# Faengt drei Klassen von Bugs ab:
# 1. KI hat Slug halluziniert (auto_5vor8 sollte das schon repariert haben — Defense in Depth)
# 2. WP-Post wurde nachtraeglich geloescht/depubliziert
# 3. Cloudflare-Tunnel oder WP down zum Sendezeitpunkt
_URL_RE = re.compile(r"https?://arakavanews\.com/\S+", re.IGNORECASE)
def _check_arakava_links(text: str) -> tuple[bool, str]:
"""Prueft alle arakavanews-URLs im Text per HEAD via stdlib. (ok, fehlertext).
"ok" ist True wenn entweder keine URL drin ist oder ALLE URLs HTTP 200 liefern.
Bei mehreren URLs reicht ein einziger Fehlschlag fuer ok=False.
Nutzt urllib (stdlib), damit der Bot-Container keine zusaetzliche Dependency
(requests) braucht.
"""
if not text:
return True, ""
urls = []
for raw in _URL_RE.findall(text):
clean = raw.rstrip(').,;:!?"\'<>')
if clean and clean not in urls:
urls.append(clean)
if not urls:
return True, ""
for url in urls:
try:
req = urllib.request.Request(
url,
method="HEAD",
headers={"User-Agent": "fuenfvoracht-presend-check/1.0"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status != 200:
return False, f"{url} -> HTTP {resp.status}"
except urllib.error.HTTPError as e:
return False, f"{url} -> HTTP {e.code}"
except urllib.error.URLError as e:
return False, f"{url} -> URLError: {str(e.reason)[:80]}"
except Exception as e:
return False, f"{url} -> {type(e).__name__}: {str(e)[:80]}"
return True, ""
BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN'] BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN']
CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '') CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin')) TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin'))
@ -35,8 +83,13 @@ def today_str():
return datetime.now(TZ).strftime('%Y-%m-%d') return datetime.now(TZ).strftime('%Y-%m-%d')
def sanitize_telegram_html(text: str) -> str:
"""Escape <>& so Telegram HTML parse_mode does not fail on stray tags from KI/Quellen."""
return html.escape(text or "", quote=False)
def with_branding(content: str) -> str: def with_branding(content: str) -> str:
text = (content or "").rstrip() text = sanitize_telegram_html((content or "").rstrip())
if BRAND_MARKER in text: if BRAND_MARKER in text:
return text return text
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
@ -232,10 +285,48 @@ async def job_post_articles(bot: Bot):
return return
try: try:
final_text = with_branding(article['content_final']) final_text = with_branding(article['content_final'])
ok, link_err = await asyncio.to_thread(_check_arakava_links, final_text)
if not ok:
flog.posting_failed(d, current_slot, f"link_check_failed: {link_err}")
await notify_reviewers(
bot,
f"⛔ <b>Posting abgebrochen — kaputter Link!</b>\n\n"
f"📅 {d} · ⏰ {current_slot} Uhr\n\n"
f"<b>Fehler:</b>\n<code>{link_err}</code>\n\n"
f"Artikel bleibt auf <code>approved</code> (kein Auto-Retry).\n"
f"👉 Dashboard: http://100.73.171.62:8080"
)
continue
image_url = article.get('image_url', '') or ''
if image_url:
try:
msg = await bot.send_photo(CHANNEL_ID, photo=image_url, caption=final_text, parse_mode=ParseMode.HTML)
except Exception:
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
else:
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML) msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
db.update_article_status(d, 'posted', post_time=current_slot) db.update_article_status(d, 'posted', post_time=current_slot)
db.save_post_history(d, msg.message_id, post_time=current_slot) db.save_post_history(d, msg.message_id, post_time=current_slot)
flog.article_posted(d, current_slot, CHANNEL_ID, msg.message_id) flog.article_posted(d, current_slot, CHANNEL_ID, msg.message_id)
# memory_service_event
try:
import requests as _rq
_rq.post("http://100.121.192.94:8400/events", json={
"source": "fuenfvoracht",
"event_type": "article_posted",
"object_key": f"{d}_{current_slot}",
"payload_json": __import__("json").dumps({
"date": d,
"slot": current_slot,
"channel": str(CHANNEL_ID),
"message_id": msg.message_id,
"title": article.get("title", ""),
}, ensure_ascii=False),
}, headers={"Authorization": "Bearer Ai8eeQibV6Z1RWc7oNPim4PXB4vILU1nRW2-XgRcX2M"}, timeout=3)
except Exception:
pass
await notify_reviewers( await notify_reviewers(
bot, bot,
f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr" f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr"
@ -253,23 +344,19 @@ async def job_post_articles(bot: Bot):
async def job_check_notify(bot: Bot): async def job_check_notify(bot: Bot):
"""Prüft alle 5 Min ob scheduled-Artikel zur Bot-Benachrichtigung fällig sind.""" """Prüft alle 5 Min ob scheduled-Artikel fällig sind → direkt approved setzen."""
due = db.get_due_notifications() due = db.get_due_notifications()
for article in due: for article in due:
d = article['date'] d = article['date']
pt = article['post_time'] pt = article['post_time']
text = ( db.update_article_status(d, 'approved', post_time=pt)
f"📋 <b>Review: {d} · {pt} Uhr</b>\n" await notify_reviewers(
f"Version {article['version']}\n" bot,
f"──────────────────────\n\n" f"📅 <b>Artikel eingeplant</b>\n\n"
f"{article['content_final']}\n\n" f"📆 {d} um <b>{pt} Uhr</b>\n"
f"──────────────────────\n" f"Wird automatisch gepostet."
f"Freigeben oder bearbeiten?"
) )
await notify_reviewers(bot, text, flog.info('article_auto_approved', date=d, post_time=pt)
reply_markup=review_keyboard(d, pt))
db.update_article_status(d, 'sent_to_bot', post_time=pt)
flog.article_sent_to_bot(d, pt, db.get_reviewer_chat_ids())
async def job_morning_briefing(bot: Bot): async def job_morning_briefing(bot: Bot):
@ -300,52 +387,90 @@ async def job_morning_briefing(bot: Bot):
lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)") lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)")
for a in approved: for a in approved:
lines.append(f"{a['post_time']} Uhr — {(a['content_final'] or '')[:50]}") lines.append(f"{a['post_time']} Uhr — {(a['content_final'] or '')[:50]}")
elif missing:
lines.append("⚠️ <b>Entwurf wartet auf Freigabe</b> (Auto-5vor8 Faktencheck)")
for a in missing:
preview = (a.get('content_final') or a.get('content_raw') or '')[:50]
lines.append(f"{a['post_time']} Uhr — {preview}")
lines.append(" → Bitte im Dashboard prüfen und freigeben.")
else: else:
lines.append("⚠️ Noch kein Artikel für heute freigegeben!") lines.append(
"⚠️ <b>Heute noch kein Artikel im Plan.</b>\n"
" Der Dr.-Bine-RSS-Lauf ist um 14 Uhr — wenn dort nichts Neues kam, "
"bitte manuell anlegen."
)
if missing: if missing and approved:
lines.append(f"📝 Entwürfe (noch nicht freigegeben): {len(missing)}") lines.append(f"\n📝 Zusätzlich Entwürfe offen: {len(missing)}")
for a in missing:
if not articles_today: preview = (a.get('content_final') or a.get('content_raw') or '')[:50]
lines.append("❌ Kein Artikel erstellt — bitte jetzt anlegen.") lines.append(f"{a['post_time']} Uhr — {preview}")
lines.append(f"\n📆 <b>Nächste 3 Tage:</b>") lines.append(f"\n📆 <b>Nächste 3 Tage:</b>")
lines.extend(plan_lines) lines.extend(plan_lines)
lines.append(f"\n👉 Dashboard: http://100.73.171.62:8080") lines.append(f"\n👉 https://fuenfvoracht.orbitalo.net")
await notify_reviewers(bot, '\n'.join(lines)) await notify_reviewers(bot, '\n'.join(lines))
flog.info('morning_briefing_sent', date=d) flog.info('morning_briefing_sent', date=d)
async def job_cleanup_db(): async def job_cleanup_db():
"""Wöchentliche DB-Bereinigung: alte Einträge löschen.""" """Woechentliche DB-Bereinigung: alte Eintraege loeschen + VACUUM."""
import sqlite3, os import sqlite3, os
db_path = os.environ.get('DB_PATH', '/data/fuenfvoracht.db') db_path = os.environ.get('DB_PATH', '/data/fuenfvoacht.db')
con = sqlite3.connect(db_path) con = sqlite3.connect(db_path)
# post_history älter als 90 Tage
r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
# article_versions älter als 90 Tage
r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount
# Artikel die bereits gepostet sind und älter als 180 Tage
r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount
con.execute("VACUUM")
con.commit() con.commit()
con.close() con.close()
flog.info('db_cleanup', post_history_deleted=r1, versions_deleted=r2, articles_deleted=r3) vac = sqlite3.connect(db_path, isolation_level=None)
vac.execute("VACUUM")
vac.close()
flog.info('db_cleanup', ph_del=r1, av_del=r2, art_del=r3)
async def job_reminder_afternoon(bot: Bot): async def job_reminder_afternoon(bot: Bot):
"""Nachmittags-Reminder wenn Hauptslot noch nicht freigegeben.""" """Reminder: Entwurf zur Prüfung, oder Hinweis wenn gar nichts da ist."""
d = today_str() d = today_str()
articles = db.get_articles_by_date(d)
active_statuses = {'approved', 'scheduled', 'sent_to_bot', 'posted'}
active_articles = [a for a in articles if a.get('status') in active_statuses]
draft_articles = [a for a in articles if a.get('status') in ('draft', 'pending_review')]
if active_articles:
flog.info(
'afternoon_reminder_skipped_article_exists',
date=d,
slots=[a.get('post_time') for a in active_articles],
)
return
if draft_articles:
lines = [
f"📝 <b>Entwurf wartet auf Freigabe</b>\n",
f"📅 Heute ({d}) — Auto-5vor8 hat einen Entwurf angelegt, der noch nicht freigegeben ist:\n",
]
for a in draft_articles:
preview = (a.get('content_final') or a.get('content_raw') or '')[:55]
lines.append(f"{a['post_time']} Uhr — {preview}")
lines.append(
"\n⚠️ Der Faktencheck war nicht zufriedenstellend — bitte im Dashboard prüfen, "
"anpassen und freigeben, sonst geht heute nichts raus."
)
lines.append("\n👉 https://fuenfvoracht.orbitalo.net")
await notify_reviewers(bot, '\n'.join(lines))
flog.info('afternoon_reminder_draft_pending', date=d, count=len(draft_articles))
return
channel = db.get_channel() channel = db.get_channel()
post_t = channel.get('post_time', POST_TIME) post_t = channel.get('post_time', POST_TIME) if channel else POST_TIME
art = db.get_article_by_date(d, post_t)
if art and art['status'] in ('sent_to_bot', 'pending_review', 'draft', 'scheduled'):
await notify_reviewers( await notify_reviewers(
bot, bot,
f"⚠️ <b>Noch nicht freigegeben!</b>\n\n" f"⚠️ <b>Noch kein Artikel für heute!</b>\n\n"
f"Posting um {post_t} Uhr — bitte jetzt freigeben.", f"Weder freigegeben noch als Entwurf vorhanden.\n"
reply_markup=review_keyboard(d, post_t) if art['status'] == 'sent_to_bot' else None f"Geplanter Slot: {post_t} Uhr.\n"
f"👉 https://fuenfvoracht.orbitalo.net"
) )
@ -383,7 +508,7 @@ def main():
morning=f"{rem_m_h:02d}:{rem_m_m:02d}", morning=f"{rem_m_h:02d}:{rem_m_m:02d}",
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}") afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
else: else:
rem_m_h, rem_m_m = 10, 0 rem_m_h, rem_m_m = 15, 0
rem_a_h, rem_a_m = 17, 45 rem_a_h, rem_a_m = 17, 45
scheduler = AsyncIOScheduler(timezone=TZ) scheduler = AsyncIOScheduler(timezone=TZ)
@ -393,7 +518,7 @@ def main():
# Alle 5 Min auf fällige Benachrichtigungen prüfen # Alle 5 Min auf fällige Benachrichtigungen prüfen
scheduler.add_job(job_check_notify, 'cron', minute='*/5', scheduler.add_job(job_check_notify, 'cron', minute='*/5',
kwargs={'bot': bot}) kwargs={'bot': bot})
# Morgen-Briefing 10:00 MEZ # Tages-Briefing 15:00 — nach Dr.-Bine-RSS-Lauf (14:00) + Auto-5vor8
scheduler.add_job(job_morning_briefing, 'cron', scheduler.add_job(job_morning_briefing, 'cron',
hour=rem_m_h, minute=rem_m_m, hour=rem_m_h, minute=rem_m_m,
kwargs={'bot': bot}) kwargs={'bot': bot})
@ -401,7 +526,6 @@ def main():
scheduler.add_job(job_reminder_afternoon, 'cron', scheduler.add_job(job_reminder_afternoon, 'cron',
hour=rem_a_h, minute=rem_a_m, hour=rem_a_h, minute=rem_a_m,
kwargs={'bot': bot}) kwargs={'bot': bot})
# Wöchentliche DB-Bereinigung (Sonntags 03:00 Uhr)
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0) scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0)
scheduler.start() scheduler.start()