diff --git a/sbas/forms.py b/sbas/forms.py
index fd4e5752..1101a18a 100755
--- a/sbas/forms.py
+++ b/sbas/forms.py
@@ -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 = {
diff --git a/sbas/migrations/0018_question_condition_question_finding_and_more.py b/sbas/migrations/0018_question_condition_question_finding_and_more.py
new file mode 100644
index 00000000..044b9bae
--- /dev/null
+++ b/sbas/migrations/0018_question_condition_question_finding_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/sbas/migrations/0019_question_title.py b/sbas/migrations/0019_question_title.py
new file mode 100644
index 00000000..2108c61f
--- /dev/null
+++ b/sbas/migrations/0019_question_title.py
@@ -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),
+ ),
+ ]
diff --git a/sbas/models.py b/sbas/models.py
index d60638b2..430bb372 100644
--- a/sbas/models.py
+++ b/sbas/models.py
@@ -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):
diff --git a/sbas/templates/sbas/base.html b/sbas/templates/sbas/base.html
index 1b601c53..2148a89d 100644
--- a/sbas/templates/sbas/base.html
+++ b/sbas/templates/sbas/base.html
@@ -27,6 +27,13 @@
Question
+
+ LLM
+
+
{% if request.user.is_superuser %}
diff --git a/sbas/templates/sbas/import_llm_questions.html b/sbas/templates/sbas/import_llm_questions.html
new file mode 100644
index 00000000..37dfab34
--- /dev/null
+++ b/sbas/templates/sbas/import_llm_questions.html
@@ -0,0 +1,11 @@
+{% extends 'sbas/base.html' %}
+
+{% block content %}
+ Import LLM Questions
+
+ Upload a single JSON array, a single object, or JSONL (one JSON object per line) matching the agreed schema.
+{% endblock %}
diff --git a/sbas/templates/sbas/llm_prompt_view.html b/sbas/templates/sbas/llm_prompt_view.html
new file mode 100644
index 00000000..a1386b59
--- /dev/null
+++ b/sbas/templates/sbas/llm_prompt_view.html
@@ -0,0 +1,133 @@
+{% extends 'sbas/base.html' %}
+
+{% block content %}
+LLM Prompts
+
+ Below are the prompts used for interacting with large language models (LLMs) within the SBA system.
+
+
+Question Generation Prompt
+
+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
+},
+}
+}
+
+
\ No newline at end of file
diff --git a/sbas/templates/sbas/question_detail.html b/sbas/templates/sbas/question_detail.html
index a687d5da..c0d2bf1e 100644
--- a/sbas/templates/sbas/question_detail.html
+++ b/sbas/templates/sbas/question_detail.html
@@ -29,14 +29,78 @@
{{ exam }}
{% endfor %}
+
+ Findings: {% if question.finding.exists %}
+
+ {% for f in question.finding.all %}
+ - {{ f.get_link|safe }}
+ {% endfor %}
+
+ {% else %}
+ None
+ {% endif %}
+
+
+ Structures: {% if question.structure.exists %}
+
+ {% for s in question.structure.all %}
+ - {{ s.get_link|safe }}
+ {% endfor %}
+
+ {% else %}
+ None
+ {% endif %}
+
+
+ Conditions: {% if question.condition.exists %}
+
+ {% for c in question.condition.all %}
+ - {{ c.get_link|safe }}
+ {% endfor %}
+
+ {% else %}
+ None
+ {% endif %}
+
+
+ Presentations: {% if question.presentation.exists %}
+
+ {% for p in question.presentation.all %}
+ - {{ p.get_link|safe }}
+ {% endfor %}
+
+ {% else %}
+ None
+ {% endif %}
+
+
+ Subspecialties: {% if question.subspecialty.exists %}
+
+ {% for ss in question.subspecialty.all %}
+ - {{ ss.get_link|safe }}
+ {% endfor %}
+
+ {% else %}
+ None
+ {% endif %}
+
Category: {{ question.category }}
- 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 %}
+
+
+
+ Feedback: {{ question.feedback|linebreaks }}
{% include 'question_notes.html' %}
diff --git a/sbas/urls.py b/sbas/urls.py
index 14c04c5c..6fb3ee3d 100644
--- a/sbas/urls.py
+++ b/sbas/urls.py
@@ -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//scores///",
# views.exam_scores_cid_user,
diff --git a/sbas/views.py b/sbas/views.py
index 52379f92..10b381b8 100644
--- a/sbas/views.py
+++ b/sbas/views.py
@@ -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"{title}\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", {})
\ No newline at end of file