Implement suggestion feature for incorrect answers; add HTMX support for dynamic candidate rendering and inline editing.
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Suggest incorrect answers</h6>
|
||||||
|
<form hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="closest .card" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Current suggestions (one per line)</label>
|
||||||
|
{{ form.answer_suggest_incorrect }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="(function(e){
|
||||||
|
var wrapper = document.getElementById('incorrect-answers');
|
||||||
|
if(!wrapper) { var c=e.target.closest('.card'); if(c) c.remove(); return; }
|
||||||
|
var t = wrapper.querySelector('template.incorrect-answers-original');
|
||||||
|
if(t) { wrapper.innerHTML = t.innerHTML; }
|
||||||
|
})(event)">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if candidates %}
|
||||||
|
<hr/>
|
||||||
|
<h6 class="mt-3">Suggested candidates</h6>
|
||||||
|
<div class="small text-muted mb-2">These are frequent submitted answers that might be suitable to treat as incorrect suggestions.</div>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for c in candidates %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div class="flex-grow-1"><pre class="mb-0">{{ c.answer }}</pre></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="badge bg-light text-dark me-2">{{ c.count }}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"suggestion": "{{ c.answer|escapejs }}"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template class="incorrect-answers-original" hidden>
|
||||||
|
<strong>Answer suggest incorrect:</strong> {{ question.answer_suggest_incorrect }}
|
||||||
|
<div class="mt-1"><button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button></div>
|
||||||
|
</template>
|
||||||
+139
-1
@@ -1332,20 +1332,158 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
@user_is_author_or_anatomy_checker
|
@user_is_author_or_anatomy_checker
|
||||||
def question_suggest_incorrect_answers(request, pk):
|
def question_suggest_incorrect_answers(request, pk):
|
||||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||||
|
|
||||||
|
# helper: build candidate suggestions (n-grams + prefixes)
|
||||||
|
def build_candidates(question_obj):
|
||||||
|
from django.db.models import Count
|
||||||
|
import re
|
||||||
|
|
||||||
|
def tokenize(s: str):
|
||||||
|
return re.findall(r"\w+", (s or "").lower())
|
||||||
|
|
||||||
|
candidates_qs = (
|
||||||
|
question_obj.cid_user_answers.values("answer_compare")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = set([s for s in question_obj.answer_suggest_incorrect if s])
|
||||||
|
marked_texts = [m.lower() for m in question_obj.get_marked_answers()]
|
||||||
|
|
||||||
|
ngram_map = {}
|
||||||
|
ngram_weight = {}
|
||||||
|
prefix_map = {}
|
||||||
|
prefix_weight = {}
|
||||||
|
|
||||||
|
for row in candidates_qs:
|
||||||
|
ans = (row.get("answer_compare") or "").strip()
|
||||||
|
cnt = int(row.get("count") or 0)
|
||||||
|
if not ans:
|
||||||
|
continue
|
||||||
|
tokens = tokenize(ans)
|
||||||
|
max_n = min(4, len(tokens))
|
||||||
|
for n in range(1, max_n + 1):
|
||||||
|
for i in range(len(tokens) - n + 1):
|
||||||
|
ngram = " ".join(tokens[i : i + n])
|
||||||
|
ngram_map.setdefault(ngram, set()).add(ans)
|
||||||
|
ngram_weight[ngram] = ngram_weight.get(ngram, 0) + cnt
|
||||||
|
|
||||||
|
for tok in tokens:
|
||||||
|
tlen = len(tok)
|
||||||
|
for plen in range(3, min(6, tlen) + 1):
|
||||||
|
pref = tok[:plen]
|
||||||
|
prefix_map.setdefault(pref, set()).add(ans)
|
||||||
|
prefix_weight[pref] = prefix_weight.get(pref, 0) + cnt
|
||||||
|
|
||||||
|
raw_candidates = []
|
||||||
|
|
||||||
|
for ngram, ans_set in ngram_map.items():
|
||||||
|
if ngram in existing:
|
||||||
|
continue
|
||||||
|
excluded = False
|
||||||
|
for mt in marked_texts:
|
||||||
|
if ngram in mt:
|
||||||
|
excluded = True
|
||||||
|
break
|
||||||
|
if excluded:
|
||||||
|
continue
|
||||||
|
coverage = len(ans_set)
|
||||||
|
if coverage < 1:
|
||||||
|
continue
|
||||||
|
raw_candidates.append(
|
||||||
|
{
|
||||||
|
"text": ngram,
|
||||||
|
"coverage": coverage,
|
||||||
|
"count": ngram_weight.get(ngram, 0),
|
||||||
|
"words": len(ngram.split()),
|
||||||
|
"type": "ngram",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for pref, ans_set in prefix_map.items():
|
||||||
|
if pref in existing:
|
||||||
|
continue
|
||||||
|
excluded = False
|
||||||
|
for mt in marked_texts:
|
||||||
|
if pref in mt:
|
||||||
|
excluded = True
|
||||||
|
break
|
||||||
|
if excluded:
|
||||||
|
continue
|
||||||
|
coverage = len(ans_set)
|
||||||
|
if coverage < 1:
|
||||||
|
continue
|
||||||
|
raw_candidates.append(
|
||||||
|
{
|
||||||
|
"text": pref,
|
||||||
|
"coverage": coverage,
|
||||||
|
"count": prefix_weight.get(pref, 0),
|
||||||
|
"words": 1,
|
||||||
|
"type": "prefix",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_candidates.sort(key=lambda x: (x["words"], -x["coverage"], -x["count"]))
|
||||||
|
|
||||||
|
selected = []
|
||||||
|
selected_texts = []
|
||||||
|
for c in raw_candidates:
|
||||||
|
t = c["text"]
|
||||||
|
skip = False
|
||||||
|
for s in selected_texts:
|
||||||
|
if s in t:
|
||||||
|
skip = True
|
||||||
|
break
|
||||||
|
if not skip:
|
||||||
|
selected.append({"answer": t, "count": c["count"], "type": c.get("type", "ngram")})
|
||||||
|
selected_texts.append(t)
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
# Support HTMX partial rendering with suggestion candidates and inline add
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
# Support single-candidate add via HTMX (button posts 'suggestion')
|
||||||
|
if request.htmx and request.POST.get("suggestion"):
|
||||||
|
suggestion = request.POST.get("suggestion").strip()
|
||||||
|
if suggestion:
|
||||||
|
existing = [s for s in question.answer_suggest_incorrect if s]
|
||||||
|
if suggestion not in existing:
|
||||||
|
existing.append(suggestion)
|
||||||
|
question.answer_suggest_incorrect = existing
|
||||||
|
question.save()
|
||||||
|
# Recompute candidates and render fragment
|
||||||
|
form = SuggestIncorrectAnswerForm(instance=question)
|
||||||
|
candidates = build_candidates(question)
|
||||||
|
context = {"form": form, "question": question, "candidates": candidates}
|
||||||
|
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||||
|
|
||||||
|
# Otherwise treat as the full form submission
|
||||||
form = SuggestIncorrectAnswerForm(request.POST, instance=question)
|
form = SuggestIncorrectAnswerForm(request.POST, instance=question)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
obj = form.save()
|
obj = form.save()
|
||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
|
# If HTMX requested, return the fragment so the UI can update in-place
|
||||||
|
if request.htmx:
|
||||||
|
# Recompute candidates and render fragment
|
||||||
|
form = SuggestIncorrectAnswerForm(instance=question)
|
||||||
|
candidates = build_candidates(question)
|
||||||
|
context = {"form": form, "question": question, "candidates": candidates}
|
||||||
|
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||||
|
|
||||||
return render(request, "anatomy/question_detail.html#suggest-incorrect-form", {"question": question})
|
return render(request, "anatomy/question_detail.html#suggest-incorrect-form", {"question": question})
|
||||||
|
|
||||||
return HttpResponse("Invalid")
|
return HttpResponse("Invalid")
|
||||||
else:
|
else:
|
||||||
form = SuggestIncorrectAnswerForm(instance=question)
|
form = SuggestIncorrectAnswerForm(instance=question)
|
||||||
|
candidates = build_candidates(question)
|
||||||
|
context = {"form": form, "question": question, "candidates": candidates}
|
||||||
|
|
||||||
return render(request, "anatomy/suggest_incorrect.html", {"form": form, "question": question})
|
if request.htmx:
|
||||||
|
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||||
|
|
||||||
|
return render(request, "anatomy/suggest_incorrect.html", context)
|
||||||
|
|
||||||
return HttpResponse("Error", status=400)
|
return HttpResponse("Error", status=400)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user