From ceea07d0b3048ced4156f6c28ac899d11a86c5b1 Mon Sep 17 00:00:00 2001 From: Ross Date: Fri, 5 Dec 2025 22:25:29 +0000 Subject: [PATCH] . --- cards/llm.py | 91 +++++++ .../templates/cards/_bulk_import_preview.html | 34 +++ cards/templates/cards/_card_item.html | 1 + cards/templates/cards/_import_preview.html | 49 ++++ cards/templates/cards/export_table.html | 46 ++++ cards/templates/cards/list.html | 3 + cards/urls.py | 5 + cards/views.py | 234 ++++++++++++++++++ db.sqlite3 | Bin 143360 -> 151552 bytes requirements.txt | 3 +- 10 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 cards/llm.py create mode 100644 cards/templates/cards/_bulk_import_preview.html create mode 100644 cards/templates/cards/_import_preview.html create mode 100644 cards/templates/cards/export_table.html 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 4a0b5bbad95d894acdaddc51271454a291f5b921..bd1e828a6f6ae378e6b22454dcd37648d4e39e4a 100644 GIT binary patch delta 6147 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 delta 929 zcmZ9KO=uHA6vt;jlWe*>DcDMzL}N=Sf@x=Wv)OF1mPIQbTIfMUMB0RHEioV4Bt}f> zp?FqoI+8+b+nn2rQc(~Q#FK(v^x#3ICwr+EJt!jRPSUigJMefr^Luap@4q`=^~`U( z7yO(B0O&48~V^MM~CxxEs#6mFrzibg=1ypo`}xav#PCPu@dZ3k&5=|lq7*3hc@dp z7)~b&8Jt+?!r?_E>@nfqh@_oi!iJ5HZ!YPKv)>eNv_9`6JJOAI1Uv1AoHra0h;b@8P?p_7L4m#h;!D x(R1)Td<{6rGJuju5aDOQwUEq9fjK{giD;bn$Sdua%&