Files
Faktenkompass/claims/services/ai_assistant.py
T
Pirmin Hinderling (fedora) 16428f2c16 Replace counter-arguments with possible answers and unify search inputs.
Models, AI assistant, and UI now show diskussionsfertige Antworten without titles; shared search partial fixes aria-label rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:50:53 +02:00

266 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
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.
- Unter possible_answers: 24 kurze, diskussionsfertige Antworten auf die Behauptung formulieren.
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"
},
"tags": ["Schlagwort1", "Schlagwort2"],
"sources": [
{
"title": "Titel",
"organization": "Autor/Organisation",
"url": "https://...",
"description": "Kurzbeschreibung"
}
],
"possible_answers": [
{
"answer": "Kurze, sachliche Antwort auf die Behauptung für die Diskussion"
}
],
"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', '')
if status and status not in VALID_CLAIM_STATUSES:
raise AIAssistantError(f'Ungültiger Status von der KI: {status}')
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 _normalize_possible_answers(items):
answers = []
for item in items or []:
if isinstance(item, str):
text = item.strip()
else:
text = (item.get('answer') or item.get('response') or '').strip()
if text:
answers.append({'answer': text})
return answers
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.tags = payload.get('tags') or []
draft.sources = payload.get('sources') or []
draft.possible_answers = _normalize_possible_answers(
payload.get('possible_answers', payload.get('counter_arguments'))
)
draft.ai_raw_response = payload
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
draft.add_message('assistant', summary)