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']*>.*?', '', 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)