diff --git a/cards/llm.py b/cards/llm.py new file mode 100644 index 0000000..52d900f --- /dev/null +++ b/cards/llm.py @@ -0,0 +1,91 @@ +import os +import json +import time +from typing import Optional, Dict, Any + +try: + import openai +except Exception: + openai = None + + +OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') +DEFAULT_MODEL = os.environ.get('CARDS_LLM_MODEL', 'gpt-4o-mini') + + +PROMPT_TEMPLATE = ( + """ +You are a JSON generator. Given a dinosaur name, return EXACTLY one JSON object matching the schema below. +Only return JSON (no explanatory text). + +Schema: +{ + "name": string, + "speed_kmh": number|null, // numeric speed in km/h + "weight_kg": number|null, // numeric weight in kg + "height_m": number|null, // numeric height in meters + "intelligence_score": integer|null, // 1-5 scale or null + "facts": [string,...], + "sources": [{"title":string,"url":string},...] +} + +Rules: +- Use units: km/h, kg, m. +- If you cannot find a reliable number, use null. +- Provide up to 5 short facts. +- Include at least one source object with a URL when available. +Name: {name} +""" +) + + +def _ensure_openai(): + if openai is None: + raise RuntimeError('openai package not installed; add openai to requirements') + if not OPENAI_API_KEY: + raise RuntimeError('OPENAI_API_KEY not set in environment') + openai.api_key = OPENAI_API_KEY + + +def _extract_json(text: str) -> Optional[Dict[str, Any]]: + text = text.strip() + # try direct parse + try: + return json.loads(text) + except Exception: + pass + # heuristic: find first { ... } block + start = text.find('{') + end = text.rfind('}') + if start != -1 and end != -1 and end > start: + try: + return json.loads(text[start:end+1]) + except Exception: + return None + return None + + +def fetch_dinosaur_structured(name: str, model: Optional[str] = None, max_retries: int = 2) -> Dict[str, Any]: + """Call the configured LLM and return parsed JSON per schema. + + Raises RuntimeError on misconfiguration or ValueError if parsing fails. + """ + model = model or DEFAULT_MODEL + if openai is None: + raise RuntimeError('openai package not available') + _ensure_openai() + + prompt = PROMPT_TEMPLATE.format(name=name) + for attempt in range(max_retries + 1): + resp = openai.ChatCompletion.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0.0, + max_tokens=600, + ) + content = resp['choices'][0]['message']['content'].strip() + data = _extract_json(content) + if data: + return data + time.sleep(1 + attempt) + raise ValueError('Failed to parse LLM response as JSON') diff --git a/cards/templates/cards/_bulk_import_preview.html b/cards/templates/cards/_bulk_import_preview.html new file mode 100644 index 0000000..7210d6e --- /dev/null +++ b/cards/templates/cards/_bulk_import_preview.html @@ -0,0 +1,34 @@ +{% comment %} Preview fragment for pasted bulk import data. {% endcomment %} +
+

Parsed suggestions

+ {% if suggestions %} +
+ {% csrf_token %} + + + + + + + {% for s in suggestions %} + + + + + + + + + + {% endfor %} + +
MatchName / PKSpeedWeightHeightIntelligenceFacts
{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}—{% endif %}{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}{{ s.speed_kmh }}{{ s.weight_kg }}{{ s.height_m }}{{ s.intelligence_score }}{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}
+
+ + +
+
+ {% else %} +
No suggestions parsed from the pasted data.
+ {% endif %} +
diff --git a/cards/templates/cards/_card_item.html b/cards/templates/cards/_card_item.html index e87c476..3cca948 100644 --- a/cards/templates/cards/_card_item.html +++ b/cards/templates/cards/_card_item.html @@ -52,6 +52,7 @@
+
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 %} +
+ {% if error %} +
Error contacting LLM: {{ error }}
+ {% else %} +

Import suggestions for {{ card.name }}

+
+ + + + + + + + + + + + + + + + + + + + + + +
FieldSuggested
Speed (km/h)
Weight (kg)
Height (m)
Intelligence (1-5 or 0-100)
Facts + +
+ {% if suggestion.sources %} +
+ Sources: + +
+ {% endif %} +
+ + +
+
+ {% endif %} +
diff --git a/cards/templates/cards/export_table.html b/cards/templates/cards/export_table.html new file mode 100644 index 0000000..2d0ea27 --- /dev/null +++ b/cards/templates/cards/export_table.html @@ -0,0 +1,46 @@ +{% extends 'cards/base.html' %} + +{% block content %} +
+
+
+

Export table of all dinosaurs

+
+
+ +
+
+

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.

+ +
+ +
+ +
+
+ +
+
+ +
+ +

Paste LLM output

+
+ {% csrf_token %} +
+
+ +
+
+
+
+ +
+
+
+ +
+
+{% endblock %} diff --git a/cards/templates/cards/list.html b/cards/templates/cards/list.html index 1eb98e8..82085d4 100644 --- a/cards/templates/cards/list.html +++ b/cards/templates/cards/list.html @@ -12,6 +12,9 @@
Bulk edit
+
+ Export table +
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