28aab3b16a
Drops Evidenzlevel from models, admin, public UI, and draft workflow while keeping Nachweise file uploads unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
8.0 KiB
Python
252 lines
8.0 KiB
Python
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.
|
||
|
||
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"
|
||
}
|
||
],
|
||
"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', '')
|
||
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 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.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)
|