.
This commit is contained in:
@@ -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>
|
||||
@@ -52,6 +52,7 @@
|
||||
<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-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>
|
||||
</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>
|
||||
@@ -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 %}
|
||||
@@ -12,6 +12,9 @@
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:export_table' %}">Export table</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ urlpatterns = [
|
||||
path('create/', views.card_create, name='create'),
|
||||
path('<int:pk>/edit/', views.card_edit, name='edit'),
|
||||
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('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'),
|
||||
]
|
||||
|
||||
+234
@@ -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')
|
||||
|
||||
BIN
Binary file not shown.
+2
-1
@@ -1,2 +1,3 @@
|
||||
django
|
||||
Pillow
|
||||
Pillow
|
||||
openai
|
||||
Reference in New Issue
Block a user