Faktenkompass: UI-Rebrand, Admin-Assistent und Produktions-Setup.

Modernisiert die öffentliche Oberfläche mit Inter, Dark Mode und Live-Suche, ergänzt KI-gestützte Entwürfe im Admin mit Nachweis-Uploads und dokumentiert Daphne/Nginx für den Serverbetrieb.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Pirmin Hinderling (fedora)
2026-07-07 15:33:58 +02:00
parent 2c7fb7d030
commit db3558872e
43 changed files with 3014 additions and 208 deletions
+257
View File
@@ -0,0 +1,257 @@
import base64
import json
import mimetypes
import re
from django.conf import settings
from claims.models import Category, Claim
VALID_CLAIM_STATUSES = {choice[0] for choice in Claim.Status.choices}
VALID_EVIDENCE_LEVELS = {choice[0] for choice in Claim.EvidenceLevel.choices}
SYSTEM_PROMPT = """Du bist ein sachlicher Fakten-Assistent für die Plattform „Faktenkompass“.
Deine Aufgabe: Aus Artikeln, Bildern und Nutzereingaben einen überprüfbaren Fakteneintrag erstellen.
Regeln:
- Schreibe auf Deutsch, neutral und wissenschaftlich.
- Keine parteipolitische Sprache, keine Übertreibungen.
- Die Behauptung (title) formuliert eine häufig geäußerte Aussage, die überprüft wird.
- Die Kurzantwort ist in ~20 Sekunden lesbar.
- Quellen müssen echte, nachvollziehbare Referenzen sein (URL aus Eingabe bevorzugen).
- Wähle eine passende bestehende Kategorie (use_existing_slug) ODER schlage eine neue vor.
- Icons für neue Kategorien: Bootstrap Icons Klassen wie bi-thermometer-half, bi-virus, bi-lightning-charge.
Antworte ausschließlich als gültiges JSON mit dieser Struktur:
{
"category": {
"use_existing_slug": "slug-oder-null",
"new_category": {
"name": "Name",
"description": "Kurzbeschreibung",
"icon": "bi-icon-name"
}
},
"claim": {
"title": "Die zu prüfende Behauptung",
"status": "falsch|irrefuehrend|teilweise_richtig|unbelegt|wissenschaftlicher_konsens|offene_frage",
"short_answer": "Kurze Einordnung",
"detailed_explanation": "Ausführlichere Erklärung",
"evidence_level": "sehr_stark|stark|mittel|schwach"
},
"tags": ["Schlagwort1", "Schlagwort2"],
"sources": [
{
"title": "Titel",
"organization": "Autor/Organisation",
"url": "https://...",
"description": "Kurzbeschreibung"
}
],
"counter_arguments": [
{
"argument": "Häufiges Gegenargument",
"response": "Sachliche Antwort"
}
],
"assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)"
}
"""
class AIAssistantError(Exception):
pass
def _categories_context():
categories = Category.objects.all().values('name', 'slug', 'description')
if not categories:
return 'Keine Kategorien vorhanden neue Kategorie vorschlagen.'
lines = []
for cat in categories:
lines.append(f"- {cat['name']} (slug: {cat['slug']}): {cat['description'][:120]}")
return '\n'.join(lines)
def _build_user_prompt(*, article_url='', article_text='', user_notes='', refinement=''):
parts = [
'Bestehende Kategorien:',
_categories_context(),
'',
f'Artikel-URL: {article_url or "(keine)"}',
'',
'Artikeltext:',
article_text or '(nicht verfügbar)',
'',
'Zusätzliche Hinweise des Admins:',
user_notes or '(keine)',
]
if refinement:
parts.extend(['', 'Überarbeitungswunsch:', refinement])
return '\n'.join(parts)
def _svg_to_png_bytes(svg_data):
try:
import cairosvg
except ImportError:
return None
try:
return cairosvg.svg2png(bytestring=svg_data)
except Exception:
return None
def _svg_text_fallback(svg_data):
text = svg_data.decode('utf-8', errors='replace')
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.IGNORECASE | re.DOTALL)
return text[:4000]
def _image_data_url(data, mime_type):
encoded = base64.b64encode(data).decode('ascii')
return {
'type': 'image_url',
'image_url': {'url': f'data:{mime_type};base64,{encoded}'},
}
def _image_message_part(uploaded_file):
uploaded_file.open('rb')
try:
data = uploaded_file.read()
finally:
uploaded_file.close()
mime_type = mimetypes.guess_type(uploaded_file.name)[0]
if mime_type == 'application/pdf' or uploaded_file.name.lower().endswith('.pdf'):
return {
'type': 'text',
'text': '[PDF-Datei hochgeladen nutze vor allem die Admin-Beschreibung und den Artikeltext.]',
}
if mime_type == 'image/svg+xml' or uploaded_file.name.lower().endswith('.svg'):
png_data = _svg_to_png_bytes(data)
if png_data:
return _image_data_url(png_data, 'image/png')
return {
'type': 'text',
'text': (
'[SVG-Grafik hochgeladen OpenAI unterstützt SVG nicht direkt. '
'Nutze diesen XML-Auszug und die Admin-Beschreibung:]\n'
+ _svg_text_fallback(data)
),
}
mime_type = mime_type or 'image/jpeg'
if mime_type not in {'image/png', 'image/jpeg', 'image/gif', 'image/webp'}:
mime_type = 'image/jpeg'
return _image_data_url(data, mime_type)
def _call_openai(messages, *, has_image=False):
api_key = getattr(settings, 'OPENAI_API_KEY', '')
if not api_key:
raise AIAssistantError(
'OPENAI_API_KEY ist nicht gesetzt. Bitte in der Umgebung oder .env konfigurieren.'
)
try:
from openai import OpenAI
except ImportError as exc:
raise AIAssistantError('OpenAI-Paket fehlt. Bitte pip install -r requirements.txt ausführen.') from exc
model = settings.OPENAI_MODEL_VISION if has_image else settings.OPENAI_MODEL
client = OpenAI(api_key=api_key, timeout=settings.OPENAI_TIMEOUT)
response = client.chat.completions.create(
model=model,
messages=messages,
response_format={'type': 'json_object'},
temperature=0.3,
)
content = response.choices[0].message.content
if not content:
raise AIAssistantError('Leere Antwort von der KI erhalten.')
return json.loads(content)
def _validate_payload(payload):
claim = payload.get('claim') or {}
status = claim.get('status', '')
evidence = claim.get('evidence_level', '')
if status and status not in VALID_CLAIM_STATUSES:
raise AIAssistantError(f'Ungültiger Status von der KI: {status}')
if evidence and evidence not in VALID_EVIDENCE_LEVELS:
raise AIAssistantError(f'Ungültiges Evidenzlevel von der KI: {evidence}')
return payload
def generate_draft_payload(
*,
article_url='',
article_text='',
user_notes='',
uploaded_file=None,
refinement='',
previous_payload=None,
):
user_prompt = _build_user_prompt(
article_url=article_url,
article_text=article_text,
user_notes=user_notes,
refinement=refinement,
)
if previous_payload:
user_prompt += '\n\nBisheriger Entwurf (JSON):\n' + json.dumps(previous_payload, ensure_ascii=False)
user_content = [{'type': 'text', 'text': user_prompt}]
has_image = False
extra_text = ''
if uploaded_file and uploaded_file.name:
part = _image_message_part(uploaded_file)
if part.get('type') == 'image_url':
user_content.append(part)
has_image = True
elif part.get('type') == 'text':
extra_text = part['text']
if extra_text:
user_prompt = f'{user_prompt}\n\n{extra_text}'
if has_image:
user_content[0] = {'type': 'text', 'text': user_prompt}
user_message = user_content
else:
user_message = user_prompt
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_message},
]
payload = _call_openai(messages, has_image=has_image)
return _validate_payload(payload)
def apply_payload_to_draft(draft, payload):
category = payload.get('category') or {}
claim = payload.get('claim') or {}
draft.category_slug = category.get('use_existing_slug') or ''
new_category = category.get('new_category') or {}
draft.new_category_name = new_category.get('name', '')
draft.new_category_description = new_category.get('description', '')
draft.new_category_icon = new_category.get('icon', '')
draft.title = claim.get('title', '')
draft.claim_status = claim.get('status', Claim.Status.UNBELEGT)
draft.short_answer = claim.get('short_answer', '')
draft.detailed_explanation = claim.get('detailed_explanation', '')
draft.evidence_level = claim.get('evidence_level', Claim.EvidenceLevel.MITTEL)
draft.tags = payload.get('tags') or []
draft.sources = payload.get('sources') or []
draft.counter_arguments = payload.get('counter_arguments') or []
draft.ai_raw_response = payload
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
draft.add_message('assistant', summary)