Compare commits

...
31 Commits
Author SHA1 Message Date
Ross 4f47354ca1 Add display of related SBA questions in condition detail view 2025-10-20 14:47:47 +01:00
Ross bc21dd6ebd Add ordering by created date to QuestionTable and update import preview layout for better readability 2025-10-20 13:53:08 +01:00
Ross 5d09c34f6d Move feedback display to a more prominent position in question detail view 2025-10-20 13:43:09 +01:00
Ross 7e7a1b8763 Add edit link column to QuestionTable for improved item management 2025-10-20 13:39:23 +01:00
Ross 272c3381e1 Remove title from QuestionTable sequence and enhance render_stem method to include title in output 2025-10-20 13:08:06 +01:00
Ross 4a3a089941 Update QuestionTable sequence to include title for improved data representation 2025-10-20 13:05:07 +01:00
Ross aac4146b7f Add category display to import preview for better item context 2025-10-20 13:04:37 +01:00
Ross 42b64c11ff Refactor actions section in case view template for improved readability and structure 2025-10-20 13:01:14 +01:00
Ross 2a94e40a1e Update QuestionForm to increase TinyMCE editor height for better visibility and modify import_llm_confirm to set question title directly from payload 2025-10-20 12:58:17 +01:00
Ross 4d305e1fa2 Add logging for bulk delete request and update button for selected questions deletion 2025-10-20 12:54:17 +01:00
Ross 452143f1ef Enhance bulk delete functionality by adding user confirmation before submission and removing debug information from the response when no items are deleted. 2025-10-20 12:41:33 +01:00
Ross 264270ce00 Enhance bulk delete functionality to support multiple checkbox naming conventions and improve debug information display when no items are deleted. 2025-10-20 12:39:59 +01:00
Ross 980bb199cf Add bulk delete functionality for questions with confirmation feedback 2025-10-20 12:32:59 +01:00
Ross 1085b9e6a2 Update import_llm_questions to set question title directly from payload 2025-10-20 12:22:06 +01:00
Ross 52886d176b Remove exams column from QuestionTable and add current user as author during question import 2025-10-20 12:21:02 +01:00
Ross 4ad101d65e Add sources display to question detail view for better reference tracking 2025-10-20 12:13:08 +01:00
Ross def291c853 Fix source display formatting and improve missing_map handling in import preview and confirmation 2025-10-20 12:11:00 +01:00
Ross 9dd4fc0402 Add sources display to import preview for better reference tracking 2025-10-20 12:06:47 +01:00
Ross 8f5ce830fe Refactor M2M toggle functionality to synchronize button states and improve placeholder updates 2025-10-20 12:06:10 +01:00
Ross b680ae749f Add sources JSONField to Question model and update import functions to handle source references 2025-10-20 12:04:10 +01:00
Ross 441d33a382 Add sources field to Question model for reference tracking 2025-10-20 12:02:18 +01:00
Ross bddccd5768 Enhance import preview and backend handling for M2M relationships, adding sources to schema and improving missing values mapping for better template rendering and debugging. 2025-10-20 12:01:07 +01:00
Ross 88713aba5b Clarify statdx source reference format and enhance sources description in LLM prompt schema 2025-10-20 11:54:31 +01:00
Ross c577da4779 Enhance LLM import preview with detailed M2M field handling and improved exclusion toggle functionality 2025-10-20 11:54:12 +01:00
Ross 16387fff77 Refactor LLM import preview to improve layout and remove redundant controls 2025-10-20 11:41:01 +01:00
Ross d37376d998 Enhance LLM question import and detail views with feedback display and source references 2025-10-20 11:39:43 +01:00
Ross 80171da78d Enhance LLM import preview with improved question display and missing M2M handling 2025-10-20 11:14:44 +01:00
Ross 9be48140a4 Enhance LLM question import preview with detailed payload information and improved answer display 2025-10-20 11:04:33 +01:00
Ross 01f67f3e32 Enhance LLM question import process with preview and confirmation steps, including error handling and M2M record creation 2025-10-20 11:01:47 +01:00
Ross 06b7239daa Add LLM question import functionality with enhanced validation and M2M handling 2025-10-20 10:47:11 +01:00
Ross 93f0bb8927 Add LLM question import functionality and enhance question model with new fields 2025-10-20 10:43:12 +01:00
21 changed files with 1380 additions and 100 deletions
+57 -57
View File
@@ -23,64 +23,64 @@
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details>
<form>
<details id="actions-detail" class="mt-3">
<summary>
<strong>Actions</strong>
</summary>
<div class="row mb-2">
<div class="col-auto">
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
<details id="actions-detail" class="mt-3">
<summary>
<strong>Actions</strong>
</summary>
<div class="row mb-2">
<div class="col-auto">
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
<!-- Dynamically load collection options via HTMX -->
<option value="">Loading collections...</option>
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
</select>
</div>
<div class="col-auto">
<button id="add-to-collection-btn"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'atlas:add_cases_to_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Add selected cases to collection
</button>
<button id="remove-from-collection-btn"
class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove selected cases from collection
</button>
<br/>
<button id="set-open-access-btn"
class="btn btn-sm btn-outline-success ms-2"
hx-post="{% url 'atlas:set_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Set Open Access
</button>
<button id="remove-open-access-btn"
class="btn btn-sm btn-outline-warning ms-2"
hx-post="{% url 'atlas:remove_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove Open Access
</button>
<span id="action-result"></span>
</div>
</div>
</details>
{% render_table table %}
</form>
<option value="">Loading collections...</option>
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
</select>
</div>
<div class="col-auto">
<button id="add-to-collection-btn"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'atlas:add_cases_to_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Add selected cases to collection
</button>
<button id="remove-from-collection-btn"
class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove selected cases from collection
</button>
<br/>
<button id="set-open-access-btn"
class="btn btn-sm btn-outline-success ms-2"
hx-post="{% url 'atlas:set_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Set Open Access
</button>
<button id="remove-open-access-btn"
class="btn btn-sm btn-outline-warning ms-2"
hx-post="{% url 'atlas:remove_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove Open Access
</button>
<span id="action-result"></span>
</div>
</div>
</details>
{% render_table table %}
</form>
{% include "generic/partials/page_size_form.html" %}
{% include "generic/partials/page_size_form.html" %}
<div id="exam-options"></div>
<div id="exam-options"></div>
{% endblock %}
+25 -16
View File
@@ -62,25 +62,34 @@
</ul>
{% endif %}
{% if condition.sbas_questions.all %}
<h4>Related SBA Questions</h4>
<ul>
{% for q in condition.sbas_questions.all %}
<li><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if not condition.rcr_curriculum and can_merge %}
<details><summary>Merge</summary>
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
<form method="POST">
{% csrf_token %}
<details><summary>Merge</summary>
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
<form method="POST">
{% csrf_token %}
<table>
{{ form.as_table }}
{{ form.as_table }}
</table>
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
hx-target="#merge-options" hx-swap="innerHTML"
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
>
Merge
</button>
</form>
<div id="merge-options"></div>
</details>
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
hx-target="#merge-options" hx-swap="innerHTML"
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
>
Merge
</button>
</form>
<div id="merge-options"></div>
</details>
{% endif %}
{% endblock %}
@@ -0,0 +1,4 @@
<div class="alert alert-info">Deleted {{ deleted }} items.</div>
{% if errors %}
<div class="alert alert-warning">Errors: {{ errors }}</div>
{% endif %}
+1
View File
@@ -34,6 +34,7 @@ urlpatterns = [
views.generic_exam_json_edit,
name="generic_exam_json_edit",
),
path("bulk_delete_questions/", views.bulk_delete_questions, name="bulk_delete_questions"),
path(
"examination-autocomplete",
views.ExaminationAutocomplete.as_view(
+77
View File
@@ -149,6 +149,83 @@ from django.db.models import Prefetch
from loguru import logger
@login_required
@user_passes_test(lambda u: u.is_superuser)
def bulk_delete_questions(request):
"""Generic bulk delete for question-like models.
Expects POST with 'app' (app_name) and 'selected' (list or comma-separated ids).
Returns a small HTML fragment summarising deletions (suitable for HTMX).
"""
if request.method != "POST":
return HttpResponse(status=405)
logger.debug(f"bulk_delete_questions called with POST: {request.POST}")
app = request.POST.get("app")
# Accept different submission patterns: checkboxes may be sent as 'selection' or 'selected'
selected_list = []
# common variants
for key in ("selected", "selection", "selected[]", "selection[]"):
vals = request.POST.getlist(key)
if vals:
# extend list; we'll parse ints below
selected_list.extend(vals)
# Also accept a raw CSV string under 'selected'
selected_raw = request.POST.get("selected")
ids = set()
if selected_list:
for s in selected_list:
# some checkbox libraries may give empty strings or 'on' for checked boxes
try:
ids.add(int(s))
except Exception:
# ignore non-int vals
continue
elif selected_raw:
for part in selected_raw.split(','):
try:
part = part.strip()
if not part:
continue
ids.add(int(part))
except Exception:
continue
# Map app name to model class
APP_MODEL_MAP = {
"sbas": SbasQuestion,
"physics": PhysicsQuestion,
"anatomy": AnatomyQuestion,
"rapids": RapidQuestion,
"rapid": RapidQuestion,
"shorts": ShortsQuestion,
"longs": LongQuestion,
"long": LongQuestion,
}
Model = APP_MODEL_MAP.get(app)
if Model is None:
return JsonResponse({"ok": False, "error": f"Unknown app: {app}"}, status=400)
deleted = 0
errors = []
try:
qs = Model.objects.filter(pk__in=list(ids))
deleted = qs.count()
qs.delete()
except Exception as e:
logger.exception("bulk_delete failed for app=%s ids=%s", app, ids)
return JsonResponse({"ok": False, "error": str(e)}, status=500)
# Render a small fragment summarising deletions
context = {"deleted": deleted, "errors": errors}
return render(request, "generic/partials/bulk_delete_result.html", context)
class RedirectMixin:
def get_success_url(self) -> str:
if "redirect" in self.request.GET:
+31 -10
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 = {
@@ -148,16 +156,29 @@ class QuestionForm(ModelForm):
# (False, 'No')])
"stem" : TinyMCE(attrs={'cols': 80, 'rows': 30}),
"feedback" : TinyMCE(attrs={'cols': 80, 'rows': 30}),
"a_answer" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}),
"a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}),
"b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"b_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}),
"e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
"a_answer" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 200}),
"a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 200}),
"b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"b_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
"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),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-20 11:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sbas', '0019_question_title'),
]
operations = [
migrations.AddField(
model_name='question',
name='sources',
field=models.JSONField(blank=True, help_text='List of source references for the question (e.g. article DOIs or StatDx IDs or Source file names).', null=True),
),
]
+15
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,18 @@ class Question(QuestionBase):
Category, on_delete=models.SET_NULL, null=True, blank=True
)
sources = models.JSONField(
blank=True,
null=True,
help_text="List of source references for the question (e.g. article DOIs or StatDx IDs or Source file names).",
)
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):
+20 -4
View File
@@ -17,13 +17,17 @@ class QuestionTable(tables.Table):
#delete = tables.LinkColumn(
# "sbas:question_delete", text="Delete", args=[A("pk")], orderable=False
#)
exams = tables.ManyToManyColumn(verbose_name="Exams")
#exams = tables.ManyToManyColumn(verbose_name="Exams")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
stem = tables.Column()
answers = tables.TemplateColumn("<ol type='A'><li>{{ record.a_answer|safe}}</li><li>{{ record.b_answer|safe}}</li><li>{{ record.c_answer|safe}}</li><li>{{ record.d_answer|safe}}</li><li>{{ record.e_answer|safe}}</li></ol>")
edit = tables.LinkColumn(
"sbas:question_update", text="Edit", args=[A("pk")], orderable=False
)
class Meta:
model = Question
template_name = "django_tables2/bootstrap4.html"
@@ -38,19 +42,31 @@ class QuestionTable(tables.Table):
"created_date",
"category",
"author",
"edit"
)
sequence = ("view", "stem", "answers", "exams")
sequence = ("view", "stem", "answers")#, "exams")
attrs = {"class": "table row-selector"}
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs):
super().__init__(
data.prefetch_related(
"category", "exams", "author"
"category", "author"
),
*args,
**kwargs,
)
def render_stem(self, value):
def render_stem(self, value, record=None, **kwargs):
"""Render the stem, optionally with a title from the record.
Some versions of django-tables2 may call the render method without
passing a `record`; guard for that and fall back to rendering the
value only.
"""
title = getattr(record, "title", None) if record is not None else None
if title:
return format_html("<h5>{}</h5>{}", title, mark_safe(value))
return mark_safe(value)
+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,15 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h1>Import LLM Questions</h1>
<form id="llm-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'sbas:import_llm_questions' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %}
{{ form.as_p }}
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" type="submit">Preview (dry run)</button>
<button class="btn btn-secondary" type="button" onclick="document.getElementById('llm-import-form').reset()">Reset</button>
</div>
</form>
<p>Upload a single JSON array, a single object, or JSONL (one JSON object per line) matching the agreed schema. Use the Preview button to inspect results before importing.</p>
<div id="import-preview" class="mt-3"></div>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% 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], if you do not know the doi please use the article name.
11. For statdx sources please reference the article name in the format [statdx:article_id] (article_id may be docid).
12. References should be included inline within the feedback fields.
13. Sources can also be included in the sources field as a list.
14. 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')" }
]
},
"sources": {
"type": "array",
"description": "List of source references for the question (e.g. article DOIs or docid or StatDx IDs or Source file names).",
"items": { "type": "string" },
"uniqueItems": true
},
"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>
{% endblock %}
@@ -0,0 +1,8 @@
<div class="alert alert-danger">
<h5>Import error</h5>
<ul>
{% for e in errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
@@ -0,0 +1,237 @@
<form id="llm-import-confirm-form" method="post" hx-post="{% url 'sbas:import_llm_confirm' %}" hx-target="#import-result" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="session_key" value="{{ session_key }}">
{% for item in items %}
<div class="card mb-2">
<div class="card-body">
<h5 class="card-title">Question {{ item.index|add:1 }}</h5>
{% if item.payload %}
<p><strong>Title:</strong> {{ item.payload.title|default:'(No title)'|safe }}</p>
<p><strong>Stem:</strong> {{ item.payload.stem|default:''|safe }}</p>
<p><strong>Answers:</strong>
<ul>
<li>A: {{ item.payload.a_answer|default:''|safe }}</li>
{% if item.payload.a_feedback %}
<p><strong>A Feedback:</strong> {{ item.payload.a_feedback|default:''|safe }}</p>
{% endif %}
<li>B: {{ item.payload.b_answer|default:''|safe }}</li>
{% if item.payload.b_feedback %}
<p><strong>B Feedback:</strong> {{ item.payload.b_feedback|default:''|safe }}</p>
{% endif %}
<li>C: {{ item.payload.c_answer|default:''|safe }}</li>
{% if item.payload.c_feedback %}
<p><strong>C Feedback:</strong> {{ item.payload.c_feedback|default:''|safe }}</p>
{% endif %}
<li>D: {{ item.payload.d_answer|default:''|safe }}</li>
{% if item.payload.d_feedback %}
<p><strong>D Feedback:</strong> {{ item.payload.d_feedback|default:''|safe }}</p>
{% endif %}
<li>E: {{ item.payload.e_answer|default:''|safe }}</li>
{% if item.payload.e_feedback %}
<p><strong>E Feedback:</strong> {{ item.payload.e_feedback|default:''|safe }}</p>
{% endif %}
</ul>
<p><strong>Best Answer:</strong> {{ item.payload.best_answer|default:'' }}
</p>
<p><strong>Feedback:</strong> {{ item.payload.feedback|default:''|safe }}</p>
<p><strong>Sources:</strong>
{% if item.payload.sources %}
<ul>
{% for source in item.payload.sources %}
<li>{{ source }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</p>
{% endif %}
<p><strong>M2M fields (click to exclude)</strong></p>
<p class="small text-muted">Green = will be created/attached. Click a value to exclude it (grey).</p>
<div class="mb-2">
<!-- Finding -->
<div class="m2m-group" data-model="Finding"><strong>Finding:</strong>
{% if item.resolved_m2m.finding %}
{% for val in item.resolved_m2m.finding %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Finding" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.finding %}
{% for val in item.missing_map.finding %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Finding" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.finding %}
<div class="small text-info">Would create: {{ item.missing_map.finding|join:", " }}</div>
{% endif %}
</div>
</div>
<!-- Structure -->
<div class="m2m-group" data-model="Structure"><strong>Structure:</strong>
{% if item.resolved_m2m.structure %}
{% for val in item.resolved_m2m.structure %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Structure" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.structure %}
{% for val in item.missing_map.structure %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Structure" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.structure %}
<div class="small text-info">Would create: {{ item.missing_map.structure|join:", " }}</div>
{% endif %}
</div>
<!-- Condition -->
<div class="m2m-group" data-model="Condition"><strong>Condition:</strong>
{% if item.resolved_m2m.condition %}
{% for val in item.resolved_m2m.condition %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Condition" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.condition %}
{% for val in item.missing_map.condition %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Condition" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.condition %}
<div class="small text-info">Would create: {{ item.missing_map.condition|join:", " }}</div>
{% endif %}
</div>
<!-- Presentation -->
<div class="m2m-group" data-model="Presentation"><strong>Presentation:</strong>
{% if item.resolved_m2m.presentation %}
{% for val in item.resolved_m2m.presentation %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Presentation" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.presentation %}
{% for val in item.missing_map.presentation %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Presentation" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.presentation %}
<div class="small text-info">Would create: {{ item.missing_map.presentation|join:", " }}</div>
{% endif %}
</div>
<!-- Subspecialty -->
<div class="m2m-group" data-model="Subspecialty"><strong>Subspecialty:</strong>
{% if item.resolved_m2m.subspecialty %}
{% for val in item.resolved_m2m.subspecialty %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Subspecialty" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.subspecialty %}
{% for val in item.missing_map.subspecialty %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Subspecialty" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.subspecialty %}
<div class="small text-info">Would create: {{ item.missing_map.subspecialty|join:", " }}</div>
{% endif %}
</div>
<! -- Category -->
<div class="m2m-group" data-model="Category"><strong>Category:</strong>
{{ item.payload.category|default:'(none)' }}
</div>
<!-- DEBUG: show raw resolved and missing maps for troubleshooting -->
<div class="card-footer text-monospace small">
<details>
<summary>Debug: resolved_m2m / missing_map (click to expand)</summary>
<pre style="white-space:pre-wrap;">resolved_m2m: {{ item.resolved_m2m|default:"{}" }}
missing_map: {{ item.missing_map|default:"{}" }}</pre>
</details>
</div>
<div>
<label><input type="checkbox" name="selected" value="{{ item.index }}" checked> Include</label>
</div>
<!-- container for hidden exclude inputs for this item -->
<div id="exclude-container-{{ item.index }}"></div>
</div>
</div>
{% endfor %}
<div class="d-flex gap-2">
<label class="me-2"><input type="checkbox" name="allow_create_m2m" {% if allow_create_m2m %}checked{% endif %}> Create missing M2M</label>
<button class="btn btn-success" type="submit">Import selected</button>
<button class="btn btn-secondary" type="button" hx-get="{% url 'sbas:import_llm_questions' %}" hx-target="#import-preview">Cancel</button>
</div>
<div id="import-result" class="mt-3"></div>
</form>
<script>
// Toggle exclude: clicking a M2M button will add/remove a hidden input and update styles
function setupToggle(btn){
btn.addEventListener('click', function(){
const model = btn.dataset.model;
const value = btn.dataset.value;
// Find all buttons with same model+value and toggle them together
document.querySelectorAll('.m2m-toggle').forEach(function(b){
if(b.dataset.model === model && b.dataset.value === value){
const idx2 = b.dataset.idx;
const container = document.getElementById('exclude-container-' + idx2);
const inputName = 'exclude_' + idx2;
const existing = container ? container.querySelector('input[data-model="' + model + '"][data-value="' + value + '"]') : null;
if(existing){
existing.remove();
b.classList.remove('btn-outline-secondary');
b.classList.remove('btn-secondary');
b.classList.add('btn-success');
} else {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = inputName;
inp.value = model + ':::' + value;
inp.setAttribute('data-model', model);
inp.setAttribute('data-value', value);
container.appendChild(inp);
b.classList.remove('btn-success');
b.classList.add('btn-outline-secondary');
}
}
});
// Update empty placeholders after toggling
refreshEmptyPlaceholders();
});
}
document.querySelectorAll('.m2m-toggle').forEach(setupToggle);
// keep backward compatibility with older toggle-exclude class
document.querySelectorAll('.toggle-exclude').forEach(setupToggle);
// Show '(none)' for empty groups
function refreshEmptyPlaceholders(){
document.querySelectorAll('.m2m-group').forEach(function(group){
// consider a button 'active' only if it has the green btn-success class
const activeButtons = group.querySelectorAll('.m2m-toggle.btn-success');
const none = group.querySelector('.m2m-none');
if(!none) return;
if(activeButtons.length === 0){
none.style.display = '';
} else {
none.style.display = 'none';
}
});
}
// run on load
refreshEmptyPlaceholders();
// when toggling, buttons are not removed from DOM, so no need to re-run on click;
// however if you later add server-side mutation that rewrites the fragment, call refreshEmptyPlaceholders after update.
</script>
<!-- Bottom controls removed: submit and cancel are inside the form above -->
@@ -0,0 +1,21 @@
<div class="alert alert-info">Imported {{ created }} questions.</div>
{% if errors %}
<div class="alert alert-danger">
<h5>Errors</h5>
<ul>
{% for e in errors %}
<li>Item {{ e.index }}: {{ e.errors|join:', ' }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if actually_created %}
<div class="alert alert-secondary">
<h5>Created M2M records</h5>
<ul>
{% for k,v in actually_created.items %}
<li>{{ k }}: {{ v|join:', ' }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
+93 -8
View File
@@ -15,30 +15,115 @@
<span>{{question.stem}}</span>
<div>
<ol class="abcde">
<li>{{ question.a_answer }}</li>
<li>{{ question.b_answer }}</li>
<li>{{ question.c_answer }}</li>
<li>{{ question.d_answer }}</li>
<li>{{ question.e_answer }}</li>
<li><strong>{{ question.a_answer }}</strong>
{% if question.a_feedback %}<p>Feedback:<i>{{ question.a_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.b_answer }}</strong>
{% if question.b_feedback %}<p>Feedback:<i>{{ question.b_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.c_answer }}</strong>
{% if question.c_feedback %}<p>Feedback:<i>{{ question.c_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.d_answer }}</strong>
{% if question.d_feedback %}<p>Feedback:<i>{{ question.d_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.e_answer }}</strong>
{% if question.e_feedback %}<p>Feedback:<i>{{ question.e_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
</ol>
Best answer: {{ question.best_answer }} ({{question.get_correct_answer_stripped}})
</div>
{% endautoescape %}
<div>
Feedback: {{ question.feedback|linebreaks }}
</div>
<div>
Sources:
{% if question.sources %}
<ul>
{% for source in question.sources %}
<li>{{ source }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
<div>
Examinations: {% for exam in question.exams.all %}
<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>
{% include 'question_notes.html' %}
</div>
{% endblock %}
+17
View File
@@ -82,6 +82,23 @@ 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(
"import_llm_confirm/",
views.import_llm_confirm,
name="import_llm_confirm",
),
#path(
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
# views.exam_scores_cid_user,
+521
View File
@@ -29,6 +29,22 @@ 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__)
import uuid
from loguru import logger
try:
import jsonschema
except Exception:
jsonschema = None
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
@@ -436,3 +452,508 @@ 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", {})
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",
)
dry_run = forms.BooleanField(required=False, initial=True, help_text="Validate and preview only; do not save to database")
allow_create_m2m = forms.BooleanField(required=False, initial=True, help_text="Automatically create missing many-to-many items when importing (if not dry-run)")
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},
"sources": {"type": "array", "items": {"type": "string"}},
},
}
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)
dry_run = bool(form.cleaned_data.get("dry_run"))
allow_create_m2m = bool(form.cleaned_data.get("allow_create_m2m"))
report = {
"total": len(candidates),
"created": 0,
"errors": [],
"sanitized_backslashes": sanitized_count,
"per_item": [],
"would_create_m2m": {},
"actually_created_m2m": {},
}
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
# Aggregators for M2M creation suggestions
aggregated_would_create = defaultdict(set)
aggregated_actually_created = defaultdict(list)
for idx, payload in enumerate(candidates):
item_report = {"index": idx, "status": None, "errors": [], "missing_m2m": [], "resolved_m2m": {}, "payload": payload}
for err in validator.iter_errors(payload):
item_report["errors"].append(err.message)
if item_report["errors"]:
logger.debug(f"Validation errors for item {idx}: {item_report['errors']}")
report["errors"].append({"index": idx, "errors": item_report["errors"]})
item_report["status"] = "invalid"
report["per_item"].append(item_report)
continue
try:
# Prepare category resolution
cat_val = payload.get("category")
cat_obj = None
if isinstance(cat_val, str):
if not dry_run:
cat_obj, _ = Category.objects.get_or_create(category=cat_val)
else:
# dry-run preview: show that category would be used/created
item_report.setdefault("category_preview", cat_val)
# M2M resolution and missing detection
m2m_map = {}
for key, model_cls in (
("finding", Finding),
("structure", Structure),
("condition", Condition),
("presentation", Presentation),
("subspecialty", Subspecialty),
):
vals = payload.get(key) or []
resolved = []
missing = []
for v in vals:
if isinstance(v, int):
try:
resolved.append(model_cls.objects.get(pk=v))
except model_cls.DoesNotExist:
missing.append(v)
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]))
else:
missing.append(v)
m2m_map[key] = {"resolved": resolved, "missing": missing}
item_report["resolved_m2m"][key] = [o.name for o in resolved]
# Always include an entry for this M2M (values may be empty)
item_report["missing_m2m"].append({"model": model_cls.__name__, "values": missing})
if missing:
for v in missing:
aggregated_would_create[model_cls.__name__].add(v)
# Build a simple map of missing values per model (stringified) for robust template rendering
# Provide both lowercase and capitalized keys so templates referencing either form will work
# missing_map keys are lowercase (matching m2m_map keys)
missing_map = {k: [str(x) for x in v["missing"]] for k, v in m2m_map.items()}
item_report["missing_map"] = missing_map
# Debug: log missing_map and resolved_m2m for troubleshooting
logger.debug(
"import_llm_questions: item %s missing_map_keys=%s resolved_keys=%s",
idx,
list(missing_map.keys()),
list((item_report.get("resolved_m2m") or {}).keys()),
)
# If dry-run, do not save; just report what would happen
if dry_run:
item_report["status"] = "would_create"
report["per_item"].append(item_report)
continue
# Not dry-run: create Question and resolve/possibly create missing M2M
with transaction.atomic():
q = Question()
q.stem = payload.get("stem", "").strip()
q.title = payload.get("title")
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")
if cat_obj:
q.category = cat_obj
# populate sources JSONField if provided (accept list or single string)
srcs = payload.get("sources")
if isinstance(srcs, list):
q.sources = srcs
elif srcs:
q.sources = [str(srcs)]
q.save()
# Add current user as author of the imported question
try:
q.author.add(request.user)
except Exception:
logger.exception("Failed to add author for imported question idx=%s", idx)
# For each M2M, create missing items if allowed and attach resolved
for key, model_cls in (
("finding", Finding),
("structure", Structure),
("condition", Condition),
("presentation", Presentation),
("subspecialty", Subspecialty),
):
resolved = m2m_map[key]["resolved"]
missing_vals = m2m_map[key]["missing"]
# create missing if allowed
created_objs = []
if missing_vals and allow_create_m2m:
for v in missing_vals:
# create with name=v (use get_or_create to avoid races)
obj, created = model_cls.objects.get_or_create(name=v)
created_objs.append(obj)
aggregated_actually_created[model_cls.__name__].append(obj.pk)
# final list to attach
final_objs = resolved + created_objs
if final_objs:
getattr(q, key).add(*[o.pk for o in final_objs])
report["created"] += 1
item_report["status"] = "created"
item_report["created_pk"] = q.pk
report["per_item"].append(item_report)
except Exception as e:
logger.exception("Failed to import item %s", idx)
item_report["status"] = "error"
item_report["errors"].append(str(e))
report["errors"].append({"index": idx, "errors": [str(e)]})
report["per_item"].append(item_report)
# Flatten aggregated_would_create sets to lists
for k, v in aggregated_would_create.items():
report["would_create_m2m"][k] = sorted(list(v))
for k, v in aggregated_actually_created.items():
report["actually_created_m2m"][k] = v
# If this was a dry-run and the request came from HTMX, render a preview partial and store the parsed candidates in session
if dry_run and request.headers.get("HX-Request"):
session_key = f"llm_import_{uuid.uuid4().hex}"
# store minimal necessary data in session (the raw payloads)
request.session[session_key] = candidates
request.session.modified = True
preview_ctx = {
"report": report,
"items": report["per_item"],
"payloads": candidates,
"session_key": session_key,
"allow_create_m2m": allow_create_m2m,
}
return render(request, "sbas/partials/import_preview.html", preview_ctx)
# Non-HTMX callers get JSON
return JsonResponse({"ok": True, "report": report})
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["POST"])
def import_llm_confirm(request):
"""Confirm import of previously-previewed candidates stored in session.
Expects POST with 'session_key' and 'selected' (comma-separated indices) and optional allow_create_m2m.
Returns an HTML fragment suitable for HTMX replacement summarising the import results.
"""
session_key = request.POST.get("session_key")
# support both a comma-separated 'selected' string or multiple 'selected' values
selected_raw = request.POST.get("selected")
selected_list = request.POST.getlist("selected")
allow_create_m2m = request.POST.get("allow_create_m2m") in ("on", "true", "True", "1")
if selected_list:
# selected_list contains strings of indices
selected = ",".join(selected_list)
else:
selected = selected_raw
if not session_key or session_key not in request.session:
return render(request, "sbas/partials/import_error.html", {"errors": ["Session expired or invalid; please re-run preview."]}, status=400)
candidates = request.session.get(session_key, [])
indices = []
if selected:
for part in selected.split(','):
try:
indices.append(int(part))
except Exception:
continue
# default: import all if none specified
if not indices:
indices = list(range(len(candidates)))
# perform actual import for selected indices
from atlas.models import Finding, Structure, Condition, Presentation, Subspecialty
# Parse exclude inputs of the form exclude_<idx>="Model:::value" (may be multiple per idx)
excluded_map = defaultdict(lambda: defaultdict(set))
for k, vlist in request.POST.lists():
if not k.startswith("exclude_"):
continue
try:
idx_str = k.split("exclude_")[1]
idx_key = int(idx_str)
except Exception:
continue
for entry in vlist:
# entry format: Model:::value
try:
model_name, val = entry.split(":::", 1)
excluded_map[idx_key][model_name].add(val)
except Exception:
continue
# Debug: log exclusions parsed from POST
try:
# convert sets to lists for logging
log_map = {i: {m: list(vals) for m, vals in models.items()} for i, models in excluded_map.items()}
logger.debug("import_llm_confirm: excluded_map=%s", log_map)
except Exception:
logger.exception("Failed to log excluded_map")
created = 0
errors = []
actually_created = defaultdict(list)
for idx in indices:
try:
payload = candidates[idx]
except Exception:
errors.append({"index": idx, "errors": ["Missing candidate"]})
continue
try:
with transaction.atomic():
q = Question()
q.stem = payload.get("stem", "").strip()
q.title = payload.get("title")
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")
cat_val = payload.get("category")
if isinstance(cat_val, str):
cat_obj, _ = Category.objects.get_or_create(category=cat_val)
q.category = cat_obj
# populate sources JSONField if provided (accept list or single string)
srcs = payload.get("sources")
if isinstance(srcs, list):
q.sources = srcs
elif srcs:
q.sources = [str(srcs)]
q.save()
# Add current user as author of the imported question
try:
q.author.add(request.user)
except Exception:
logger.exception("Failed to add author for confirmed import idx=%s", idx)
for key, model_cls in (("finding", Finding), ("structure", Structure), ("condition", Condition), ("presentation", Presentation), ("subspecialty", Subspecialty)):
vals = payload.get(key) or []
final_objs = []
for v in vals:
if isinstance(v, int):
try:
final_objs.append(model_cls.objects.get(pk=v))
except model_cls.DoesNotExist:
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():
# add resolved objects unless excluded for this item
for obj in list(qs[:5]):
if obj.name in excluded_map.get(idx, {}).get(model_cls.__name__, set()):
logger.debug("Skipping resolved attach for idx=%s model=%s name=%s due to exclusion", idx, model_cls.__name__, obj.name)
continue
final_objs.append(obj)
else:
if allow_create_m2m:
# skip creation if excluded for this item
if v in excluded_map.get(idx, {}).get(model_cls.__name__, set()):
logger.debug("Skipping creation for idx=%s model=%s name=%s due to exclusion", idx, model_cls.__name__, v)
continue
obj, created_flag = model_cls.objects.get_or_create(name=v)
final_objs.append(obj)
actually_created[model_cls.__name__].append(obj.pk)
if final_objs:
getattr(q, key).add(*[o.pk for o in final_objs])
created += 1
except Exception as e:
errors.append({"index": idx, "errors": [str(e)]})
# cleanup session
try:
del request.session[session_key]
request.session.modified = True
except Exception:
pass
return render(request, "sbas/partials/import_result.html", {"created": created, "errors": errors, "actually_created": dict(actually_created)})
+14 -5
View File
@@ -18,13 +18,22 @@
</form>
</details>
</div>
{% render_table table %}
{% render_table table %}
<button id="delete-selected-btn" class="btn btn-danger btn-sm mt-2"
hx-post="{% url 'generic:bulk_delete_questions' %}"
hx-include="[name='selection']:checked" hx-vals='{"app":"{{ app_name|escapejs }}"}'
hx-target="#action-result"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">
Delete selected questions</button>
<button id="button-select-add-exam"
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
data-exam_list_url="{% url 'api-1:'|add:app_name|add:'_user_exams' %}"
data-type={{app_name}} data-csrf="{{ csrf_token}}">Add selected questions to
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
data-exam_list_url="{% url 'api-1:'|add:app_name|add:'_user_exams' %}"
data-type={{app_name}} data-csrf="{{ csrf_token}}">Add selected questions to
exam</button>
<div id="exam-options"></div>
<div id="action-result" class="mt-2"></div>
{% endblock %}