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,
|
ModelForm,
|
||||||
ModelMultipleChoiceField,
|
ModelMultipleChoiceField,
|
||||||
ModelChoiceField,
|
ModelChoiceField,
|
||||||
|
CheckboxSelectMultiple,
|
||||||
ChoiceField,
|
ChoiceField,
|
||||||
CharField,
|
CharField,
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,8 @@ from django.forms.widgets import RadioSelect, TextInput, Textarea
|
|||||||
|
|
||||||
from tinymce.widgets import TinyMCE
|
from tinymce.widgets import TinyMCE
|
||||||
|
|
||||||
|
from dal import autocomplete
|
||||||
|
|
||||||
|
|
||||||
class UserAnswerForm(ModelForm):
|
class UserAnswerForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -140,6 +143,11 @@ class QuestionForm(ModelForm):
|
|||||||
"e_answer",
|
"e_answer",
|
||||||
"e_feedback",
|
"e_feedback",
|
||||||
"best_answer",
|
"best_answer",
|
||||||
|
"finding",
|
||||||
|
"structure",
|
||||||
|
"condition",
|
||||||
|
"presentation",
|
||||||
|
"subspecialty",
|
||||||
]
|
]
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
@@ -158,6 +166,19 @@ class QuestionForm(ModelForm):
|
|||||||
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||||
"d_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}),
|
"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 = {
|
#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 django.contrib.contenttypes.fields import GenericRelation
|
||||||
|
|
||||||
|
from atlas.models import Finding, Structure, Condition, Subspecialty, Presentation
|
||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
category = models.CharField(max_length=200)
|
category = models.CharField(max_length=200)
|
||||||
@@ -24,6 +26,7 @@ class Category(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Question(QuestionBase):
|
class Question(QuestionBase):
|
||||||
|
title = models.CharField(max_length=200, help_text="Short title for question", blank=True, null=True)
|
||||||
stem = models.TextField(
|
stem = models.TextField(
|
||||||
blank=False,
|
blank=False,
|
||||||
help_text="Stem of the question",
|
help_text="Stem of the question",
|
||||||
@@ -91,6 +94,12 @@ class Question(QuestionBase):
|
|||||||
Category, on_delete=models.SET_NULL, null=True, blank=True
|
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)
|
#notes = GenericRelation(QuestionNote)
|
||||||
|
|
||||||
def __str__(self):
|
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>
|
<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>
|
</ul>
|
||||||
</li>
|
</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 %}
|
{% if request.user.is_superuser %}
|
||||||
<li class="nav-item">
|
<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>
|
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">{{ exam }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
Category: {{ question.category }}
|
Category: {{ question.category }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
Author: {% for user in question.author.all %}
|
Author(s):
|
||||||
{{ author }},
|
{% if question.author.exists %}
|
||||||
|
{% for u in question.author.all %}
|
||||||
|
{{ u.get_full_name|default:u.username }}{% if not forloop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Feedback: {{ question.feedback|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'question_notes.html' %}
|
{% include 'question_notes.html' %}
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ urlpatterns = [
|
|||||||
views.UserAnswerDelete.as_view(),
|
views.UserAnswerDelete.as_view(),
|
||||||
name="user_answer_delete",
|
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(
|
#path(
|
||||||
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
||||||
# views.exam_scores_cid_user,
|
# views.exam_scores_cid_user,
|
||||||
|
|||||||
+248
@@ -29,6 +29,250 @@ from django.http import Http404, HttpResponseBadRequest, JsonResponse
|
|||||||
from django.http import HttpResponseRedirect, HttpResponse
|
from django.http import HttpResponseRedirect, HttpResponse
|
||||||
|
|
||||||
from .models import Question, Category, Exam, UserAnswer
|
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_tables2 import SingleTableView, SingleTableMixin
|
||||||
from django_filters.views import FilterView
|
from django_filters.views import FilterView
|
||||||
@@ -436,3 +680,7 @@ def exam_clone2(request, exam_id):
|
|||||||
new_exam = exam.clone_model()
|
new_exam = exam.clone_model()
|
||||||
return redirect("sbas:exam_update", pk=new_exam.id)
|
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