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
+91
View File
@@ -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')
@@ -0,0 +1,34 @@
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
<div class="box">
<h4 class="title is-6">Parsed suggestions</h4>
{% if suggestions %}
<form hx-post="{% url 'cards:import_apply_bulk' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %}
<textarea name="data" style="display:none">{{ suggestions_json }}</textarea>
<table class="table is-fullwidth is-striped">
<thead>
<tr><th>Match</th><th>Name / PK</th><th>Speed</th><th>Weight</th><th>Height</th><th>Intelligence</th><th>Facts</th></tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}—{% endif %}</td>
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td>{{ s.speed_kmh }}</td>
<td>{{ s.weight_kg }}</td>
<td>{{ s.height_m }}</td>
<td>{{ s.intelligence_score }}</td>
<td>{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="margin-top:0.5rem">
<button class="button is-primary" type="submit">Apply all suggestions</button>
<button class="button" type="button" onclick="document.getElementById('import-preview').innerHTML=''">Cancel</button>
</div>
</form>
{% else %}
<div class="notification is-warning">No suggestions parsed from the pasted data.</div>
{% endif %}
</div>
+1
View File
@@ -52,6 +52,7 @@
<div class="buttons" style="margin-top:0.6rem"> <div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
<button class="button is-small is-primary" hx-get="{% url 'cards:preview' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML">Preview</button> <button class="button is-small is-primary" hx-get="{% url 'cards:preview' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML">Preview</button>
<button class="button is-small" hx-post="{% url 'cards:import' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Import Facts</button>
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">Delete</button> <button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">Delete</button>
</div> </div>
</div> </div>
@@ -0,0 +1,49 @@
{% comment %} HTMX fragment showing LLM suggestion and form to accept/apply it. {% endcomment %}
<div id="import-preview" class="box">
{% if error %}
<div class="notification is-danger">Error contacting LLM: {{ error }}</div>
{% else %}
<h4 class="title is-6">Import suggestions for {{ card.name }}</h4>
<form hx-post="{% url 'cards:import_apply' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">
<table class="table is-fullwidth">
<tr><th>Field</th><th>Suggested</th></tr>
<tr>
<td>Speed (km/h)</td>
<td><input name="speed_kmh" value="{{ suggestion.speed_kmh|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Weight (kg)</td>
<td><input name="weight_kg" value="{{ suggestion.weight_kg|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Height (m)</td>
<td><input name="height_m" value="{{ suggestion.height_m|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Intelligence (1-5 or 0-100)</td>
<td><input name="intelligence_score" value="{{ suggestion.intelligence_score|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Facts</td>
<td>
<textarea name="facts" class="textarea" rows="6">{% if suggestion.facts %}{{ suggestion.facts|join:"\n" }}{% endif %}</textarea>
</td>
</tr>
</table>
{% if suggestion.sources %}
<div class="content">
<strong>Sources:</strong>
<ul>
{% for s in suggestion.sources %}
<li>{% if s.url %}<a href="{{ s.url }}" target="_blank">{{ s.title|default:s.url }}</a>{% else %}{{ s.title }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div style="margin-top:0.5rem">
<button class="button is-primary" type="submit">Apply</button>
<button class="button" type="button" onclick="document.getElementById('htmx-form').innerHTML=''">Cancel</button>
</div>
</form>
{% endif %}
</div>
+46
View File
@@ -0,0 +1,46 @@
{% extends 'cards/base.html' %}
{% block content %}
<section class="section">
<div class="level">
<div class="level-left">
<h2 class="title is-4">Export table of all dinosaurs</h2>
</div>
<div class="level-right">
<div class="level-item">
<a class="button is-light" href="{% url 'cards:list' %}">Back to list</a>
</div>
</div>
</div>
<p>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: <code>pk</code> or <code>name</code>, <code>speed_kmh</code>, <code>weight_kg</code>, <code>height_m</code>, <code>intelligence</code>, <code>facts</code> (array or newline-separated string). When you get the model response, paste that JSON into the box below and click <strong>Preview pasted results</strong>.</p>
<div class="field">
<label class="label">Export (JSON)</label>
<div class="control">
<textarea id="export-table-text" class="textarea" rows="10">{{ json_text }}</textarea>
</div>
<div style="margin-top:0.5rem">
<button class="button" onclick="(function(){var t=document.getElementById('export-table-text');t.select();try{navigator.clipboard.writeText(t.value);}catch(e){} })()">Copy JSON</button>
</div>
</div>
<hr />
<h3 class="title is-6">Paste LLM output</h3>
<form method="post" hx-post="{% url 'cards:import_paste' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea name="pasted" class="textarea" rows="12" placeholder='Paste the LLM response here (JSON array, NDJSON, or CSV/TSV)'></textarea>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Preview pasted results</button>
</div>
</div>
</form>
<div id="import-preview"></div>
</section>
{% endblock %}
+3
View File
@@ -12,6 +12,9 @@
<div class="level-item"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a> <a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:export_table' %}">Export table</a>
</div>
</div> </div>
</div> </div>
+5
View File
@@ -8,6 +8,8 @@ urlpatterns = [
path('create/', views.card_create, name='create'), path('create/', views.card_create, name='create'),
path('<int:pk>/edit/', views.card_edit, name='edit'), path('<int:pk>/edit/', views.card_edit, name='edit'),
path('<int:pk>/delete/', views.card_delete, name='delete'), path('<int:pk>/delete/', views.card_delete, name='delete'),
path('<int:pk>/import/', views.import_facts_for_card, name='import'),
path('<int:pk>/import/apply/', views.apply_import_for_card, name='import_apply'),
path('preview/<int:pk>/', views.preview_card, name='preview'), path('preview/<int:pk>/', views.preview_card, name='preview'),
path('backgrounds/', views.background_list, name='backgrounds'), path('backgrounds/', views.background_list, name='backgrounds'),
path('backgrounds/create/', views.background_create, name='background_create'), path('backgrounds/create/', views.background_create, name='background_create'),
@@ -16,4 +18,7 @@ urlpatterns = [
path('overview/', views.cards_overview, name='overview'), path('overview/', views.cards_overview, name='overview'),
path('overview/bulk-update/', views.cards_bulk_update, name='overview_bulk_update'), 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('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'),
] ]
+234
View File
@@ -6,6 +6,11 @@ from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm from .forms import DinosaurForm
from .forms import BackgroundImageForm 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): def is_htmx(request):
@@ -215,6 +220,68 @@ def preview_card(request, pk):
return render(request, 'cards/preview.html', {'card': card, 'background': background}) 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): def cards_overview(request):
"""Overview page showing all cards in a table for quick edits.""" """Overview page showing all cards in a table for quick edits."""
backgrounds = BackgroundImage.objects.all() backgrounds = BackgroundImage.objects.all()
@@ -266,3 +333,170 @@ def cards_bulk_create_images(request):
created.append(d) created.append(d)
# Redirect back to overview # Redirect back to overview
return redirect('cards: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')
BIN
View File
Binary file not shown.
+1
View File
@@ -1,2 +1,3 @@
django django
Pillow Pillow
openai