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>
This commit is contained in:
Pirmin Hinderling (fedora)
2026-07-07 22:50:53 +02:00
parent 28aab3b16a
commit 16428f2c16
19 changed files with 237 additions and 101 deletions
+17 -17
View File
@@ -4,12 +4,12 @@ from django.utils.html import format_html
from .admin_forms import (
ClaimAdminForm,
CounterArgumentInlineForm,
PossibleAnswerInlineForm,
EvidenceFileInlineForm,
SourceInlineForm,
)
from .admin_views import AssistantView, ReviewDraftView
from .models import Category, Claim, ClaimDraft, CounterArgument, EvidenceFile, Source, Tag
from .models import Category, Claim, ClaimDraft, PossibleAnswer, EvidenceFile, Source, Tag
class FaktenAdminMixin:
@@ -59,14 +59,14 @@ class SourceInline(FaktenAdminMixin, admin.StackedInline):
verbose_name_plural = 'Quellen'
class CounterArgumentInline(FaktenAdminMixin, admin.StackedInline):
model = CounterArgument
form = CounterArgumentInlineForm
class PossibleAnswerInline(FaktenAdminMixin, admin.StackedInline):
model = PossibleAnswer
form = PossibleAnswerInlineForm
extra = 1
fields = ('argument', 'response')
fields = ('answer',)
classes = ('collapse-open',)
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
verbose_name = 'Mögliche Antwort'
verbose_name_plural = 'Mögliche Antworten'
@admin.register(Tag)
@@ -115,7 +115,7 @@ class ClaimAdmin(FaktenAdminMixin, admin.ModelAdmin):
autocomplete_fields = ('category',)
save_on_top = True
date_hierarchy = 'updated_at'
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
inlines = [EvidenceFileInline, SourceInline, PossibleAnswerInline]
readonly_fields = ('public_link', 'created_at', 'updated_at')
fieldsets = (
('Einordnung', {
@@ -175,16 +175,16 @@ class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
search_fields = ('title', 'organization', 'description')
@admin.register(CounterArgument)
class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = CounterArgumentInlineForm
list_display = ('argument_preview', 'claim')
@admin.register(PossibleAnswer)
class PossibleAnswerAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = PossibleAnswerInlineForm
list_display = ('answer_preview', 'claim')
list_filter = ('claim__category',)
search_fields = ('argument', 'response')
search_fields = ('answer', 'claim__title')
@admin.display(description='Gegenargument')
def argument_preview(self, obj):
return obj.argument[:80]
@admin.display(description='Antwort')
def answer_preview(self, obj):
return obj.answer[:80]
@admin.register(ClaimDraft)
+4 -5
View File
@@ -1,6 +1,6 @@
from django import forms
from .models import Claim, CounterArgument, EvidenceFile, Source
from .models import Claim, PossibleAnswer, EvidenceFile, Source
class ClaimAdminForm(forms.ModelForm):
@@ -26,13 +26,12 @@ class SourceInlineForm(forms.ModelForm):
}
class CounterArgumentInlineForm(forms.ModelForm):
class PossibleAnswerInlineForm(forms.ModelForm):
class Meta:
model = CounterArgument
model = PossibleAnswer
fields = '__all__'
widgets = {
'argument': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}),
'response': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
'answer': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
}
+9 -9
View File
@@ -53,8 +53,8 @@ class ReviewDraftForm(forms.ModelForm):
required=False,
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
)
counter_arguments_json = forms.CharField(
label='Gegenargumente (JSON)',
possible_answers_json = forms.CharField(
label='Mögliche Antworten (JSON)',
required=False,
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
)
@@ -95,8 +95,8 @@ class ReviewDraftForm(forms.ModelForm):
self.fields['sources_json'].initial = json.dumps(
instance.sources or [], ensure_ascii=False, indent=2
)
self.fields['counter_arguments_json'].initial = json.dumps(
instance.counter_arguments or [], ensure_ascii=False, indent=2
self.fields['possible_answers_json'].initial = json.dumps(
instance.possible_answers or [], ensure_ascii=False, indent=2
)
if instance.category_slug:
category = Category.objects.filter(slug=instance.category_slug).first()
@@ -119,23 +119,23 @@ class ReviewDraftForm(forms.ModelForm):
raise forms.ValidationError('Quellen müssen eine JSON-Liste sein.')
return data
def clean_counter_arguments_json(self):
raw = (self.cleaned_data.get('counter_arguments_json') or '').strip()
def clean_possible_answers_json(self):
raw = (self.cleaned_data.get('possible_answers_json') or '').strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise forms.ValidationError('Ungültiges JSON für Gegenargumente.') from exc
raise forms.ValidationError('Ungültiges JSON für mögliche Antworten.') from exc
if not isinstance(data, list):
raise forms.ValidationError('Gegenargumente müssen eine JSON-Liste sein.')
raise forms.ValidationError('Mögliche Antworten müssen eine JSON-Liste sein.')
return data
def save(self, commit=True):
draft = super().save(commit=False)
draft.tags = self.cleaned_data['tags_text']
draft.sources = self.cleaned_data['sources_json']
draft.counter_arguments = self.cleaned_data['counter_arguments_json']
draft.possible_answers = self.cleaned_data['possible_answers_json']
category = self.cleaned_data.get('category_choice')
if category:
draft.category_slug = category.slug
+9 -13
View File
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from claims.models import Category, Claim, CounterArgument, Source, Tag
from claims.models import Category, Claim, PossibleAnswer, Source, Tag
class Command(BaseCommand):
@@ -96,10 +96,9 @@ class Command(BaseCommand):
url='https://www.rki.de/DE/Content/InfAZ/N/Neuartiges_Coronavirus/Impfen/Impfen_node.html',
description='Offizielle Informationen des RKI zu Wirkweise und Sicherheit der Impfstoffe.',
)
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Die mRNA ist künstlich und kann deshalb die Zelle dauerhaft verändern.',
response='Künstliche mRNA unterscheidet sich in der Wirkung nicht grundlegend von natürlicher mRNA im Körper. Beide werden abgebaut. Es gibt keinen Mechanismus, durch den Impf-mRNA in die DNA integriert wird.',
answer='Künstliche mRNA unterscheidet sich in der Wirkung nicht grundlegend von natürlicher mRNA im Körper. Beide werden abgebaut. Es gibt keinen Mechanismus, durch den Impf-mRNA in die DNA integriert wird.',
)
c2 = Claim.objects.create(
@@ -142,10 +141,9 @@ class Command(BaseCommand):
url='https://www.ipcc.ch/report/ar6/syr/',
description='Aktueller Synthesebericht des Weltklimarats.',
)
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Früher gab es auch warme und kalte Perioden ohne Industrie.',
response='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
answer='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
)
Claim.objects.create(
@@ -173,10 +171,9 @@ class Command(BaseCommand):
detailed_explanation='Studien zeigen Szenarien mit nahezu 100 % erneuerbarem Strom, erfordern aber Infrastruktur und Sektorkopplung.',
)
c1.tags.add(tags['erneuerbare'])
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Wenn die Sonne nicht scheint und der Wind nicht weht, fällt das Netz aus.',
response='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
answer='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
)
Claim.objects.create(
@@ -255,8 +252,7 @@ class Command(BaseCommand):
detailed_explanation='Peer Review filtert Fehler, garantiert aber keine Wahrheit. Metaanalysen fassen viele Studien zusammen.',
)
c1.tags.add(tags['forschung'])
CounterArgument.objects.create(
PossibleAnswer.objects.create(
claim=c1,
argument='Wenn eine Studie in einer renommierten Zeitschrift steht, ist sie bewiesen.',
response='Renommierte Journale publizieren auch widerlegte Ergebnisse. Die Qualität hängt von Replikation und Methodik ab.',
answer='Renommierte Journale publizieren auch widerlegte Ergebnisse. Die Qualität hängt von Replikation und Methodik ab.',
)
@@ -0,0 +1,69 @@
from django.db import migrations, models
def migrate_possible_answer_text(apps, schema_editor):
PossibleAnswer = apps.get_model('claims', 'PossibleAnswer')
for obj in PossibleAnswer.objects.all():
obj.answer = (obj.response or obj.argument or '').strip()
obj.save(update_fields=['answer'])
def migrate_draft_json(apps, schema_editor):
ClaimDraft = apps.get_model('claims', 'ClaimDraft')
for draft in ClaimDraft.objects.all():
answers = []
for item in draft.counter_arguments 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})
draft.possible_answers = answers
draft.save(update_fields=['possible_answers'])
class Migration(migrations.Migration):
dependencies = [
('claims', '0006_remove_claim_evidence_level_and_more'),
]
operations = [
migrations.RenameModel(
old_name='CounterArgument',
new_name='PossibleAnswer',
),
migrations.AddField(
model_name='possibleanswer',
name='answer',
field=models.TextField(blank=True, default='', verbose_name='Antwort'),
),
migrations.RunPython(migrate_possible_answer_text, migrations.RunPython.noop),
migrations.RemoveField(
model_name='possibleanswer',
name='argument',
),
migrations.RemoveField(
model_name='possibleanswer',
name='response',
),
migrations.AlterModelOptions(
name='possibleanswer',
options={
'ordering': ['id'],
'verbose_name': 'Mögliche Antwort',
'verbose_name_plural': 'Mögliche Antworten',
},
),
migrations.AddField(
model_name='claimdraft',
name='possible_answers',
field=models.JSONField(blank=True, default=list, verbose_name='Mögliche Antworten'),
),
migrations.RunPython(migrate_draft_json, migrations.RunPython.noop),
migrations.RemoveField(
model_name='claimdraft',
name='counter_arguments',
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.15 on 2026-07-07 20:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0007_possible_answers'),
]
operations = [
migrations.AlterField(
model_name='possibleanswer',
name='answer',
field=models.TextField(verbose_name='Antwort'),
),
migrations.AlterField(
model_name='possibleanswer',
name='claim',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='possible_answers', to='claims.claim', verbose_name='Behauptung'),
),
]
+7 -8
View File
@@ -205,23 +205,22 @@ class Source(models.Model):
return self.title
class CounterArgument(models.Model):
class PossibleAnswer(models.Model):
claim = models.ForeignKey(
Claim,
on_delete=models.CASCADE,
related_name='counter_arguments',
related_name='possible_answers',
verbose_name='Behauptung',
)
argument = models.TextField('Gegenargument')
response = models.TextField('Antwort')
answer = models.TextField('Antwort')
class Meta:
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
verbose_name = 'Mögliche Antwort'
verbose_name_plural = 'Mögliche Antworten'
ordering = ['id']
def __str__(self):
return self.argument[:80]
return self.answer[:80]
def draft_upload_path(instance, filename):
@@ -286,7 +285,7 @@ class ClaimDraft(models.Model):
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)
possible_answers = models.JSONField('Mögliche Antworten', 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)
+18 -4
View File
@@ -20,6 +20,7 @@ Regeln:
- 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:
{
@@ -46,10 +47,9 @@ Antworte ausschließlich als gültiges JSON mit dieser Struktur:
"description": "Kurzbeschreibung"
}
],
"counter_arguments": [
"possible_answers": [
{
"argument": "Häufiges Gegenargument",
"response": "Sachliche Antwort"
"answer": "Kurze, sachliche Antwort auf die Behauptung für die Diskussion"
}
],
"assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)"
@@ -229,6 +229,18 @@ def generate_draft_payload(
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 {}
@@ -245,7 +257,9 @@ def apply_payload_to_draft(draft, payload):
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.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)
+8 -6
View File
@@ -4,7 +4,7 @@ import uuid
from django.db import transaction
from django.utils.text import slugify
from claims.models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
from claims.models import Category, Claim, EvidenceFile, PossibleAnswer, Source, Tag
class DraftPublishError(Exception):
@@ -72,11 +72,13 @@ def publish_draft(draft):
description=source.get('description', ''),
)
for counter in draft.counter_arguments or []:
argument = (counter.get('argument') or '').strip()
response = (counter.get('response') or '').strip()
if argument and response:
CounterArgument.objects.create(claim=claim, argument=argument, response=response)
for item in draft.possible_answers or []:
if isinstance(item, str):
answer = item.strip()
else:
answer = (item.get('answer') or item.get('response') or '').strip()
if answer:
PossibleAnswer.objects.create(claim=claim, answer=answer)
if draft.uploaded_file:
_attach_uploaded_file(draft, claim)
+1 -1
View File
@@ -59,7 +59,7 @@ def category_detail(request, slug):
def claim_detail(request, slug):
claim = get_object_or_404(
Claim.objects.select_related('category').prefetch_related(
'tags', 'sources', 'counter_arguments', 'evidence_files'
'tags', 'sources', 'possible_answers', 'evidence_files'
),
slug=slug,
)