diff --git a/anatomy/templates/anatomy/partials/suggest_incorrect_fragment.html b/anatomy/templates/anatomy/partials/suggest_incorrect_fragment.html new file mode 100644 index 00000000..ae9b3df7 --- /dev/null +++ b/anatomy/templates/anatomy/partials/suggest_incorrect_fragment.html @@ -0,0 +1,43 @@ +
+
+
Suggest incorrect answers
+
+ {% csrf_token %} +
+ + {{ form.answer_suggest_incorrect }} +
+
+ + +
+
+ + {% if candidates %} +
+
Suggested candidates
+
These are frequent submitted answers that might be suitable to treat as incorrect suggestions.
+ + {% endif %} +
+
+ + diff --git a/anatomy/views.py b/anatomy/views.py index 0861ef29..2f52eb16 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -1332,20 +1332,158 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): @user_is_author_or_anatomy_checker def question_suggest_incorrect_answers(request, 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": + # 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) if form.is_valid(): obj = form.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 HttpResponse("Invalid") else: 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)