Add LLM question import functionality and enhance question model with new fields

This commit is contained in:
Ross
2025-10-20 10:43:12 +01:00
parent 4986c2faa9
commit 93f0bb8927
10 changed files with 565 additions and 3 deletions
+21
View File
@@ -3,6 +3,7 @@ from django.forms import (
ModelForm,
ModelMultipleChoiceField,
ModelChoiceField,
CheckboxSelectMultiple,
ChoiceField,
CharField,
)
@@ -22,6 +23,8 @@ from django.forms.widgets import RadioSelect, TextInput, Textarea
from tinymce.widgets import TinyMCE
from dal import autocomplete
class UserAnswerForm(ModelForm):
class Meta:
@@ -140,6 +143,11 @@ class QuestionForm(ModelForm):
"e_answer",
"e_feedback",
"best_answer",
"finding",
"structure",
"condition",
"presentation",
"subspecialty",
]
widgets = {
@@ -158,6 +166,19 @@ class QuestionForm(ModelForm):
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"structure": autocomplete.ModelSelect2Multiple(
url="atlas:structure-autocomplete"
),
"finding": autocomplete.ModelSelect2Multiple(
url="atlas:finding-autocomplete"
),
"condition": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
"presentation": autocomplete.ModelSelect2Multiple(
url="atlas:presentation-autocomplete"
),
"subspecialty": CheckboxSelectMultiple(),
}
#widgets = {
@@ -0,0 +1,39 @@
# Generated by Django 5.1.4 on 2025-10-20 08:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0079_casecollection_prerequisites'),
('sbas', '0017_exam_results_supervisor_visible'),
]
operations = [
migrations.AddField(
model_name='question',
name='condition',
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.condition'),
),
migrations.AddField(
model_name='question',
name='finding',
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.finding'),
),
migrations.AddField(
model_name='question',
name='presentation',
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.presentation'),
),
migrations.AddField(
model_name='question',
name='structure',
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.structure'),
),
migrations.AddField(
model_name='question',
name='subspecialty',
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.subspecialty'),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-20 09:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sbas', '0018_question_condition_question_finding_and_more'),
]
operations = [
migrations.AddField(
model_name='question',
name='title',
field=models.CharField(blank=True, help_text='Short title for question', max_length=200, null=True),
),
]
+9
View File
@@ -15,6 +15,8 @@ import reversion
from django.contrib.contenttypes.fields import GenericRelation
from atlas.models import Finding, Structure, Condition, Subspecialty, Presentation
class Category(models.Model):
category = models.CharField(max_length=200)
@@ -24,6 +26,7 @@ class Category(models.Model):
class Question(QuestionBase):
title = models.CharField(max_length=200, help_text="Short title for question", blank=True, null=True)
stem = models.TextField(
blank=False,
help_text="Stem of the question",
@@ -91,6 +94,12 @@ class Question(QuestionBase):
Category, on_delete=models.SET_NULL, null=True, blank=True
)
finding = models.ManyToManyField(Finding, blank=True, related_name="sbas_questions")
structure = models.ManyToManyField(Structure, blank=True, related_name="sbas_questions")
condition = models.ManyToManyField(Condition, blank=True, related_name="sbas_questions")
presentation = models.ManyToManyField(Presentation, blank=True, related_name="sbas_questions")
subspecialty = models.ManyToManyField(Subspecialty, blank=True, related_name="sbas_questions")
#notes = GenericRelation(QuestionNote)
def __str__(self):
+7
View File
@@ -27,6 +27,13 @@
<li><a class="dropdown-item" href="{% url 'sbas:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
<li class="nav-item-dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-gear"></i> LLM</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'sbas:llm_prompt_view' %}" title="Generate questions using LLM"><i class="bi bi-robot"></i> LLM prompt</a></li>
<li><a class="dropdown-item" href="{% url 'sbas:import_llm_questions' %}" title="Import questions using LLM"><i class="bi bi-upload"></i> Import Questions</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
@@ -0,0 +1,11 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h1>Import LLM Questions</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button class="btn btn-primary" type="submit">Upload and Import</button>
</form>
<p>Upload a single JSON array, a single object, or JSONL (one JSON object per line) matching the agreed schema.</p>
{% endblock %}
+133
View File
@@ -0,0 +1,133 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h2>LLM Prompts</h2>
<p>
Below are the prompts used for interacting with large language models (LLMs) within the SBA system.
</p>
<h3>Question Generation Prompt</h3>
<pre>
You are an expert radiologist tasked with generating high-quality multiple-choice questions for medical imaging education. Each question should consist of a clinical vignette, an image description, and five answer choices (A through E), with one correct answer and four plausible distractors.
Please adhere to the following guidelines when creating each question:
1. Clinical Vignette: Provide a brief clinical scenario that sets the context for the question including relevant patient history and clinical findings.
2. Make sure the question focuses on key radiological concepts, findings, or diagnoses.
3. Answer Choices: Create five answer options. Ensure that one is the correct answer and the others are plausible distractors.
4. Explanation: Provide a concise explanation for why the correct answer is right and why the distractors are incorrect.
5. Difficulty Level: Aim for a moderate difficulty level suitable for training radiologists.
6. Clarity and Precision: Use clear and precise language, avoiding ambiguity.
7. Relevance: Ensure that the question is relevant to current radiological practices and guidelines.
8. Questions should feature metadata tags for finding, structure, condition, presentation, and subspecialty.
9. Each question output should be a json object with the following schema:
10. For article sources pleese reference the article doi within the explanation field in the format [doi:10.xxxx/xxxxx].
11. For statdx sources please reference the article in the format [statdx:article_id].
12. References should be included inline within the explanation field.
13. Questions need to have a radiology focus, please avoid questions that are purely clinical or biochemical without imaging relevance.
JSON Schema (draft-07 compatible)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SBA Question",
"description": "Schema for importing LLM-generated questions into the sbas.Question model.",
"type": "object",
"additionalProperties": false,
"required": [
"title",
"stem",
"a_answer",
"b_answer",
"c_answer",
"d_answer",
"e_answer",
"best_answer",
"category"
],
"properties": {
"title": {
"type": "string",
"description": "Short title for the question."
},
"stem": {
"type": "string",
"description": "HTML or plain text question stem. Trim leading/trailing whitespace before saving."
},
"a_answer": { "type": "string" },
"a_feedback": { "type": "string", "default": "" },
"b_answer": { "type": "string" },
"b_feedback": { "type": "string", "default": "" },
"c_answer": { "type": "string" },
"c_feedback": { "type": "string", "default": "" },
"d_answer": { "type": "string" },
"d_feedback": { "type": "string", "default": "" },
"e_answer": { "type": "string" },
"e_feedback": { "type": "string", "default": "" },
"feedback": {
"type": "string",
"description": "General question feedback (may include HTML)."
},
"best_answer": {
"type": "string",
"enum": ["a", "b", "c", "d", "e"],
"description": "One of 'a','b','c','d','e'."
},
"category": {
"oneOf": [
{ "type": "string", "description": "Category name ('Central Nervous and Head & Neck', 'Paediatric', 'Genito-urinary, Adrenal, Obstetrics & Gynaecology and Breast', 'Gastro-intestinal', 'Musculoskeletal and Trauma', 'Cardiothoracic and Vascular')" }
]
},
"finding": {
"type": "array",
"description": "References to Finding objects. Prefer numeric IDs; names allowed if importer resolves them.",
"items": {
"oneOf": [
{ "type": "integer" },
{ "type": "string" }
]
},
"uniqueItems": true
},
"structure": {
"type": "array",
"items": {
"oneOf": [
{ "type": "integer" },
{ "type": "string" }
]
},
"uniqueItems": true
},
"condition": {
"type": "array",
"items": {
"oneOf": [
{ "type": "integer" },
{ "type": "string" }
]
},
"uniqueItems": true
},
"presentation": {
"type": "array",
"items": {
"oneOf": [
{ "type": "integer" },
{ "type": "string" }
]
},
"uniqueItems": true
},
"subspecialty": {
"type": "array",
"items": {
"oneOf": [
{ "type": "integer" },
{ "type": "string", "description": "Subspecialty name (e.g. 'Haematology & Oncology', 'Vascular', 'Uroradiology', 'Thoracic', 'Paediatric', 'Obstetric and Gynaecological', 'Neuroradiology', 'Musculoskeletal', 'Head and Neck', 'Gastrointestinal and hepatobiliary', 'Cardiac', 'Breast')" }
]
},
"uniqueItems": true
},
}
}
</pre>
+67 -3
View File
@@ -29,14 +29,78 @@
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">{{ exam }}</a>
{% endfor %}
</div>
<div>
Findings: {% if question.finding.exists %}
<ul>
{% for f in question.finding.all %}
<li>{{ f.get_link|safe }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</div>
<div>
Structures: {% if question.structure.exists %}
<ul>
{% for s in question.structure.all %}
<li>{{ s.get_link|safe }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</div>
<div>
Conditions: {% if question.condition.exists %}
<ul>
{% for c in question.condition.all %}
<li>{{ c.get_link|safe }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</div>
<div>
Presentations: {% if question.presentation.exists %}
<ul>
{% for p in question.presentation.all %}
<li>{{ p.get_link|safe }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</div>
<div>
Subspecialties: {% if question.subspecialty.exists %}
<ul>
{% for ss in question.subspecialty.all %}
<li>{{ ss.get_link|safe }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</div>
<div>
Category: {{ question.category }}
</div>
<div>
Author: {% for user in question.author.all %}
{{ author }},
{% endfor %}
Author(s):
{% if question.author.exists %}
{% for u in question.author.all %}
{{ u.get_full_name|default:u.username }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
Unknown
{% endif %}
</div>
<div>
Feedback: {{ question.feedback|linebreaks }}
</div>
{% include 'question_notes.html' %}
+12
View File
@@ -82,6 +82,18 @@ urlpatterns = [
views.UserAnswerDelete.as_view(),
name="user_answer_delete",
),
path(
"llm_prompts/",
views.llm_prompt_view,
name="llm_prompt_view",
)
,
path(
"import_llm_questions/",
views.import_llm_questions,
name="import_llm_questions",
),
#path(
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
# views.exam_scores_cid_user,
+248
View File
@@ -29,6 +29,250 @@ from django.http import Http404, HttpResponseBadRequest, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from .models import Question, Category, Exam, UserAnswer
from django.views.decorators.http import require_http_methods
from django.db import transaction
import re
import logging
logger = logging.getLogger(__name__)
from loguru import logger
try:
import jsonschema
except Exception:
jsonschema = None
class LLMImportForm(forms.Form):
file = forms.FileField(required=False, help_text="Upload a JSON or JSONL file matching the SBA import schema")
raw = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 10, "cols": 80}),
help_text="Or paste JSON, JSONL (newline-delimited), or multiple JSON documents separated by blank lines",
)
def _resolve_m2m(model_class, value):
"""Resolve an item which may be an int (pk) or a string (name/slug).
Returns a queryset or empty list of matching objects.
"""
if value is None:
return []
if isinstance(value, int):
try:
return [model_class.objects.get(pk=value)]
except model_class.DoesNotExist:
return []
if isinstance(value, str):
# try exact name field, then case-insensitive contains
qs = model_class.objects.filter(name__iexact=value)
if not qs.exists():
qs = model_class.objects.filter(name__icontains=value)
return list(qs[:5])
return []
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def import_llm_questions(request):
"""Upload a JSON/JSONL file of LLM-produced questions and import them.
Only superusers may use this view. The view validates each object against
the agreed JSON schema (draft-07) and attempts to resolve M2M references
by numeric id or by name. Returns a JSON report of created items and errors.
"""
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SBA Question",
"type": "object",
"additionalProperties": False,
"required": [
"title",
"stem",
"a_answer",
"b_answer",
"c_answer",
"d_answer",
"e_answer",
"best_answer",
"category",
],
"properties": {
"title": {"type": "string"},
"stem": {"type": "string"},
"a_answer": {"type": "string"},
"a_feedback": {"type": "string"},
"b_answer": {"type": "string"},
"b_feedback": {"type": "string"},
"c_answer": {"type": "string"},
"c_feedback": {"type": "string"},
"d_answer": {"type": "string"},
"d_feedback": {"type": "string"},
"e_answer": {"type": "string"},
"e_feedback": {"type": "string"},
"feedback": {"type": "string"},
"best_answer": {"type": "string", "enum": ["a", "b", "c", "d", "e"]},
"category": {"oneOf": [{"type": "string"}]},
"finding": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
"structure": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
"condition": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
"presentation": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
"subspecialty": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
},
}
if request.method == "GET":
form = LLMImportForm()
return render(request, "sbas/import_llm_questions.html", {"form": form})
# POST
form = LLMImportForm(request.POST, request.FILES)
if not form.is_valid():
return JsonResponse({"ok": False, "errors": ["No file uploaded or invalid form"]}, status=400)
raw_text = None
if form.cleaned_data.get("raw"):
raw_text = form.cleaned_data.get("raw")
elif form.cleaned_data.get("file"):
f = form.cleaned_data["file"]
raw_text = f.read().decode("utf-8")
else:
return JsonResponse({"ok": False, "errors": ["No file uploaded or text provided"]}, status=400)
# support: single JSON object, JSON array, JSONL (one JSON object per line),
# or multiple JSON documents separated by one or more blank lines.
candidates = []
# sanitize raw_text by escaping backslashes that are not part of a valid
# JSON escape sequence. This handles inputs containing LaTeX-style
# sequences such as "\ge" which are invalid JSON (Invalid \escape).
def _sanitize_backslashes(s: str):
# valid escapes after backslash in JSON: \" \\ \/ \b \f \n \r \t and \uXXXX
# This regex finds backslashes not followed by ", \\ , /, b, f, n, r, t, or u
pattern = re.compile(r"\\(?!(?:[\"\\/bfnrtu]))")
new_s, n = pattern.subn(r"\\\\", s)
return new_s, n
raw_text_stripped = raw_text.strip()
raw_text, sanitized_count = _sanitize_backslashes(raw_text_stripped)
logger.debug("import_llm_questions: sanitized input length %d (escaped %d backslashes)", len(raw_text), sanitized_count)
# Try whole-text JSON first (object or array)
parse_error = None
try:
parsed = json.loads(raw_text)
if isinstance(parsed, list):
candidates = parsed
elif isinstance(parsed, dict):
candidates = [parsed]
except Exception as e_outer:
parse_error = e_outer
# Try JSONL: each non-empty line is a JSON object
for i, line in enumerate(raw_text.splitlines()):
line = line.strip()
if not line:
continue
try:
candidates.append(json.loads(line))
except Exception:
# not JSONL or some lines are multi-line JSON; fallthrough
candidates = []
break
# If JSONL didn't produce candidates, try splitting by blank lines
if not candidates:
blocks = [b.strip() for b in re.split(r"\n\s*\n", raw_text) if b.strip()]
if len(blocks) > 1:
for i, block in enumerate(blocks):
try:
candidates.append(json.loads(block))
except Exception as e_block:
return JsonResponse({"ok": False, "errors": [f"Invalid JSON in block {i+1}: {e_block}"]}, status=400)
if not candidates:
# If still empty, return the original parse error if present
msg = str(parse_error) if parse_error is not None else "No JSON objects found"
logger.debug(f"Failed to parse input: {msg}")
return JsonResponse({"ok": False, "errors": [f"Failed to parse input: {msg}"]}, status=400)
report = {"total": len(candidates), "created": 0, "errors": [], "sanitized_backslashes": sanitized_count}
if jsonschema is None:
return JsonResponse({"ok": False, "errors": ["jsonschema library not available in environment"]}, status=500)
validator = jsonschema.Draft7Validator(schema)
from atlas.models import Finding, Structure, Condition, Presentation, Subspecialty
for idx, payload in enumerate(candidates):
errors = []
for err in validator.iter_errors(payload):
errors.append(err.message)
if errors:
logger.debug(f"Validation errors for item {idx}: {errors}")
report["errors"].append({"index": idx, "errors": errors})
continue
# create the Question inside a transaction; partial failures should not leave half-written objects
try:
with transaction.atomic():
q = Question()
q.stem = payload.get("stem", "").strip()
# map title -> not present in model; use as stem prefix or store in feedback
title = payload.get("title")
if title:
q.stem = f"<strong>{title}</strong>\n" + q.stem
q.a_answer = payload.get("a_answer", "").strip()
q.a_feedback = payload.get("a_feedback", "")
q.b_answer = payload.get("b_answer", "").strip()
q.b_feedback = payload.get("b_feedback", "")
q.c_answer = payload.get("c_answer", "").strip()
q.c_feedback = payload.get("c_feedback", "")
q.d_answer = payload.get("d_answer", "").strip()
q.d_feedback = payload.get("d_feedback", "")
q.e_answer = payload.get("e_answer", "").strip()
q.e_feedback = payload.get("e_feedback", "")
q.feedback = payload.get("feedback", "")
q.best_answer = payload.get("best_answer")
# category resolve by name
cat_val = payload.get("category")
if isinstance(cat_val, str):
cat_obj, created = Category.objects.get_or_create(category=cat_val)
q.category = cat_obj
q.save()
# Resolve M2Ms
for key, model_cls in (
("finding", Finding),
("structure", Structure),
("condition", Condition),
("presentation", Presentation),
("subspecialty", Subspecialty),
):
vals = payload.get(key) or []
resolved = []
for v in vals:
if isinstance(v, int):
try:
resolved.append(model_cls.objects.get(pk=v))
except model_cls.DoesNotExist:
# skip unknown
continue
elif isinstance(v, str):
qs = model_cls.objects.filter(name__iexact=v)
if not qs.exists():
qs = model_cls.objects.filter(name__icontains=v)
if qs.exists():
resolved.extend(list(qs[:5]))
if resolved:
getattr(q, key).add(*[o.pk for o in resolved])
report["created"] += 1
except Exception as e:
report["errors"].append({"index": idx, "errors": [str(e)]})
return JsonResponse({"ok": True, "report": report})
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
@@ -436,3 +680,7 @@ def exam_clone2(request, exam_id):
new_exam = exam.clone_model()
return redirect("sbas:exam_update", pk=new_exam.id)
def llm_prompt_view(request):
return render(request, "sbas/llm_prompt_view.html", {})