This commit is contained in:
Ross
2025-12-05 22:25:29 +00:00
parent 5de97c5ee0
commit ceea07d0b3
10 changed files with 465 additions and 1 deletions
+234
View File
@@ -6,6 +6,11 @@ from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm
from .forms import BackgroundImageForm
from .llm import fetch_dinosaur_structured
from decimal import Decimal
import json
import csv
from io import StringIO
def is_htmx(request):
@@ -215,6 +220,68 @@ def preview_card(request, pk):
return render(request, 'cards/preview.html', {'card': card, 'background': background})
def import_facts_for_card(request, pk):
"""Call an LLM to fetch suggested facts/stats for a single card and
return an HTMX fragment showing a preview and an apply button."""
card = get_object_or_404(Dinosaur, pk=pk)
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
try:
suggestion = fetch_dinosaur_structured(card.name)
except Exception as e:
# Return a small fragment with the error so HTMX can render it in the form area
return HttpResponse(render_to_string('cards/_import_preview.html', {'card': card, 'error': str(e)}, request=request))
# normalize fields for the template
context = {'card': card, 'suggestion': suggestion}
return HttpResponse(render_to_string('cards/_import_preview.html', context, request=request))
def apply_import_for_card(request, pk):
card = get_object_or_404(Dinosaur, pk=pk)
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
# Read submitted values (form uses plain names)
def _get_float(name):
v = request.POST.get(name)
if not v:
return None
try:
return float(v)
except Exception:
return None
speed = _get_float('speed_kmh')
weight = _get_float('weight_kg')
height = _get_float('height_m')
intelligence = _get_float('intelligence_score')
facts_text = request.POST.get('facts', '')
if speed is not None:
card.speed = int(round(speed))
if weight is not None:
card.weight = int(round(weight))
if height is not None:
# store with one decimal place
card.height = Decimal(str(round(height, 1)))
if intelligence is not None:
# Accept either a 1-5 scale or 0-100. Map 1-5 -> 0-100 if needed.
if 0 <= intelligence <= 5:
card.intelligence = int(round((intelligence / 5.0) * 100))
else:
card.intelligence = int(round(max(0, min(100, intelligence))))
# Facts: accept multi-line textarea
card.facts = '\n'.join([l.strip() for l in facts_text.splitlines() if l.strip()])
card.save()
# Return refreshed card HTML to replace the item in the list and clear the form.
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
oob = '<div id="htmx-form" hx-swap-oob="true"></div>'
return HttpResponse(card_html + oob)
def cards_overview(request):
"""Overview page showing all cards in a table for quick edits."""
backgrounds = BackgroundImage.objects.all()
@@ -266,3 +333,170 @@ def cards_bulk_create_images(request):
created.append(d)
# Redirect back to overview
return redirect('cards:overview')
def export_table(request):
"""Render a page containing a TSV/CSV/Markdown table of all dinosaurs that
can be copied and pasted into an LLM web interface."""
qs = Dinosaur.objects.order_by('name')
# produce a JSON array of objects for easy copy/paste
rows = []
for d in qs:
rows.append({
'pk': d.pk,
'name': d.name,
'speed_kmh': d.speed,
'weight_kg': d.weight,
'height_m': float(d.height),
'intelligence': d.intelligence,
'facts': d.facts_list,
})
import json as _json
json_text = _json.dumps(rows, ensure_ascii=False, indent=2)
return render(request, 'cards/export_table.html', {'json_text': json_text})
def _parse_pasted_input(text):
"""Try to parse pasted LLM output into a list of suggestion dicts.
Accepts JSON array/object, newline-delimited JSON objects, or CSV/TSV with headers.
Each suggestion should contain at least a `name` or `pk` and optional fields:
`speed_kmh`, `weight_kg`, `height_m`, `intelligence_score`, `facts` (list or string).
Returns list of dicts.
"""
text = (text or '').strip()
if not text:
return []
# Try JSON
try:
data = json.loads(text)
if isinstance(data, dict):
return [data]
if isinstance(data, list):
return data
except Exception:
pass
# Try newline-delimited JSON
lines = [l.strip() for l in text.splitlines() if l.strip()]
if len(lines) > 1:
maybe = []
for l in lines:
try:
obj = json.loads(l)
maybe.append(obj)
except Exception:
maybe = []
break
if maybe:
return maybe
# Try CSV/TSV
try:
sniffer = csv.Sniffer()
dialect = sniffer.sniff(text[:1024])
f = StringIO(text)
reader = csv.DictReader(f, dialect=dialect)
out = []
for row in reader:
out.append({k.strip(): (v.strip() if v is not None else '') for k, v in row.items()})
if out:
return out
except Exception:
pass
# As last resort return single-line fallback
return [{'raw': text}]
def import_from_paste(request):
"""Endpoint that accepts pasted LLM output and returns a preview fragment."""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
text = request.POST.get('pasted', '')
parsed = _parse_pasted_input(text)
# Normalize parsed entries into suggestion dicts with named fields
suggestions = []
for item in parsed:
# lower-case keys for convenience
entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)}
s = {
'pk': entry.get('pk') or entry.get('id'),
'name': entry.get('name'),
'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'),
'weight_kg': entry.get('weight_kg') or entry.get('weight') or entry.get('mass_kg'),
'height_m': entry.get('height_m') or entry.get('height') or entry.get('height_meters'),
'intelligence_score': entry.get('intelligence_score') or entry.get('intelligence') or entry.get('iq'),
'facts': entry.get('facts') or entry.get('fact') or entry.get('raw'),
'sources': entry.get('sources') or entry.get('source')
}
# convert facts string to list if needed
if isinstance(s['facts'], str):
parts = [p.strip() for p in s['facts'].split('\n') if p.strip()]
if len(parts) == 1 and '|' in parts[0]:
parts = [p.strip() for p in parts[0].split('|') if p.strip()]
s['facts'] = parts
suggestions.append(s)
# provide a JSON-serialized version of suggestions so the preview form can post it
suggestions_json = json.dumps(suggestions, ensure_ascii=False)
return render(request, 'cards/_bulk_import_preview.html', {'suggestions': suggestions, 'suggestions_json': suggestions_json})
def apply_bulk_import(request):
"""Apply pasted suggestions. For now apply all suggestions provided in the
`data` POST field (JSON array). Returns updated list fragment or a simple message."""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
data = request.POST.get('data')
if not data:
return HttpResponseBadRequest('No data provided')
try:
parsed = json.loads(data)
except Exception:
return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0
for item in parsed:
name = item.get('name') if isinstance(item, dict) else None
pk = item.get('pk') if isinstance(item, dict) else None
d = None
if pk:
try:
d = Dinosaur.objects.get(pk=pk)
except Dinosaur.DoesNotExist:
d = None
if d is None and name:
try:
d = Dinosaur.objects.get(name__iexact=name)
except Dinosaur.DoesNotExist:
d = None
if not d:
continue
# apply numeric fields if present
def _to_float(v):
try:
return float(v)
except Exception:
return None
speed = _to_float(item.get('speed_kmh'))
weight = _to_float(item.get('weight_kg'))
height = _to_float(item.get('height_m'))
intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence'))
if speed is not None:
d.speed = int(round(speed))
if weight is not None:
d.weight = int(round(weight))
if height is not None:
d.height = Decimal(str(round(height, 1)))
if intelligence is not None:
if 0 <= intelligence <= 5:
d.intelligence = int(round((intelligence / 5.0) * 100))
else:
d.intelligence = int(round(max(0, min(100, intelligence))))
facts = item.get('facts')
if isinstance(facts, list):
d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
elif isinstance(facts, str):
d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
d.save()
applied += 1
return HttpResponse(f'Applied updates to {applied} dinosaurs')