Add LLM question import functionality and enhance question model with new fields
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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
@@ -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", {})
|
||||
Reference in New Issue
Block a user