Files
Faktenkompass/claims/models.py
T
Pirmin Hinderling (fedora) 28aab3b16a Remove ambiguous evidence level field from claims and the AI assistant.
Drops Evidenzlevel from models, admin, public UI, and draft workflow while keeping Nachweise file uploads unchanged.

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

328 lines
10 KiB
Python

import os
import uuid
from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
DRAFT_UPLOAD_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
class Tag(models.Model):
name = models.CharField('Name', max_length=100, unique=True)
slug = models.SlugField('Slug', max_length=120, unique=True, blank=True)
class Meta:
verbose_name = 'Schlagwort'
verbose_name_plural = 'Schlagwörter'
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Category(models.Model):
name = models.CharField('Name', max_length=200)
slug = models.SlugField('Slug', max_length=220, unique=True, blank=True)
description = models.TextField('Beschreibung')
icon = models.CharField(
'Icon (Bootstrap Icons Klasse)',
max_length=50,
blank=True,
help_text='z. B. bi-thermometer-half',
)
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
class Meta:
verbose_name = 'Kategorie'
verbose_name_plural = 'Kategorien'
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('claims:category_detail', kwargs={'slug': self.slug})
@property
def claim_count(self):
return self.claims.count()
class Claim(models.Model):
class Status(models.TextChoices):
FALSCH = 'falsch', 'Falsch'
IRREFUEHREND = 'irrefuehrend', 'Irreführend'
TEILWEISE_RICHTIG = 'teilweise_richtig', 'Teilweise richtig'
UNBELEGT = 'unbelegt', 'Unbelegt'
WISSENSCHAFTLICHER_KONSENS = 'wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens'
OFFENE_FRAGE = 'offene_frage', 'Offene Frage'
category = models.ForeignKey(
Category,
on_delete=models.CASCADE,
related_name='claims',
verbose_name='Kategorie',
)
title = models.CharField('Titel (Behauptung)', max_length=300)
slug = models.SlugField('Slug', max_length=320, unique=True, blank=True)
status = models.CharField(
'Status',
max_length=40,
choices=Status.choices,
default=Status.UNBELEGT,
)
short_answer = models.TextField('Kurzantwort')
detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True)
tags = models.ManyToManyField(Tag, blank=True, verbose_name='Schlagwörter')
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
class Meta:
verbose_name = 'Behauptung'
verbose_name_plural = 'Behauptungen'
ordering = ['-updated_at']
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.title)
slug = base_slug
counter = 1
while Claim.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f'{base_slug}-{counter}'
counter += 1
self.slug = slug
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('claims:claim_detail', kwargs={'slug': self.slug})
def get_status_badge_class(self):
mapping = {
self.Status.FALSCH: 'danger',
self.Status.IRREFUEHREND: 'warning',
self.Status.TEILWEISE_RICHTIG: 'info',
self.Status.UNBELEGT: 'secondary',
self.Status.WISSENSCHAFTLICHER_KONSENS: 'success',
self.Status.OFFENE_FRAGE: 'primary',
}
return mapping.get(self.status, 'secondary')
def evidence_upload_path(instance, filename):
ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin'
if ext not in EVIDENCE_FILE_EXTENSIONS:
ext = 'bin'
claim_id = instance.claim_id or 'new'
return f'evidence/{claim_id}/{uuid.uuid4().hex[:12]}.{ext}'
class EvidenceFile(models.Model):
claim = models.ForeignKey(
Claim,
on_delete=models.CASCADE,
related_name='evidence_files',
verbose_name='Behauptung',
)
title = models.CharField('Titel', max_length=200, blank=True)
file = models.FileField(
'Datei',
upload_to=evidence_upload_path,
max_length=500,
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
)
caption = models.CharField('Bildunterschrift', max_length=500, blank=True)
order = models.PositiveSmallIntegerField('Reihenfolge', default=0)
created_at = models.DateTimeField('Hochgeladen am', auto_now_add=True)
class Meta:
verbose_name = 'Nachweis'
verbose_name_plural = 'Nachweise'
ordering = ['order', 'id']
def __str__(self):
return self.display_title
@property
def display_title(self):
if self.title:
return self.title
return self.file.name.rsplit('/', 1)[-1]
@property
def file_extension(self):
return self.file.name.rsplit('.', 1)[-1].lower()
@property
def is_image(self):
return self.file_extension in IMAGE_EXTENSIONS
@property
def is_pdf(self):
return self.file_extension == 'pdf'
@property
def image_alt(self):
return self.caption or 'Nachweis-Grafik'
class Source(models.Model):
claim = models.ForeignKey(
Claim,
on_delete=models.CASCADE,
related_name='sources',
verbose_name='Behauptung',
)
title = models.CharField('Titel', max_length=300)
organization = models.CharField('Autor/Organisation', max_length=200)
url = models.URLField('Link', max_length=500)
description = models.TextField('Beschreibung', blank=True)
class Meta:
verbose_name = 'Quelle'
verbose_name_plural = 'Quellen'
ordering = ['id']
def __str__(self):
return self.title
class CounterArgument(models.Model):
claim = models.ForeignKey(
Claim,
on_delete=models.CASCADE,
related_name='counter_arguments',
verbose_name='Behauptung',
)
argument = models.TextField('Gegenargument')
response = models.TextField('Antwort')
class Meta:
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
ordering = ['id']
def __str__(self):
return self.argument[:80]
def draft_upload_path(instance, filename):
return f'drafts/{instance.pk or "new"}/{filename}'
class ClaimDraft(models.Model):
class DraftStatus(models.TextChoices):
PROCESSING = 'processing', 'Wird verarbeitet'
REVIEW = 'review', 'Zur Prüfung'
PUBLISHED = 'published', 'Veröffentlicht'
FAILED = 'failed', 'Fehlgeschlagen'
REJECTED = 'rejected', 'Abgelehnt'
created_by = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='claim_drafts',
verbose_name='Erstellt von',
)
status = models.CharField(
'Status',
max_length=20,
choices=DraftStatus.choices,
default=DraftStatus.PROCESSING,
)
article_url = models.URLField('Artikel-URL', max_length=500, blank=True)
user_notes = models.TextField('Eingabe / Beschreibung', blank=True)
uploaded_file = models.FileField(
'Hochgeladene Datei',
upload_to='drafts/uploads/%Y/%m/',
max_length=500,
blank=True,
validators=[FileExtensionValidator(allowed_extensions=DRAFT_UPLOAD_EXTENSIONS)],
help_text='Optional: Bild oder PDF als Kontext',
)
article_text = models.TextField('Extrahierter Artikeltext', blank=True)
category_slug = models.SlugField(
'Bestehende Kategorie (Slug)',
max_length=220,
blank=True,
help_text='Slug einer vorhandenen Kategorie, falls passend',
)
new_category_name = models.CharField('Neue Kategorie', max_length=200, blank=True)
new_category_description = models.TextField('Beschreibung neue Kategorie', blank=True)
new_category_icon = models.CharField(
'Icon neue Kategorie',
max_length=50,
blank=True,
help_text='Bootstrap-Icon-Klasse, z. B. bi-thermometer-half',
)
title = models.CharField('Behauptung', max_length=300, blank=True)
claim_status = models.CharField(
'Einordnung',
max_length=40,
choices=Claim.Status.choices,
blank=True,
)
short_answer = models.TextField('Kurzantwort', blank=True)
detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True)
tags = models.JSONField('Schlagwörter', default=list, blank=True)
sources = models.JSONField('Quellen', default=list, blank=True)
counter_arguments = models.JSONField('Gegenargumente', default=list, blank=True)
conversation = models.JSONField('Konversation', default=list, blank=True)
ai_raw_response = models.JSONField('KI-Rohantwort', blank=True, null=True)
error_message = models.TextField('Fehlermeldung', blank=True)
published_claim = models.ForeignKey(
Claim,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='origin_drafts',
verbose_name='Veröffentlichte Behauptung',
)
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
class Meta:
verbose_name = 'Fakten-Entwurf'
verbose_name_plural = 'Fakten-Entwürfe'
ordering = ['-created_at']
def __str__(self):
return self.title or f'Entwurf #{self.pk}'
def get_admin_review_url(self):
return reverse('admin:claims_claimdraft_review', args=[self.pk])
def get_admin_assistant_url(self):
return reverse('admin:claims_claimdraft_assistant')
def add_message(self, role, content):
from django.utils import timezone
messages = list(self.conversation or [])
messages.append({
'role': role,
'content': content,
'at': timezone.now().isoformat(),
})
self.conversation = messages