diff --git a/cards/templates/cards/_import_preview.html b/cards/templates/cards/_import_preview.html
new file mode 100644
index 0000000..0473db5
--- /dev/null
+++ b/cards/templates/cards/_import_preview.html
@@ -0,0 +1,49 @@
+{% comment %} HTMX fragment showing LLM suggestion and form to accept/apply it. {% endcomment %}
+
Copy the JSON below and paste it into your web LLM interface. Ask the model to return a JSON array of objects with these fields: pk or name, speed_kmh, weight_kg, height_m, intelligence, facts (array or newline-separated string). When you get the model response, paste that JSON into the box below and click Preview pasted results.
diff --git a/cards/urls.py b/cards/urls.py
index b1642f6..042b05a 100644
--- a/cards/urls.py
+++ b/cards/urls.py
@@ -8,6 +8,8 @@ urlpatterns = [
path('create/', views.card_create, name='create'),
path('/edit/', views.card_edit, name='edit'),
path('/delete/', views.card_delete, name='delete'),
+ path('/import/', views.import_facts_for_card, name='import'),
+ path('/import/apply/', views.apply_import_for_card, name='import_apply'),
path('preview//', views.preview_card, name='preview'),
path('backgrounds/', views.background_list, name='backgrounds'),
path('backgrounds/create/', views.background_create, name='background_create'),
@@ -16,4 +18,7 @@ urlpatterns = [
path('overview/', views.cards_overview, name='overview'),
path('overview/bulk-update/', views.cards_bulk_update, name='overview_bulk_update'),
path('overview/create-images/', views.cards_bulk_create_images, name='overview_create_images'),
+ path('export-table/', views.export_table, name='export_table'),
+ path('import/paste/', views.import_from_paste, name='import_paste'),
+ path('import/apply/', views.apply_bulk_import, name='import_apply_bulk'),
]
diff --git a/cards/views.py b/cards/views.py
index bc82dbc..8d2e208 100644
--- a/cards/views.py
+++ b/cards/views.py
@@ -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 = ''
+ 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')
diff --git a/db.sqlite3 b/db.sqlite3
index 4a0b5bb..bd1e828 100644
Binary files a/db.sqlite3 and b/db.sqlite3 differ
diff --git a/requirements.txt b/requirements.txt
index a4088d3..66bdbdf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
django
-Pillow
\ No newline at end of file
+Pillow
+openai
\ No newline at end of file