diff --git a/arakava-news/artikel/cursor-memory-system-artikel.md b/arakava-news/artikel/cursor-memory-system-artikel.md new file mode 100644 index 00000000..84349df2 --- /dev/null +++ b/arakava-news/artikel/cursor-memory-system-artikel.md @@ -0,0 +1,201 @@ +# Wie ich meiner KI ein Gedächtnis gebaut habe — und warum das alles verändert + +**Kategorie:** Technik / KI / Homelab +**Tags:** Cursor, Claude, AI Memory, Homelab, Git, Automatisierung, Prompt Engineering +**Lesezeit:** ca. 8 Minuten + +--- + +

Dieser Artikel erscheint zusammen mit einem öffentlichen GitHub-Repository: cursor-memory-system — alles was du brauchst um das System nachzubauen.

+ +--- + +## Das Problem: Die KI weiß nach jedem Gespräch nichts mehr + +Ich nutze Cursor mit Claude täglich für mein Homelab. Server einrichten, Code schreiben, Automatisierungen bauen. Das funktioniert gut — bis die Session endet. + +Beim nächsten Tag fängt die KI wieder bei null an. Ich erkläre zum dritten Mal welche Container laufen, welche IP mein WordPress hat, was das Projekt eigentlich soll. Wertvolle Minuten, manchmal eine halbe Stunde, nur um den Kontext wiederherzustellen. + +**Das ist kein Bug. Das ist wie diese KI-Systeme funktionieren.** Jedes Gespräch ist eine leere Seite. + +Ich wollte das ändern. + +--- + +## Die Idee: Ein Git-Repository als Gehirn + +Die Lösung kam aus einer simplen Beobachtung: Die KI kann Dateien lesen. Also gebe ich ihr die richtigen Dateien — mit allem was sie über mein System wissen muss. + +Kein Plugin. Kein teurer API-Wrapper. Nur ein Git-Repository mit Markdown-Dateien. + +Ich nenne es **homelab-brain**. + +*[Grafik: Animiertes Flussdiagramm — Live-Infrastruktur links, homelab-brain Repo mitte, Cursor/KI rechts, Datenpfeile fließen von links nach rechts]* + +--- + +## Wie es funktioniert + +### 1. Die Routing-Tabelle + +Die wichtigste Datei ist `.cursorrules` im Workspace-Root. Sie ist die **Telefonzentrale** des Systems: + +```markdown +## Routing-Tabelle +| Aufgabe betrifft... | Lade diese Datei | +|-----------------------------|-------------------------------| +| WordPress / RSS | arakava-news/STATE.md | +| KI-Redakteur | redax-wp/STATE.md | +| Smart Home / ioBroker | smart-home/STATE.md | +| ESP32 / Display / Heizung | esp32/PLAN.md | +| Server / Container / Proxmox| infrastructure/STATE.md | +``` + +Wenn ich eine neue Cursor-Session öffne und sage "richte mir einen neuen Container ein", liest die KI zuerst `.cursorrules` und weiß: *Infrastruktur → lade `infrastructure/STATE.md`*. Nur diese eine Datei. Das Kontextfenster bleibt sauber. + +### 2. Die STATE.md Dateien + +Für jedes Projekt gibt es eine `STATE.md`. Sie enthält alles was die KI wissen muss: + +```markdown +# STATE: Redax-WP +**Stand: 28.02.2026** + +## Zugang +| Was | URL | +|-----|-----| +| Dashboard | https://redax.orbitalo.net | +| Login | admin / astral66 | + +## Stack +redax-web Flask Dashboard (:8080) +redax-wordpress WordPress intern (:80) +redax-db MySQL 8 + +## Letzter Stand +- Multi-Publish zu 2 WordPress-Instanzen aktiv +- Drag & Drop im Redaktionsplan +- ESP32-Serie Teil 2 als Entwurf (Post 1340) +``` + +IPs, Passwörter, Container-Nummern, letzter Entwicklungsstand — alles drin. Die KI fragt nicht nach. + +### 3. Das Auto-Sync-Script + +Das Geniale: Die STATE.md-Dateien für laufende Services werden **automatisch aktualisiert**. Ein Cron-Job auf dem Server läuft alle 15 Minuten: + +```bash +# Läuft auf pve-hetzner, alle 15 Min +*/15 * * * * /opt/homelab-brain/scripts/sync-state.sh +``` + +Das Script fragt live die Container ab — ist der RSS-Manager aktiv? Wie viel OpenRouter-Guthaben ist noch da? Wie voll ist die Festplatte? — und schreibt die Ergebnisse in die STATE.md. Dann committed und pushed es auf Forgejo (mein internes Git). + +*[Screenshot: STATE.md mit Live-Daten — Timestamp, Service-Status grün, OpenRouter-Guthaben]* + +Wenn ich morgens Cursor öffne, hat die KI den **Stand von heute Nacht** — nicht von vor drei Wochen. + +--- + +## Was sich verändert hat + +**Vorher:** +> Ich: "Richte mir auf CT 113 den Redakteur ein." +> KI: "Was ist CT 113? Auf welchem Server? Welches OS? Was soll der Redakteur tun?" + +**Nachher:** +> Ich: "Richte mir auf CT 113 den Redakteur ein." +> KI: *liest infrastructure/STATE.md* → weiß: CT 113 ist auf pve-hetzner, Debian 12, IP 10.10.10.113, Docker läuft, Tailscale aktiv. Schreibt direkt den Deployment-Befehl. + +Der Unterschied ist nicht nur Zeit. Es ist **Qualität**. Die KI macht keine Annahmen mehr über meine Infrastruktur — sie kennt sie. + +--- + +## Das Ergebnis in Zahlen + +Ich betreibe damit: +- **7 aktive Projekte** (WordPress-Blog, KI-Redakteur, Edelmetall-Bot, Flugpreisscanner, Smart-Home, ESP32-Heizung, FünfVorAcht-Telegram-Bot) +- **12 Container** auf 2 Proxmox-Servern +- **Auto-Sync alle 15 Minuten** für 4 Live-Services + +Und ich muss der KI **nie wieder erklären** was mein System ist. + +--- + +## Der Trick mit dem Kontextfenster + +Ein häufiger Fehler: alles in eine riesige Datei packen und immer komplett laden. Das funktioniert nicht — Kontextfenster sind begrenzt, und je mehr irrelevanter Kontext drin ist, desto schlechter werden die Antworten. + +Meine Lösung: **Routing + Lazy Loading**. + +``` +.cursorrules ← wird IMMER geladen (klein, ~30 Zeilen) + └── Routing-Tabelle + ├── Aufgabe A → lade STATE-A.md + ├── Aufgabe B → lade STATE-B.md + └── Aufgabe C → lade STATE-C.md +``` + +Die KI lädt nur was gerade relevant ist. Bei 7 Projekten mit je 100-200 Zeilen STATE.md würde alles auf einmal ~1500 Zeilen Kontext fressen. Mit Routing sind es ~30 Zeilen Basis + ~150 Zeilen pro Session. + +*[Grafik: Vergleich Kontextfenster — ohne Routing (voll, rot) vs. mit Routing (schmal, grün)]* + +--- + +## Wie du es nachbaust + +Ich habe das komplette System als Template auf GitHub veröffentlicht: + +**→ [github.com/Orbitalo/cursor-memory-system](https://github.com/Orbitalo/cursor-memory-system)** + +Das Repository enthält: +- `.cursorrules` Vorlage mit Routing-Tabelle +- `STATE-template.md` zum Anpassen +- `sync-state.sh` Auto-Sync-Script (für Linux-Server) +- `SETUP.md` — Schritt-für-Schritt Anleitung + +**Minimale Version in 10 Minuten:** + +1. Repo klonen oder Template kopieren +2. `.cursorrules` in deinen Workspace-Root +3. Routing-Tabelle auf deine Projekte anpassen +4. Eine `STATE.md` für dein erstes Projekt anlegen +5. Cursor neu starten + +Das Auto-Sync ist optional — auch eine manuell gepflegte STATE.md ist 10x besser als keine. + +--- + +## Was kommt als nächstes + +Das System wächst mit dem Homelab. Geplant: + +- **ESP32-Integration**: Sobald die Heizungssteuerung läuft, werden Echtzeit-Temperaturen direkt in `smart-home/STATE.md` geschrieben — die KI sieht live ob der Pufferspeicher warm genug ist +- **Alert-System**: Wenn ein Service ausfällt, schreibt der Watchdog ein `⚠️ ALERT` in die STATE.md — die KI sieht es beim nächsten Öffnen sofort +- **Mehrsprachig**: README und Templates auf Englisch für die internationale Community + +--- + +## Fazit + +Das Konzept ist simpel bis zur Peinlichkeit: **Gib der KI die richtigen Dateien, und sie wird klüger.** + +Kein Plugin, kein Abo, keine schwarze Magie. Ein Git-Repository, ein paar Markdown-Dateien, optional ein Cron-Job. Das war's. + +Die KI hat kein schlechtes Gedächtnis. Sie hat nur kein Gedächtnis bekommen. + +--- + +*Dieser Artikel beschreibt mein persönliches Homelab-Setup. Das GitHub-Repository ist ein Template — anpassen erwünscht.* + +*→ GitHub: [cursor-memory-system](https://github.com/Orbitalo/cursor-memory-system)* +*→ Nächster Artikel: [ESP32-Heizungsprojekt Teil 2 — Die Hardware](/...)* + +--- + +### Grafik-Ideen für diesen Artikel + +1. **Animiertes Flussdiagramm**: Infrastruktur → homelab-brain → KI — mit leuchtenden Datenpfeilen (SVG, wie Fließschaltbild) +2. **Kontextfenster-Vergleich**: Balkendiagramm "ohne System" (voll, rot) vs. "mit Routing" (schlank, grün) +3. **Screenshot**: Cursor öffnet sich, KI antwortet sofort mit korrekten IPs/Befehlen ohne Nachfragen +4. **Repo-Struktur**: Dateibaum als schöne Grafik (dark theme) diff --git a/arakava-news/artikel/memory-system-diagram.svg b/arakava-news/artikel/memory-system-diagram.svg new file mode 100644 index 00000000..3ea701b7 Binary files /dev/null and b/arakava-news/artikel/memory-system-diagram.svg differ diff --git a/fuenfvoacht/docker-compose.yml b/fuenfvoacht/docker-compose.yml new file mode 100644 index 00000000..e69de29b diff --git a/fuenfvoacht/src/app.py b/fuenfvoacht/src/app.py new file mode 100644 index 00000000..8da687f1 --- /dev/null +++ b/fuenfvoacht/src/app.py @@ -0,0 +1,555 @@ +from flask import Flask, render_template, request, jsonify, redirect, url_for, Response +from functools import wraps +from datetime import datetime, date, timedelta +import os +import asyncio +import logging +import requests as req_lib + +import database as db +import openrouter +import logger as flog + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +TZ_NAME = os.environ.get('TIMEZONE', 'Europe/Berlin') +BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '') +POST_TIME = os.environ.get('POST_TIME', '19:55') + +AUTH_USER = os.environ.get('AUTH_USER', 'Holgerhh') +AUTH_PASS = os.environ.get('AUTH_PASS', 'ddlhh') + +BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben" +BRAND_SIGNATURE = ( + "Wir schützen die Zukunft unserer Kinder und das Leben❤️\n\n" + "Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n" + "Unterstützt die Menschen, die für Uns einstehen❗️" +) + + +def check_auth(username, password): + return username == AUTH_USER and password == AUTH_PASS + + +def authenticate(): + return Response( + 'Zugang verweigert.', 401, + {'WWW-Authenticate': 'Basic realm="FünfVorAcht"'}) + + +@app.before_request +def before_request_auth(): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + + +@app.after_request +def add_no_cache(response): + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + response.headers['Pragma'] = 'no-cache' + return response + + +def today_str(): + import pytz + return datetime.now(pytz.timezone(TZ_NAME)).strftime('%Y-%m-%d') + + +def today_display(): + import pytz + return datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y') + + +def week_range(): + today = date.today() + start = today - timedelta(days=today.weekday()) + return [(start + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)] + + +def planning_days(count=7): + import pytz + tz = pytz.timezone(TZ_NAME) + t = datetime.now(tz).date() + return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)] + + +def with_branding(content: str) -> str: + text = (content or "").rstrip() + if BRAND_MARKER in text: + return text + return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE + + +def send_telegram_message(chat_id, text, reply_markup=None): + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" + payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} + if reply_markup: + import json + payload["reply_markup"] = json.dumps(reply_markup) + try: + r = req_lib.post(url, json=payload, timeout=10) + return r.json() + except Exception as e: + logger.error("Telegram send fehlgeschlagen: %s", e) + return None + + +def notify_all_reviewers(text, reply_markup=None): + results = [] + for chat_id in db.get_reviewer_chat_ids(): + result = send_telegram_message(chat_id, text, reply_markup) + results.append({'chat_id': chat_id, 'ok': bool(result and result.get('ok'))}) + return results + + +# ── Main Dashboard ──────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + today = today_str() + articles_today = db.get_articles_by_date(today) + article_today = articles_today[0] if articles_today else None + week_days = week_range() + week_articles_raw = db.get_week_articles(week_days[0], week_days[-1]) + # Mehrere Artikel pro Tag: dict date → list + week_articles = {} + for a in week_articles_raw: + week_articles.setdefault(a['date'], []).append(a) + + recent = db.get_recent_articles(10) + stats = db.get_monthly_stats() + channel = db.get_channel() + prompts = db.get_prompts() + tags = db.get_tags() + favorites = db.get_favorites() + locations = db.get_locations() + current_location = db.get_current_location() + reviewers = db.get_reviewers() + last_posted = db.get_last_posted() + + plan_days = planning_days(7) + plan_raw = db.get_week_articles(plan_days[0], plan_days[-1]) + plan_articles = {} + for a in plan_raw: + plan_articles.setdefault(a['date'], []).append(a) + + month_start = date.today().replace(day=1).strftime('%Y-%m-%d') + month_end = (date.today().replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1) + month_articles = {} + for a in db.get_week_articles(month_start, month_end.strftime('%Y-%m-%d')): + month_articles.setdefault(a['date'], []).append(a['status']) + + return render_template('index.html', + today=today, + article_today=article_today, + articles_today=articles_today, + week_days=week_days, + week_articles=week_articles, + plan_days=plan_days, + plan_articles=plan_articles, + month_articles=month_articles, + recent=recent, + stats=stats, + channel=channel, + post_time=POST_TIME, + prompts=prompts, + tags=tags, + favorites=favorites, + locations=locations, + current_location=current_location, + reviewers=reviewers, + last_posted=last_posted) + + +# ── History ─────────────────────────────────────────────────────────────────── + +@app.route('/history') +def history(): + articles = db.get_recent_articles(30) + return render_template('history.html', articles=articles) + + +# ── Prompts ─────────────────────────────────────────────────────────────────── + +@app.route('/prompts') +def prompts(): + all_prompts = db.get_prompts() + return render_template('prompts.html', prompts=all_prompts) + + +@app.route('/prompts/save', methods=['POST']) +def save_prompt(): + pid = request.form.get('id') + name = request.form.get('name', '').strip() + system_prompt = request.form.get('system_prompt', '').strip() + if pid: + db.save_prompt(int(pid), name, system_prompt) + else: + db.create_prompt(name, system_prompt) + return redirect(url_for('prompts')) + + +@app.route('/prompts/default/', methods=['POST']) +def set_default_prompt(pid): + db.set_default_prompt(pid) + return redirect(url_for('prompts')) + + +@app.route('/prompts/delete/', methods=['POST']) +def delete_prompt(pid): + db.delete_prompt(pid) + return redirect(url_for('prompts')) + + +@app.route('/prompts/test', methods=['POST']) +def test_prompt(): + data = request.get_json() + system_prompt = data.get('system_prompt', '') + source = data.get('source', 'https://tagesschau.de') + tag = data.get('tag', 'Politik') + prompt_id = data.get('prompt_id') + import pytz + date_display = datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y') + try: + result = asyncio.run(openrouter.generate_article(source, system_prompt, date_display, tag)) + if prompt_id: + db.save_prompt_test_result(int(prompt_id), result) + return jsonify({'success': True, 'result': result}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +# ── Hilfe ───────────────────────────────────────────────────────────────────── + +@app.route('/hilfe') +def hilfe(): + return render_template('hilfe.html') + + +# ── Settings ────────────────────────────────────────────────────────────────── + +@app.route('/settings') +def settings(): + channel = db.get_channel() + favorites = db.get_favorites() + tags = db.get_tags() + reviewers = db.get_reviewers(active_only=False) + return render_template('settings.html', channel=channel, + favorites=favorites, tags=tags, + reviewers=reviewers) + + +@app.route('/settings/channel', methods=['POST']) +def save_channel(): + telegram_id = request.form.get('telegram_id', '').strip() + post_time = request.form.get('post_time', '19:55').strip() + db.update_channel(telegram_id, post_time) + return redirect(url_for('settings')) + + +@app.route('/settings/favorite/add', methods=['POST']) +def add_favorite(): + label = request.form.get('label', '').strip() + url = request.form.get('url', '').strip() + if label and url: + db.add_favorite(label, url) + return redirect(url_for('settings')) + + +# ── Reviewer API ────────────────────────────────────────────────────────────── + +@app.route('/api/reviewers', methods=['GET']) +def api_reviewers(): + return jsonify(db.get_reviewers(active_only=False)) + + +@app.route('/api/reviewers/add', methods=['POST']) +def api_add_reviewer(): + data = request.get_json() + chat_id = data.get('chat_id') + name = data.get('name', '').strip() + if not chat_id or not name: + return jsonify({'success': False, 'error': 'chat_id und name erforderlich'}) + try: + chat_id = int(chat_id) + except ValueError: + return jsonify({'success': False, 'error': 'Ungültige Chat-ID'}) + added = db.add_reviewer(chat_id, name) + if not added: + return jsonify({'success': False, 'error': 'Chat-ID bereits vorhanden'}) + # Willkommensnachricht + welcome = ( + f"👋 Willkommen bei FünfVorAcht!\n\n" + f"Du wurdest als Redakteur hinzugefügt.\n" + f"Ab jetzt erhältst du Reviews, Reminder und Status-Meldungen.\n\n" + f"/start für eine Übersicht aller Befehle." + ) + send_telegram_message(chat_id, welcome) + return jsonify({'success': True}) + + +@app.route('/api/reviewers/remove', methods=['POST']) +def api_remove_reviewer(): + data = request.get_json() + chat_id = data.get('chat_id') + if not chat_id: + return jsonify({'success': False, 'error': 'chat_id erforderlich'}) + db.remove_reviewer(int(chat_id)) + return jsonify({'success': True}) + + +# ── API Endpoints ───────────────────────────────────────────────────────────── + +@app.route('/api/generate', methods=['POST']) +def api_generate(): + data = request.get_json() + source = data.get('source', '').strip() + tag = data.get('tag', 'allgemein') + prompt_id = data.get('prompt_id') + date_str = data.get('date', today_str()) + post_time = data.get('post_time', POST_TIME) + if not source: + return jsonify({'success': False, 'error': 'Keine Quelle angegeben'}) + prompt = None + if prompt_id: + all_prompts = db.get_prompts() + prompt = next((p for p in all_prompts if str(p['id']) == str(prompt_id)), None) + if not prompt: + prompt = db.get_default_prompt() + if not prompt: + return jsonify({'success': False, 'error': 'Kein Prompt konfiguriert'}) + try: + content = asyncio.run( + openrouter.generate_article(source, prompt['system_prompt'], today_display(), tag) + ) + existing = db.get_article_by_date(date_str, post_time) + if existing: + db.update_article_content(date_str, content, new_version=True, post_time=post_time) + conn = db.get_conn() + conn.execute( + "UPDATE articles SET source_input=?, tag=?, status='draft' WHERE date=? AND post_time=?", + (source, tag, date_str, post_time) + ) + conn.commit() + conn.close() + else: + db.create_article(date_str, source, content, prompt['id'], tag, post_time) + flog.article_generated(date_str, source, 1, tag) + return jsonify({'success': True, 'content': content}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@app.route('/api/article//save', methods=['POST']) +def api_save(date_str): + data = request.get_json() + content = data.get('content', '').strip() + post_time = data.get('post_time', POST_TIME) + if not content: + return jsonify({'success': False, 'error': 'Kein Inhalt'}) + existing = db.get_article_by_date(date_str, post_time) + if existing: + db.update_article_content(date_str, content, post_time=post_time) + flog.article_saved(date_str, post_time) + return jsonify({'success': True, 'article_id': existing['id']}) + 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, 'article_id': candidate['id']}) + return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag vorhanden'}) + + +@app.route('/api/article//schedule', methods=['POST']) +def api_schedule(date_str): + """Artikel einplanen: post_time + notify_at setzen.""" + data = request.get_json() + post_time = data.get('post_time', POST_TIME) + notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom + notify_at_custom = data.get('notify_at') + + existing = db.get_article_by_date(date_str, post_time) + if not existing: + 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() + + conn = db.get_conn() + ts = datetime.utcnow().isoformat() + conn.execute( + "UPDATE articles SET status='approved', scheduled_at=?, notify_at=? WHERE date=? AND post_time=?", + (ts, ts, date_str, post_time) + ) + conn.commit() + conn.close() + + notify_all_reviewers( + f"📅 Artikel eingeplant\n\n" + f"📆 {date_str} um {post_time} Uhr\n" + f"Wird automatisch gepostet." + ) + + flog.article_scheduled(date_str, post_time, ts) + return jsonify({'success': True}) + + +@app.route('/api/article//reschedule', methods=['POST']) +def api_reschedule(article_id): + data = request.get_json() + new_date = data.get('date') + new_time = data.get('post_time') + if not new_date or not new_time: + return jsonify({'success': False, 'error': 'date und post_time erforderlich'}) + # Slot-Konflikt prüfen + if db.slot_is_taken(new_date, new_time, exclude_id=article_id): + taken = db.get_taken_slots(new_date) + flog.slot_conflict(new_date, new_time) + return jsonify({ + 'success': False, + 'error': f'Slot {new_date} {new_time} ist bereits belegt.', + 'taken_slots': taken + }) + ok = db.reschedule_article(article_id, new_date, new_time) + if ok: + return jsonify({'success': True}) + return jsonify({'success': False, 'error': 'Fehler beim Umplanen'}) + + +@app.route('/api/article//delete', methods=['POST']) +def api_delete(article_id): + db.delete_article(article_id) + return jsonify({'success': True}) + + +@app.route('/api/slots/') +def api_slots(date_str): + """Gibt belegte Slots für einen Tag zurück.""" + taken = db.get_taken_slots(date_str) + return jsonify({'date': date_str, 'taken': taken}) + + +@app.route('/api/article//send-to-bot', methods=['POST']) +def api_send_to_bot(date_str): + data = request.get_json() or {} + post_time = data.get('post_time', POST_TIME) + article = db.get_article_by_date(date_str, post_time) + if not article or not article.get('content_final'): + return jsonify({'success': False, 'error': 'Kein Artikel vorhanden'}) + channel = db.get_channel() + pt = channel.get('post_time', post_time) + branded = with_branding(article['content_final']) + text = ( + f"📋 Review: {date_str} · {post_time} Uhr\n" + f"Version {article['version']}\n" + f"──────────────────────\n\n" + f"{branded}\n\n" + f"──────────────────────\n" + f"Freigeben oder bearbeiten?" + ) + keyboard = { + "inline_keyboard": [[ + {"text": "✅ Freigeben", "callback_data": f"approve:{date_str}:{post_time}"}, + {"text": "✏️ Bearbeiten", "callback_data": f"edit:{date_str}:{post_time}"} + ]] + } + results = notify_all_reviewers(text, keyboard) + ok_count = sum(1 for r in results if r['ok']) + if ok_count > 0: + db.update_article_status(date_str, 'sent_to_bot', post_time=post_time) + flog.article_sent_to_bot(date_str, post_time, [r['chat_id'] for r in results if r['ok']]) + return jsonify({'success': True}) + return jsonify({'success': False, 'error': 'Kein Reviewer erreichbar'}) + + +@app.route('/api/settings/post-time', methods=['POST']) +def api_save_post_time(): + data = request.get_json() + post_time = data.get('post_time', '19:55') + channel = db.get_channel() + db.update_channel(channel.get('telegram_id', ''), post_time) + return jsonify({'success': True}) + + +@app.route('/api/settings/location', methods=['POST']) +def api_set_location(): + data = request.get_json() + location_id = data.get('location_id') + if not location_id: + return jsonify({'success': False, 'error': 'Keine Location-ID'}) + db.set_location(location_id) + loc = db.get_current_location() + morning, afternoon = db.get_reminder_times_in_berlin(loc) + return jsonify({ + 'success': True, + 'location': loc, + 'reminders_berlin': { + 'morning': f"{morning[0]:02d}:{morning[1]:02d}", + 'afternoon': f"{afternoon[0]:02d}:{afternoon[1]:02d}" + } + }) + + +@app.route('/api/balance') +def api_balance(): + try: + balance = asyncio.run(openrouter.get_balance()) + return jsonify(balance) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/article/') +def api_article(date_str): + post_time = request.args.get('post_time') + article = db.get_article_by_date(date_str, post_time) + if not article: + return jsonify({'error': 'Nicht gefunden'}), 404 + return jsonify(article) + + +@app.route('/api/articles/') +def api_articles_for_date(date_str): + """Alle Artikel eines Tages (alle Slots).""" + articles = db.get_articles_by_date(date_str) + return jsonify(articles) + + +@app.route('/api/article//approve', methods=['POST']) +def api_approve(date_str): + data = request.get_json() or {} + post_time = data.get('post_time', POST_TIME) + db.update_article_status(date_str, 'approved', post_time=post_time) + flog.article_approved(date_str, post_time, 0) + return jsonify({'success': True}) + + +@app.route('/api/article//skip', methods=['POST']) +def api_skip(date_str): + data = request.get_json() or {} + post_time = data.get('post_time', POST_TIME) + db.update_article_status(date_str, 'skipped', post_time=post_time) + flog.article_skipped(date_str, post_time) + return jsonify({'success': True}) + + +if __name__ == '__main__': + db.init_db() + app.run(host='0.0.0.0', port=8080, debug=False) diff --git a/fuenfvoacht/src/database.py b/fuenfvoacht/src/database.py new file mode 100644 index 00000000..7eee89c3 --- /dev/null +++ b/fuenfvoacht/src/database.py @@ -0,0 +1,732 @@ +import sqlite3 +import os +import logging +from datetime import datetime + +import logger as flog + +_logger = logging.getLogger(__name__) +DB_PATH = os.environ.get('DB_PATH', '/data/fuenfvoracht.db') + +DEFAULT_PROMPT = '''Du erstellst einen strukturierten Beitrag für den Telegram-Kanal "Fünf vor Acht". +Der Beitrag präsentiert einen Inhalt (Video, Artikel, Vortrag) neutral und informativ. +Leser sollen sich selbst ein Bild machen können. + +EINGABE: {source} +DATUM: {date} +THEMA: {tag} + +AUFGABE: +Analysiere die Quelle und erstelle einen Telegram-Beitrag nach exakt diesem FORMAT. +Wähle passende Emojis für die Sektions-Überschriften je nach Thema. +Schreibe sachlich und ohne eigene Wertung. + +FORMAT — exakt so ausgeben (Telegram-kompatibel, kein HTML): + +[Kategorie-Emoji] [Typ]: [Vollständiger Titel] + +🔗 [Quelle ansehen / Artikel lesen / Video ansehen]: +[URL aus der Eingabe] + +[Themen-Emoji] Inhaltlicher Schwerpunkt + +[2-3 Sätze: Wer spricht/schreibt worüber und in welchem Kontext] + +Themen im Überblick: +• [Kernthema 1] +• [Kernthema 2] +• [Kernthema 3] +• [Kernthema 4] +• [Kernthema 5] + +[2. Themen-Emoji] [Zweiter Schwerpunkt falls vorhanden] + +[2-3 Sätze zum zweiten Teil] + +• [Unterpunkt 1] +• [Unterpunkt 2] +• [Unterpunkt 3] + +📌 Einordnung + +[2-3 Sätze: Neutrale Beschreibung des Formats/der Methode. Kein Urteil. Leser können Quellen selbst prüfen.]''' + + +def get_conn(): + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + conn = get_conn() + c = conn.cursor() + + # Basis-Tabellen + c.executescript(''' + CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + post_time TEXT NOT NULL DEFAULT '19:55', + source_input TEXT, + content_raw TEXT, + content_final TEXT, + status TEXT DEFAULT 'draft', + version INTEGER DEFAULT 1, + review_message_id INTEGER, + review_chat_id INTEGER, + prompt_id INTEGER, + tag TEXT, + notify_at TEXT, + scheduled_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + sent_to_bot_at TEXT, + approved_at TEXT, + posted_at TEXT, + UNIQUE(date, post_time) + ); + CREATE TABLE IF NOT EXISTS article_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + version_nr INTEGER NOT NULL, + content TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (article_id) REFERENCES articles(id) + ); + CREATE TABLE IF NOT EXISTS post_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + channel_message_id INTEGER, + posted_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (article_id) REFERENCES articles(id) + ); + CREATE TABLE IF NOT EXISTS prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + system_prompt TEXT NOT NULL, + is_default INTEGER DEFAULT 0, + last_tested_at TEXT, + test_result TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS sources_favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + url TEXT, + used_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ); + CREATE TABLE IF NOT EXISTS article_tags ( + article_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (article_id, tag_id), + FOREIGN KEY (article_id) REFERENCES articles(id), + FOREIGN KEY (tag_id) REFERENCES tags(id) + ); + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + telegram_id TEXT, + post_time TEXT DEFAULT '19:55', + timezone TEXT DEFAULT 'Europe/Berlin', + active INTEGER DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + flag TEXT NOT NULL, + timezone TEXT NOT NULL, + reminder_morning TEXT DEFAULT '10:00', + reminder_afternoon TEXT DEFAULT '18:00' + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS reviewers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL, + active INTEGER DEFAULT 1, + added_at TEXT DEFAULT (datetime('now')) + ); + ''') + + # Migration: post_time Spalte zu bestehender articles-Tabelle hinzufügen falls fehlt + cols = [r[1] for r in c.execute("PRAGMA table_info(articles)").fetchall()] + if 'post_time' not in cols: + c.execute("ALTER TABLE articles ADD COLUMN post_time TEXT NOT NULL DEFAULT '19:55'") + _logger.info("Migration: post_time Spalte hinzugefügt") + if 'notify_at' not in cols: + c.execute("ALTER TABLE articles ADD COLUMN notify_at TEXT") + if 'scheduled_at' not in cols: + c.execute("ALTER TABLE articles ADD COLUMN scheduled_at TEXT") + + # Migration: UNIQUE(date) → UNIQUE(date, post_time) + # Alte DBs hatten nur UNIQUE auf date; das verhindert mehrere Artikel pro Tag + unique_idx_cols = [r[2] for r in c.execute("PRAGMA index_info(sqlite_autoindex_articles_1)").fetchall()] + if unique_idx_cols == ['date']: + _logger.info("Migration: UNIQUE(date) → UNIQUE(date, post_time) wird durchgeführt") + c.executescript(""" + CREATE TABLE articles_migrated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + post_time TEXT NOT NULL DEFAULT '19:55', + source_input TEXT, + content_raw TEXT, + content_final TEXT, + status TEXT DEFAULT 'draft', + version INTEGER DEFAULT 1, + review_message_id INTEGER, + review_chat_id INTEGER, + prompt_id INTEGER, + tag TEXT, + notify_at TEXT, + scheduled_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + sent_to_bot_at TEXT, + approved_at TEXT, + posted_at TEXT, + UNIQUE(date, post_time) + ); + INSERT INTO articles_migrated + SELECT id, date, post_time, source_input, content_raw, content_final, + status, version, review_message_id, review_chat_id, prompt_id, + tag, notify_at, scheduled_at, created_at, sent_to_bot_at, + approved_at, posted_at + FROM articles; + DROP TABLE articles; + ALTER TABLE articles_migrated RENAME TO articles; + """) + _logger.info("Migration: UNIQUE(date, post_time) erfolgreich") + + # reviewers-Tabelle Migration + reviewer_cols = [r[1] for r in c.execute("PRAGMA table_info(reviewers)").fetchall()] + if not reviewer_cols: + _logger.info("Migration: reviewers Tabelle erstellt") + + # Standard-Daten + c.execute("SELECT COUNT(*) FROM prompts") + if c.fetchone()[0] == 0: + c.execute( + "INSERT INTO prompts (name, system_prompt, is_default) VALUES (?, ?, 1)", + ("Standard", DEFAULT_PROMPT) + ) + c.execute("SELECT COUNT(*) FROM channels") + if c.fetchone()[0] == 0: + c.execute( + "INSERT INTO channels (name, telegram_id, post_time, timezone) VALUES (?, ?, ?, ?)", + ("Fünf vor Acht", "", "19:55", "Europe/Berlin") + ) + for tag in ["Politik", "Wirtschaft", "Tech", "Gesellschaft", "Umwelt", "Kultur", "Sport"]: + c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) + + locations = [ + ("Deutschland", "🇩🇪", "Europe/Berlin", "10:00", "18:00"), + ("Kambodscha", "🇰🇭", "Asia/Phnom_Penh", "10:00", "18:00"), + ("Thailand", "🇹🇭", "Asia/Bangkok", "10:00", "18:00"), + ("USA Ostküste", "🇺🇸", "America/New_York", "10:00", "18:00"), + ("USA Westküste","🇺🇸", "America/Los_Angeles", "10:00", "18:00"), + ("Spanien", "🇪🇸", "Europe/Madrid", "10:00", "18:00"), + ] + c.execute("SELECT COUNT(*) FROM locations") + if c.fetchone()[0] == 0: + for loc in locations: + c.execute( + "INSERT INTO locations (name, flag, timezone, reminder_morning, reminder_afternoon) VALUES (?,?,?,?,?)", + loc + ) + + c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('user_location_id', '1')") + + conn.commit() + conn.close() + _logger.info("DB initialisiert: %s", DB_PATH) + + +# ── Article CRUD ────────────────────────────────────────────────────────────── + +def get_article_by_date(date_str, post_time=None): + """Gibt ersten Artikel des Tages zurück, oder den mit spezifischer post_time.""" + conn = get_conn() + if post_time: + row = conn.execute( + "SELECT * FROM articles WHERE date=? AND post_time=?", (date_str, post_time) + ).fetchone() + else: + row = conn.execute( + "SELECT * FROM articles WHERE date=? ORDER BY post_time ASC LIMIT 1", (date_str,) + ).fetchone() + conn.close() + return dict(row) if row else None + + +def get_articles_by_date(date_str): + """Gibt alle Artikel eines Tages zurück (mehrere Slots).""" + conn = get_conn() + rows = conn.execute( + "SELECT * FROM articles WHERE date=? ORDER BY post_time ASC", (date_str,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_article_by_id(article_id): + conn = get_conn() + row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone() + conn.close() + return dict(row) if row else None + + +def slot_is_taken(date_str, post_time, exclude_id=None): + """Prüft ob ein Zeitslot bereits belegt ist.""" + conn = get_conn() + if exclude_id: + row = conn.execute( + "SELECT id FROM articles WHERE date=? AND post_time=? AND id!=? AND status NOT IN ('skipped','rejected')", + (date_str, post_time, exclude_id) + ).fetchone() + else: + row = conn.execute( + "SELECT id FROM articles WHERE date=? AND post_time=? AND status NOT IN ('skipped','rejected')", + (date_str, post_time) + ).fetchone() + conn.close() + return row is not None + + +def get_taken_slots(date_str): + """Gibt alle belegten Zeitslots eines Tages zurück.""" + conn = get_conn() + rows = conn.execute( + "SELECT post_time FROM articles WHERE date=? AND status NOT IN ('skipped','rejected')", + (date_str,) + ).fetchall() + conn.close() + return [r[0] for r in rows] + + +def create_article(date_str, source_input, content, prompt_id, tag="allgemein", post_time="19:55"): + conn = get_conn() + try: + conn.execute( + "INSERT INTO articles (date, post_time, source_input, content_raw, content_final, prompt_id, tag) VALUES (?,?,?,?,?,?,?)", + (date_str, post_time, source_input, content, content, prompt_id, tag) + ) + conn.execute( + "INSERT INTO article_versions (article_id, version_nr, content) " + "VALUES ((SELECT id FROM articles WHERE date=? AND post_time=?), 1, ?)", + (date_str, post_time, content) + ) + conn.commit() + flog.article_generated(date_str, source_input or '', 1, tag) + finally: + conn.close() + + +def reschedule_article(article_id, new_date, new_post_time): + """Verschiebt einen Artikel auf einen neuen Datum/Zeit-Slot.""" + conn = get_conn() + try: + conn.execute( + "UPDATE articles SET date=?, post_time=? WHERE id=?", + (new_date, new_post_time, article_id) + ) + conn.commit() + flog.info('article_rescheduled', article_id=article_id, + new_date=new_date, new_post_time=new_post_time) + return True + except sqlite3.IntegrityError: + flog.slot_conflict(new_date, new_post_time) + return False + finally: + conn.close() + + +def delete_article(article_id): + conn = get_conn() + conn.execute("DELETE FROM article_versions WHERE article_id=?", (article_id,)) + conn.execute("DELETE FROM articles WHERE id=?", (article_id,)) + conn.commit() + conn.close() + flog.info('article_deleted', article_id=article_id) + + +def update_article_status(date_str, status, message_id=None, chat_id=None, post_time=None): + conn = get_conn() + ts = datetime.utcnow().isoformat() + where = "date=? AND post_time=?" if post_time else "date=?" + params_base = (date_str, post_time) if post_time else (date_str,) + + if status == 'approved': + conn.execute( + f"UPDATE articles SET status=?, approved_at=?, review_message_id=?, review_chat_id=? WHERE {where}", + (status, ts, message_id, chat_id) + params_base + ) + elif status == 'posted': + conn.execute( + f"UPDATE articles SET status=?, posted_at=? WHERE {where}", + (status, ts) + params_base + ) + elif status == 'sent_to_bot': + conn.execute( + f"UPDATE articles SET status=?, sent_to_bot_at=?, review_message_id=?, review_chat_id=? WHERE {where}", + (status, ts, message_id, chat_id) + params_base + ) + elif status == 'scheduled': + conn.execute( + f"UPDATE articles SET status=?, scheduled_at=? WHERE {where}", + (status, ts) + params_base + ) + else: + conn.execute(f"UPDATE articles SET status=? WHERE {where}", (status,) + params_base) + conn.commit() + conn.close() + + +def schedule_article(date_str, post_time, notify_at): + """Artikel einplanen mit Bot-Benachrichtigungs-Zeitpunkt.""" + conn = get_conn() + ts = datetime.utcnow().isoformat() + conn.execute( + "UPDATE articles SET status='scheduled', scheduled_at=?, post_time=?, notify_at=? WHERE date=? AND post_time=?", + (ts, post_time, notify_at, date_str, post_time) + ) + conn.commit() + conn.close() + flog.article_scheduled(date_str, post_time, notify_at) + + +def get_due_notifications(): + """Gibt alle scheduled-Artikel zurück deren notify_at <= jetzt.""" + conn = get_conn() + now = datetime.utcnow().isoformat() + rows = conn.execute( + "SELECT * FROM articles WHERE status='scheduled' AND notify_at IS NOT NULL AND notify_at <= ?", + (now,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def update_article_content(date_str, content, new_version=False, post_time=None): + conn = get_conn() + where = "date=? AND post_time=?" if post_time else "date=?" + params_base = (date_str, post_time) if post_time else (date_str,) + + if new_version: + version = conn.execute( + f"SELECT version FROM articles WHERE {where}", params_base + ).fetchone() + new_v = (version[0] or 1) + 1 + conn.execute( + f"UPDATE articles SET content_raw=?, content_final=?, version=?, status='pending_review' WHERE {where}", + (content, content, new_v) + params_base + ) + article_id = conn.execute( + f"SELECT id FROM articles WHERE {where}", params_base + ).fetchone()[0] + conn.execute( + "INSERT INTO article_versions (article_id, version_nr, content) VALUES (?,?,?)", + (article_id, new_v, content) + ) + else: + conn.execute( + f"UPDATE articles SET content_final=? WHERE {where}", (content,) + params_base + ) + conn.commit() + conn.close() + + +def save_post_history(date_str, channel_message_id, post_time=None): + conn = get_conn() + where = "date=? AND post_time=?" if post_time else "date=?" + params = (date_str, post_time) if post_time else (date_str,) + article_id = conn.execute( + f"SELECT id FROM articles WHERE {where}", params + ).fetchone() + if article_id: + conn.execute( + "INSERT INTO post_history (article_id, channel_message_id) VALUES (?,?)", + (article_id[0], channel_message_id) + ) + conn.commit() + conn.close() + + +def get_recent_articles(limit=30): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM articles ORDER BY date DESC, post_time ASC LIMIT ?", (limit,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_week_articles(from_date, to_date): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM articles WHERE date BETWEEN ? AND ? ORDER BY date ASC, post_time ASC", + (from_date, to_date) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_last_posted(): + conn = get_conn() + row = conn.execute( + "SELECT date, post_time, posted_at FROM articles WHERE status='posted' ORDER BY posted_at DESC LIMIT 1" + ).fetchone() + conn.close() + return dict(row) if row else None + + +# ── Prompts ─────────────────────────────────────────────────────────────────── + +def get_prompts(): + conn = get_conn() + rows = conn.execute("SELECT * FROM prompts ORDER BY is_default DESC, id ASC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_default_prompt(): + conn = get_conn() + row = conn.execute("SELECT * FROM prompts WHERE is_default=1 LIMIT 1").fetchone() + if not row: + row = conn.execute("SELECT * FROM prompts LIMIT 1").fetchone() + conn.close() + return dict(row) if row else None + + +def save_prompt(prompt_id, name, system_prompt): + conn = get_conn() + conn.execute("UPDATE prompts SET name=?, system_prompt=? WHERE id=?", (name, system_prompt, prompt_id)) + conn.commit() + conn.close() + + +def create_prompt(name, system_prompt): + conn = get_conn() + conn.execute("INSERT INTO prompts (name, system_prompt) VALUES (?,?)", (name, system_prompt)) + conn.commit() + conn.close() + + +def set_default_prompt(prompt_id): + conn = get_conn() + conn.execute("UPDATE prompts SET is_default=0") + conn.execute("UPDATE prompts SET is_default=1 WHERE id=?", (prompt_id,)) + conn.commit() + conn.close() + + +def delete_prompt(prompt_id): + conn = get_conn() + conn.execute("DELETE FROM prompts WHERE id=? AND is_default=0", (prompt_id,)) + conn.commit() + conn.close() + + +def save_prompt_test_result(prompt_id, result): + conn = get_conn() + conn.execute( + "UPDATE prompts SET test_result=?, last_tested_at=? WHERE id=?", + (result, datetime.utcnow().isoformat(), prompt_id) + ) + conn.commit() + conn.close() + + +# ── Channel / Settings ──────────────────────────────────────────────────────── + +def get_channel(): + conn = get_conn() + row = conn.execute("SELECT * FROM channels WHERE active=1 LIMIT 1").fetchone() + conn.close() + return dict(row) if row else {} + + +def update_channel(telegram_id, post_time="19:55"): + conn = get_conn() + conn.execute( + "UPDATE channels SET telegram_id=?, post_time=? WHERE active=1", + (telegram_id, post_time) + ) + conn.commit() + conn.close() + + +# ── Reviewers ───────────────────────────────────────────────────────────────── + +def get_reviewers(active_only=True): + conn = get_conn() + if active_only: + rows = conn.execute( + "SELECT * FROM reviewers WHERE active=1 ORDER BY added_at ASC" + ).fetchall() + else: + rows = conn.execute("SELECT * FROM reviewers ORDER BY added_at ASC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_reviewer(chat_id: int, name: str): + conn = get_conn() + try: + conn.execute( + "INSERT INTO reviewers (chat_id, name, active) VALUES (?,?,1)", + (chat_id, name) + ) + conn.commit() + flog.reviewer_added(chat_id, name) + return True + except sqlite3.IntegrityError: + return False + finally: + conn.close() + + +def remove_reviewer(chat_id: int): + conn = get_conn() + conn.execute("UPDATE reviewers SET active=0 WHERE chat_id=?", (chat_id,)) + conn.commit() + conn.close() + flog.reviewer_removed(chat_id) + + +def get_reviewer_chat_ids(): + """Gibt aktive Reviewer-Chat-IDs aus DB zurück, Fallback auf ENV.""" + reviewers = get_reviewers(active_only=True) + if reviewers: + return [r['chat_id'] for r in reviewers] + # Fallback: ENV + import os + ids = [] + raw = os.environ.get('REVIEW_CHAT_IDS', '') + if raw.strip(): + for part in raw.split(','): + try: + ids.append(int(part.strip())) + except ValueError: + pass + admin = os.environ.get('ADMIN_CHAT_ID', '') + if admin: + try: + ids.append(int(admin)) + except ValueError: + pass + unique = [] + for cid in ids: + if cid not in unique: + unique.append(cid) + return unique + + +# ── Favorites ───────────────────────────────────────────────────────────────── + +def get_favorites(): + conn = get_conn() + rows = conn.execute("SELECT * FROM sources_favorites ORDER BY used_count DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_favorite(label, url): + conn = get_conn() + conn.execute("INSERT INTO sources_favorites (label, url) VALUES (?,?)", (label, url)) + conn.commit() + conn.close() + + +def increment_favorite(fav_id): + conn = get_conn() + conn.execute("UPDATE sources_favorites SET used_count=used_count+1 WHERE id=?", (fav_id,)) + conn.commit() + conn.close() + + +# ── Tags ────────────────────────────────────────────────────────────────────── + +def get_tags(): + conn = get_conn() + rows = conn.execute("SELECT * FROM tags ORDER BY name").fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ── Locations ───────────────────────────────────────────────────────────────── + +def get_locations(): + conn = get_conn() + rows = conn.execute("SELECT * FROM locations ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_current_location(): + conn = get_conn() + loc_id = conn.execute( + "SELECT value FROM settings WHERE key='user_location_id'" + ).fetchone() + if not loc_id: + conn.close() + return None + row = conn.execute("SELECT * FROM locations WHERE id=?", (loc_id[0],)).fetchone() + conn.close() + return dict(row) if row else None + + +def set_location(location_id): + conn = get_conn() + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('user_location_id', ?)", + (str(location_id),) + ) + conn.commit() + conn.close() + + +def get_reminder_times_in_berlin(location: dict) -> tuple: + import pytz + from datetime import datetime, date + user_tz = pytz.timezone(location['timezone']) + berlin_tz = pytz.timezone('Europe/Berlin') + today = date.today() + + def convert(local_time_str): + h, m = map(int, local_time_str.split(':')) + local_dt = user_tz.localize(datetime(today.year, today.month, today.day, h, m)) + berlin_dt = local_dt.astimezone(berlin_tz) + return berlin_dt.hour, berlin_dt.minute + + return convert(location['reminder_morning']), convert(location['reminder_afternoon']) + + +# ── Stats ───────────────────────────────────────────────────────────────────── + +def get_monthly_stats(): + conn = get_conn() + from datetime import date + month = date.today().strftime('%Y-%m') + total = conn.execute( + "SELECT COUNT(*) FROM articles WHERE date LIKE ?", (f"{month}%",) + ).fetchone()[0] + posted = conn.execute( + "SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='posted'", (f"{month}%",) + ).fetchone()[0] + skipped = conn.execute( + "SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='skipped'", (f"{month}%",) + ).fetchone()[0] + avg_version = conn.execute( + "SELECT AVG(version) FROM articles WHERE date LIKE ?", (f"{month}%",) + ).fetchone()[0] or 0 + conn.close() + return {"total": total, "posted": posted, "skipped": skipped, "avg_version": round(avg_version, 1)} diff --git a/fuenfvoacht/src/logger.py b/fuenfvoacht/src/logger.py new file mode 100644 index 00000000..2adf7796 --- /dev/null +++ b/fuenfvoacht/src/logger.py @@ -0,0 +1,100 @@ +""" +Strukturiertes Logging für FünfVorAcht. +Schreibt JSON-Lines nach /logs/fuenfvoracht.log +""" +import json +import logging +import os +from datetime import datetime + +LOG_PATH = os.environ.get('LOG_PATH', '/logs/fuenfvoracht.log') + +_file_handler = None + + +def _get_file_handler(): + global _file_handler + if _file_handler is None: + os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) + _file_handler = logging.FileHandler(LOG_PATH, encoding='utf-8') + _file_handler.setLevel(logging.DEBUG) + return _file_handler + + +def _write(level: str, event: str, **kwargs): + entry = { + 'ts': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), + 'level': level, + 'event': event, + **kwargs, + } + line = json.dumps(entry, ensure_ascii=False) + try: + handler = _get_file_handler() + record = logging.LogRecord( + name='fuenfvoracht', level=getattr(logging, level), + pathname='', lineno=0, msg=line, args=(), exc_info=None + ) + handler.emit(record) + except Exception: + pass + # Auch in stdout damit docker logs es zeigt + print(line, flush=True) + + +def info(event: str, **kwargs): + _write('INFO', event, **kwargs) + + +def warning(event: str, **kwargs): + _write('WARNING', event, **kwargs) + + +def error(event: str, **kwargs): + _write('ERROR', event, **kwargs) + + +# Kurzformen für häufige Events +def article_generated(date: str, source: str, version: int, tag: str): + info('article_generated', date=date, source=source[:120], version=version, tag=tag) + + +def article_saved(date: str, post_time: str): + info('article_saved', date=date, post_time=post_time) + + +def article_scheduled(date: str, post_time: str, notify_at: str): + info('article_scheduled', date=date, post_time=post_time, notify_at=notify_at) + + +def article_sent_to_bot(date: str, post_time: str, chat_ids: list): + info('article_sent_to_bot', date=date, post_time=post_time, chat_ids=chat_ids) + + +def article_approved(date: str, post_time: str, by_chat_id: int): + info('article_approved', date=date, post_time=post_time, by_chat_id=by_chat_id) + + +def article_posted(date: str, post_time: str, channel_id: str, message_id: int): + info('article_posted', date=date, post_time=post_time, + channel_id=channel_id, message_id=message_id) + + +def article_skipped(date: str, post_time: str): + info('article_skipped', date=date, post_time=post_time) + + +def posting_failed(date: str, post_time: str, reason: str): + error('posting_failed', date=date, post_time=post_time, reason=reason[:300]) + + +def reviewer_added(chat_id: int, name: str): + info('reviewer_added', chat_id=chat_id, name=name) + + +def reviewer_removed(chat_id: int): + info('reviewer_removed', chat_id=chat_id) + + +def slot_conflict(date: str, post_time: str): + warning('slot_conflict', date=date, post_time=post_time) diff --git a/fuenfvoacht/src/openrouter.py b/fuenfvoacht/src/openrouter.py new file mode 100644 index 00000000..47e6e107 --- /dev/null +++ b/fuenfvoacht/src/openrouter.py @@ -0,0 +1,72 @@ +import os +import logging +import aiohttp +import asyncio + +logger = logging.getLogger(__name__) + +OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '') +OPENROUTER_BASE = "https://openrouter.ai/api/v1" +DEFAULT_MODEL = os.environ.get('AI_MODEL', 'openai/gpt-4o-mini') + + +async def generate_article(source: str, prompt_template: str, date_str: str, tag: str = "allgemein") -> str: + system_prompt = prompt_template.format( + source=source, + date=date_str, + tag=tag.lower().replace(" ", "") + ) + payload = { + "model": DEFAULT_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Schreibe jetzt den Artikel basierend auf dieser Quelle:\n\n{source}"} + ], + "max_tokens": 600, + "temperature": 0.8 + } + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "https://fuenfvoracht.orbitalo.net", + "X-Title": "FünfVorAcht Bot" + } + async with aiohttp.ClientSession() as session: + async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp: + data = await resp.json() + if resp.status != 200: + raise Exception(f"OpenRouter Fehler {resp.status}: {data}") + return data["choices"][0]["message"]["content"].strip() + + +async def get_balance() -> dict: + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json" + } + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{OPENROUTER_BASE}/auth/key", headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + key_data = data.get("data", {}) + limit = key_data.get("limit") + usage = key_data.get("usage", 0) + if limit: + remaining = round(limit - usage, 4) + else: + remaining = None + return { + "usage": round(usage, 4), + "limit": limit, + "remaining": remaining, + "label": key_data.get("label", ""), + "is_free_tier": key_data.get("is_free_tier", False) + } + except Exception as e: + logger.error("Balance-Abfrage fehlgeschlagen: %s", e) + return {"usage": None, "limit": None, "remaining": None} + + +def get_balance_sync() -> dict: + return asyncio.run(get_balance()) diff --git a/fuenfvoacht/src/requirements-bot.txt b/fuenfvoacht/src/requirements-bot.txt new file mode 100644 index 00000000..dfa8e16b --- /dev/null +++ b/fuenfvoacht/src/requirements-bot.txt @@ -0,0 +1,4 @@ +python-telegram-bot==20.7 +apscheduler==3.10.4 +aiohttp==3.9.3 +pytz==2024.1 diff --git a/fuenfvoacht/src/requirements-web.txt b/fuenfvoacht/src/requirements-web.txt new file mode 100644 index 00000000..70ee4151 --- /dev/null +++ b/fuenfvoacht/src/requirements-web.txt @@ -0,0 +1,5 @@ +flask==3.0.2 +aiohttp==3.9.3 +pytz==2024.1 +gunicorn==21.2.0 +requests==2.31.0 diff --git a/fuenfvoacht/src/templates/hilfe.html b/fuenfvoacht/src/templates/hilfe.html new file mode 100644 index 00000000..b86718a0 --- /dev/null +++ b/fuenfvoacht/src/templates/hilfe.html @@ -0,0 +1,386 @@ + + + + + +Anleitung — FünfVorAcht + + + + + + + + +
+ +
+

Anleitung

+ FünfVorAcht — KI-gestützter Telegram-Poster +
+ + +
+

⚡ Schnellübersicht — Normaler Tagesablauf

+
+
1. Quelle eingeben
+ +
2. Artikel generieren
+ +
3. Redigieren & speichern
+ +
4. Einplanen (Uhrzeit)
+ +
5. Zum Bot senden
+ +
6. Im Bot freigeben ✅
+ +
7. Automatisch gepostet 📤
+
+
+ + +
+ +
+ ✏️ +

1. Artikel erstellen

+
+ +
+
+ +
+
+
Quelle eingeben
+

URL eines Artikels, Videos oder Vortrags einfügen — oder ein Thema als Text beschreiben.

+

Tipp: Häufig genutzte Quellen als Favoriten speichern → Dropdown nutzen.

+
+
+
Thema & Prompt wählen
+

Tag (z.B. Politik, Tech) und den gewünschten KI-Prompt auswählen.

+

Prompts können unter Prompts bearbeitet und getestet werden.

+
+
+ +
+
⚡ Generieren
+

Button Artikel generieren klicken — die KI erstellt einen fertigen Telegram-Beitrag. Rechts erscheint sofort die Telegram-Vorschau.

+

Nicht zufrieden? Neu generieren erstellt eine neue Version (v2, v3 …). Alle Versionen werden gespeichert.

+
+ +
+
✍️ Redigieren
+

Text im Editor direkt bearbeiten. Die Telegram-Vorschau aktualisiert sich in Echtzeit. Zeichenanzahl wird live angezeigt (max. 4096).

+

Das Markenzeichen wird automatisch am Ende eingefügt — nicht manuell nötig.

+
+ +
+
+ + +
+ +
+ 📅 +

2. Zeitlich einplanen

+ NEU +
+ +
+
+ +
+
📅 Einplanen-Panel
+

Nach dem Generieren auf Einplanen klicken. Ein Panel öffnet sich:

+
    +
  • Datum — aus dem Redaktionsplan übernommen oder frei wählbar
  • +
  • Uhrzeit — 15-Minuten-Raster (z.B. 07:00, 19:45, 19:55)
  • +
  • Bot-Benachrichtigung — Sofort / Vortag 17:00 / Posting-Tag 10:00
  • +
+
+ +
+
+
Artikel für heute
+

Bot-Benachrichtigung: Sofort vorausgewählt

+
+
+
Artikel für morgen/später
+

Automatisch: Vortag 17:00 Uhr vorausgewählt

+
+
+
Zeitkonflikt
+

Blockiert wenn Slot belegt — belegte Zeiten sind ausgegraut

+
+
+ +
+ ℹ️ Mehrere Posts pro Tag: Für jeden Zeitslot einen eigenen Artikel anlegen. Jeder Slot wird unabhängig eingeplant und gepostet. +
+ +
+
+ + +
+ +
+ 📱 +

3. Freigabe & Review im Telegram-Bot

+ BOT +
+ +
+
+ +
+
+
Review erhalten
+

Zum geplanten Zeitpunkt (oder sofort bei manuell senden) schickt der Bot den Artikel an alle Redakteure mit zwei Buttons:

+
+ ✅ Freigeben + ✏️ Bearbeiten +
+
+
+
Bearbeiten im Bot
+

✏️ drücken → Bot zeigt aktuellen Text → einfach neue Version als nächste Nachricht schicken → Bot bestätigt + zeigt erneut Review-Buttons.

+
+
+ +
+
Bot-Befehle
+
+
/start Übersicht & Hilfe
+
/heute Alle Slots von heute
+
/queue Nächste 3 Tage
+
/skip Hauptslot heute überspringen
+
+
+ +
+
☀️ Morgen-Briefing (10:00 Uhr MEZ)
+

Täglich um 10:00 Uhr schickt der Bot automatisch einen Überblick:

+
    +
  • • Welche Slots heute geplant sind (mit Status)
  • +
  • • Ob noch etwas fehlt oder freigegeben werden muss
  • +
  • • Ausblick auf die nächsten 3 Tage
  • +
+
+ +
+
+ + +
+ +
+ 📤 +

4. Automatisches Posting

+
+ +
+
+ +
+
Ablauf
+

Der Scheduler prüft jede Minute: gibt es einen freigegebenen Artikel dessen Uhrzeit jetzt fällig ist?

+
    +
  • Freigegeben + Uhrzeit erreicht → wird in den Kanal gepostet
  • +
  • Nicht freigegeben → Nachmittags-Reminder (18:00 Uhr)
  • +
  • Fehler beim Posting → sofortiger Fehler-Alarm an alle Redakteure
  • +
+
+ +
+
+
+
Freigegeben
+
Postet automatisch
+
+
+
⚠️
+
Kein Artikel
+
Alarm + überspringen
+
+
+
+
Posting-Fehler
+
Sofort-Alarm mit Ursache
+
+
+ +
+ Das Markenzeichen wird automatisch unter jeden Beitrag gesetzt — auch wenn es im Editor noch nicht sichtbar ist. +
+ +
+
+ + +
+ +
+ 🗂️ +

5. Redaktionsplan verwalten

+ NEU +
+ +
+
+ +
+
+
📅 Tag anklicken
+

Klick auf einen Tag im Redaktionsplan lädt den Artikel direkt ins Studio — ohne neu generieren.

+
+
+
🔄 Umplanen
+

Direkt im Board: neues Datum oder Uhrzeit wählen. Bei Zeitkonflikt wird geblockt und ein freier Slot vorgeschlagen.

+
+
+
🗑️ Löschen
+

Artikel aus einem Slot entfernen — mit Sicherheitsabfrage. Slot wird danach wieder als frei angezeigt.

+
+
+ +
+
Status-Übersicht
+
+
📝
Entwurf
+
🗓️
Eingeplant
+
📱
Beim Bot
+
Freigegeben
+
📤
Gepostet
+
⏭️
Skip
+
+
+ +
+
+ + +
+ +
+ ⚙️ +

6. Einstellungen

+
+ +
+
+ +
+
+
📢 Telegram Kanal
+

Kanal-ID oder @username des Ziel-Kanals eintragen. Der Bot muss Admin im Kanal sein.

+
+
+
⏰ Standard-Posting-Zeit
+

Default-Uhrzeit für neue Artikel. Kann pro Artikel beim Einplanen überschrieben werden.

+
+
+
👥 Redakteure
+

Neue Redakteure per Chat-ID hinzufügen. Beim Hinzufügen erhält der neue Redakteur automatisch eine Willkommensnachricht. Chat-ID herausfinden: @userinfobot in Telegram.

+
+
+
📌 Quellen-Favoriten
+

Häufig genutzte URLs speichern — erscheinen im Studio als Dropdown für schnellen Zugriff.

+
+
+
🌍 Aufenthaltsort
+

Aktuellen Standort einstellen. Die Reminder-Zeiten werden automatisch auf MEZ umgerechnet.

+
+
+
🧠 Prompts
+

KI-Prompts erstellen, bearbeiten und mit einer Testquelle ausprobieren. Default-Prompt für neue Artikel festlegen.

+
+
+ +
+
+ + +
+ +
+ +

Häufige Fragen

+
+ +
+
+ +
+
Was passiert wenn ich vergesse freizugeben?
+

Um 18:00 Uhr kommt ein Reminder. Falls bis zur Posting-Zeit kein freigegebener Artikel vorhanden ist, wird der Slot übersprungen und ein Alarm gesendet.

+
+ +
+
Kann ich einen bereits geposteten Artikel bearbeiten?
+

Im Dashboard nicht rückwirkend — aber in Telegram kannst du die Nachricht direkt bearbeiten (Telegram-Editier-Funktion).

+
+ +
+
Wo finde ich die Chat-ID für einen neuen Redakteur?
+

In Telegram @userinfobot anschreiben → gibt die eigene Chat-ID zurück. Oder die Person schreibt dem @Diendemleben_bot — die ID erscheint dann im Bot-Log.

+
+ +
+
Wie sehe ich ob der Bot läuft?
+

Im Dashboard-Header: letzter Post-Zeitstempel. Im Bot: /start senden — Antwort bedeutet Bot ist aktiv. Auf dem Server: docker ps in CT 112.

+
+ +
+
Kann ich mehrere Artikel pro Tag planen?
+

Ja — jeden Zeitslot (15-Min-Raster) einmal belegen. Jeder Slot wird unabhängig gepostet. Doppelt belegte Slots werden automatisch blockiert.

+
+ +
+
+ +
+ FünfVorAcht · CT 112 auf pve-hetzner · Dashboard: fuenfvoracht.orbitalo.net +
+ +
+ + + + + diff --git a/fuenfvoacht/src/templates/history.html b/fuenfvoacht/src/templates/history.html new file mode 100644 index 00000000..51866774 --- /dev/null +++ b/fuenfvoacht/src/templates/history.html @@ -0,0 +1,72 @@ + + + + + +History — FünfVorAcht + + + + + + +
+

📋 Artikel-History

+
+ {% for art in articles %} +
+
+
+ {{ art.date }} + + {{ {'posted': '📤 Gepostet', 'approved': '✅ Freigegeben', + 'pending_review': '⏳ Offen', 'skipped': '⏭️ Skip'}.get(art.status, art.status) }} + + v{{ art.version }} +
+ +
+
+ Quelle: {{ art.source_input[:80] if art.source_input else '—' }} +
+ +
+ {% else %} +
Noch keine Artikel vorhanden.
+ {% endfor %} +
+
+ + + diff --git a/fuenfvoacht/src/templates/index.html b/fuenfvoacht/src/templates/index.html new file mode 100644 index 00000000..34d136af --- /dev/null +++ b/fuenfvoacht/src/templates/index.html @@ -0,0 +1,961 @@ + + + + + +🕗 FünfVorAcht Dashboard + + + + + + + + +
+ + +
+
+

✏️ Artikel-Studio — {{ today }}

+
+ {% if article_today %} + + {{ {'draft':'📝 Entwurf','sent_to_bot':'📱 Beim Bot','approved':'✅ Freigegeben','posted':'📤 Gepostet','skipped':'⏭️ Übersprungen','pending_review':'⏳ Offen'}.get(article_today.status, article_today.status) }} + + {% else %} + 📝 Neu + {% endif %} + +
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ + +
+ +
+ + +
+ + + +
+ + + + +
+ + +
+ + +
+
+ + +
+
+ Vorschau erscheint beim Bearbeiten… +
+
+ + +
+
+

⏰ Posting

+
+ + 🇩🇪 + + +
+
+
+ {{ channel.post_time or '19:55' }} Uhr 🇩🇪 + +
+
+
+ +
+
+
+ + +
+ + +
+
+

📅 Redaktionsplan — Nächste 7 Tage

+ Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪 +
+
+ {% set status_icons = {'draft':'📝','scheduled':'🗓️','sent_to_bot':'📱','approved':'✅','posted':'📤','skipped':'⏭️','pending_review':'⏳'} %} + {% set status_labels = {'draft':'Entwurf','scheduled':'Eingeplant','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'} %} + {% set status_css = {'posted':'status-posted','approved':'status-approved','sent_to_bot':'status-sent','scheduled':'status-sent','draft':'status-pending','pending_review':'status-pending','skipped':'status-skipped'} %} + + {% for d in plan_days %} + {% set arts = plan_articles.get(d, []) %} + {% set is_today = (d == today) %} + + +
+ {{ d[8:] }}.{{ d[5:7] }}. + {% if is_today %}Heute{% endif %} + {% if not arts %}— kein Artikel{% endif %} +
+ + {% for art in arts %} + +
+ + +
+ {{ status_icons.get(art.status, '❓') }} + {{ art.post_time }} +
+ {% if art.content_final %} +
{{ art.content_final[:70] }}
+
v{{ art.version }}{% if art.tag %} · {{ art.tag }}{% endif %}
+ {% else %} +
Kein Inhalt
+ {% endif %} +
+ + {{ status_labels.get(art.status, art.status) }} + + + + {% if art.status != 'posted' %} +
+ + +
+ {% endif %} +
+ + + + +
+ {% endfor %} + + {% if not arts %} +
+ {% endif %} + + {% endfor %} +
+
+ + +
+ + +
+

📆 Monatsübersicht

+
+
Mo
+
Di
+
Mi
+
Do
+
Fr
+
Sa
+
So
+
+
+
+ Gepostet + Geplant + Entwurf + Leer +
+
+ + +
+

📊 {{ today[:7] }}

+
+
+
{{ stats.posted }}
+
Gepostet
+
+
+
{{ stats.skipped }}
+
Skip
+
+
+
{{ stats.avg_version }}×
+
Ø Regen.
+
+
+
+
+ 💰 OpenRouter + +
+
— 🔄 klicken
+
+
+ + +
+
+

📋 Letzte Posts

+ Alle → +
+ {% for art in recent[:4] %} +
+ {{ art.date[5:] }} + {{ art.content_final[:50] if art.content_final else '—' }} + {{ {'posted':'📤','approved':'✅','sent_to_bot':'📱','draft':'📝','skipped':'⏭️'}.get(art.status,'?') }} +
+ {% else %} +
Keine Artikel.
+ {% endfor %} +
+ +
+
+ +
+ + + + diff --git a/fuenfvoacht/src/templates/logs.html b/fuenfvoacht/src/templates/logs.html new file mode 100644 index 00000000..b595ee81 --- /dev/null +++ b/fuenfvoacht/src/templates/logs.html @@ -0,0 +1,211 @@ + + + + + +Logs — FünfVorAcht + + + + + + + + +
+
+

📋 System-Logs

+
+ + + +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ Lade Logs… +
+ + +
+
+ + + + diff --git a/fuenfvoacht/src/templates/prompts.html b/fuenfvoacht/src/templates/prompts.html new file mode 100644 index 00000000..854411bf --- /dev/null +++ b/fuenfvoacht/src/templates/prompts.html @@ -0,0 +1,224 @@ + + + + + +Prompts — FünfVorAcht + + + + + + + +
+
+

🧠 Prompt-Bibliothek

+ +
+ +
+ + +
+ {% for p in prompts %} +
+
+
+ {{ p.name }} + {% if p.is_default %} + Standard + {% endif %} +
+ {% if not p.is_default %} +
+ +
+ {% endif %} +
+
{{ p.system_prompt[:100] }}…
+ {% if p.last_tested_at %} +
Zuletzt getestet: {{ p.last_tested_at[:16] }}
+ {% endif %} +
+ {% endfor %} +
+ + +
+
+

Prompt auswählen

+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + diff --git a/fuenfvoacht/src/templates/settings.html b/fuenfvoacht/src/templates/settings.html new file mode 100644 index 00000000..5d35f5d4 --- /dev/null +++ b/fuenfvoacht/src/templates/settings.html @@ -0,0 +1,96 @@ + + + + + +Einstellungen — FünfVorAcht + + + + + + +
+

⚙️ Einstellungen

+ + +
+

📢 Telegram Kanal

+
+
+ + +
+
+ + +
+ +
+
+ + +
+

📌 Quellen-Favoriten

+ {% if favorites %} +
+ {% for f in favorites %} +
+
+ {{ f.label }} + {{ f.url[:50] }}{% if f.url|length > 50 %}…{% endif %} +
+ {{ f.used_count }}× +
+ {% endfor %} +
+ {% endif %} +
+ + + +
+
+ + +
+

🏷️ Themen-Tags

+
+ {% for tag in tags %} + #{{ tag.name }} + {% endfor %} +
+
Tags werden im Bot beim Generieren automatisch aus dem Prompt übernommen.
+
+ +
+ +