Compare commits
31
Commits
4986c2faa9
...
4f47354ca1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f47354ca1 | ||
|
|
bc21dd6ebd | ||
|
|
5d09c34f6d | ||
|
|
7e7a1b8763 | ||
|
|
272c3381e1 | ||
|
|
4a3a089941 | ||
|
|
aac4146b7f | ||
|
|
42b64c11ff | ||
|
|
2a94e40a1e | ||
|
|
4d305e1fa2 | ||
|
|
452143f1ef | ||
|
|
264270ce00 | ||
|
|
980bb199cf | ||
|
|
1085b9e6a2 | ||
|
|
52886d176b | ||
|
|
4ad101d65e | ||
|
|
def291c853 | ||
|
|
9dd4fc0402 | ||
|
|
8f5ce830fe | ||
|
|
b680ae749f | ||
|
|
441d33a382 | ||
|
|
bddccd5768 | ||
|
|
88713aba5b | ||
|
|
c577da4779 | ||
|
|
16387fff77 | ||
|
|
d37376d998 | ||
|
|
80171da78d | ||
|
|
9be48140a4 | ||
|
|
01f67f3e32 | ||
|
|
06b7239daa | ||
|
|
93f0bb8927 |
@@ -62,6 +62,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if not condition.rcr_curriculum and can_merge %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="alert alert-info">Deleted {{ deleted }} items.</div>
|
||||||
|
{% if errors %}
|
||||||
|
<div class="alert alert-warning">Errors: {{ errors }}</div>
|
||||||
|
{% endif %}
|
||||||
@@ -34,6 +34,7 @@ urlpatterns = [
|
|||||||
views.generic_exam_json_edit,
|
views.generic_exam_json_edit,
|
||||||
name="generic_exam_json_edit",
|
name="generic_exam_json_edit",
|
||||||
),
|
),
|
||||||
|
path("bulk_delete_questions/", views.bulk_delete_questions, name="bulk_delete_questions"),
|
||||||
path(
|
path(
|
||||||
"examination-autocomplete",
|
"examination-autocomplete",
|
||||||
views.ExaminationAutocomplete.as_view(
|
views.ExaminationAutocomplete.as_view(
|
||||||
|
|||||||
@@ -149,6 +149,83 @@ from django.db.models import Prefetch
|
|||||||
from loguru import logger
|
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:
|
class RedirectMixin:
|
||||||
def get_success_url(self) -> str:
|
def get_success_url(self) -> str:
|
||||||
if "redirect" in self.request.GET:
|
if "redirect" in self.request.GET:
|
||||||
|
|||||||
+31
-10
@@ -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 = {
|
||||||
@@ -148,16 +156,29 @@ class QuestionForm(ModelForm):
|
|||||||
# (False, 'No')])
|
# (False, 'No')])
|
||||||
"stem" : TinyMCE(attrs={'cols': 80, 'rows': 30}),
|
"stem" : TinyMCE(attrs={'cols': 80, 'rows': 30}),
|
||||||
"feedback" : 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_answer" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 200}),
|
||||||
"a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}),
|
"a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 200}),
|
||||||
"b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"b_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"b_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||||
"e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
"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 = {
|
#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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,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,18 @@ class Question(QuestionBase):
|
|||||||
Category, on_delete=models.SET_NULL, null=True, blank=True
|
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)
|
#notes = GenericRelation(QuestionNote)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
+20
-4
@@ -17,13 +17,17 @@ class QuestionTable(tables.Table):
|
|||||||
#delete = tables.LinkColumn(
|
#delete = tables.LinkColumn(
|
||||||
# "sbas:question_delete", text="Delete", args=[A("pk")], orderable=False
|
# "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)
|
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||||
|
|
||||||
stem = tables.Column()
|
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>")
|
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:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
@@ -38,19 +42,31 @@ class QuestionTable(tables.Table):
|
|||||||
"created_date",
|
"created_date",
|
||||||
"category",
|
"category",
|
||||||
"author",
|
"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):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
data.prefetch_related(
|
data.prefetch_related(
|
||||||
"category", "exams", "author"
|
"category", "author"
|
||||||
),
|
),
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**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)
|
return mark_safe(value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -15,30 +15,115 @@
|
|||||||
<span>{{question.stem}}</span>
|
<span>{{question.stem}}</span>
|
||||||
<div>
|
<div>
|
||||||
<ol class="abcde">
|
<ol class="abcde">
|
||||||
<li>{{ question.a_answer }}</li>
|
<li><strong>{{ question.a_answer }}</strong>
|
||||||
<li>{{ question.b_answer }}</li>
|
{% if question.a_feedback %}<p>Feedback:<i>{{ question.a_feedback|default:''|safe }}</i> </p>{% endif %}
|
||||||
<li>{{ question.c_answer }}</li>
|
</li>
|
||||||
<li>{{ question.d_answer }}</li>
|
<li><strong>{{ question.b_answer }}</strong>
|
||||||
<li>{{ question.e_answer }}</li>
|
{% 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>
|
</ol>
|
||||||
Best answer: {{ question.best_answer }} ({{question.get_correct_answer_stripped}})
|
Best answer: {{ question.best_answer }} ({{question.get_correct_answer_stripped}})
|
||||||
</div>
|
</div>
|
||||||
{% endautoescape %}
|
{% 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>
|
<div>
|
||||||
Examinations: {% for exam in question.exams.all %}
|
Examinations: {% for exam in question.exams.all %}
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
{% include 'question_notes.html' %}
|
{% include 'question_notes.html' %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -82,6 +82,23 @@ 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(
|
||||||
|
"import_llm_confirm/",
|
||||||
|
views.import_llm_confirm,
|
||||||
|
name="import_llm_confirm",
|
||||||
|
),
|
||||||
#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,
|
||||||
|
|||||||
+521
@@ -29,6 +29,22 @@ 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__)
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jsonschema
|
||||||
|
except Exception:
|
||||||
|
jsonschema = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 +452,508 @@ 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", {})
|
||||||
|
|
||||||
|
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)})
|
||||||
@@ -20,11 +20,20 @@
|
|||||||
</div>
|
</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"
|
<button id="button-select-add-exam"
|
||||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
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-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-type={{app_name}} data-csrf="{{ csrf_token}}">Add selected questions to
|
||||||
exam</button>
|
exam</button>
|
||||||
<div id="exam-options"></div>
|
|
||||||
|
<div id="action-result" class="mt-2"></div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user