Phase 6: take a test (weighted sampling + question flow)
- internal/sampling: ComputeWeight (Laplace-smoothed error rate + recency
multiplier, floor 0.15) and SelectWeighted (A-Res reservoir algorithm).
10k-run statistical test verifies weak questions appear >3x more often
than mastered, and mastered questions still appear (floor exercised).
- GET/POST /test/new: source filter with live available-count JS update,
n-questions input, weighted vs uniform mode radio.
- GET /test/{id}/q/{n}: deterministic answer shuffle per (test_id,
question_id), progress bar, mobile-friendly large tap targets.
- POST /test/{id}/q/{n}: records answer + upserts stat; advances to next
question or finishes test and redirects to results stub.
- GET /test/{id}/results: stub (Phase 7 will add full review).
- Ownership enforced: all test routes 404 for wrong user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
{{define "content"}}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-1">New Test</h1>
|
||||
{{if .Error}}
|
||||
<div class="mt-3 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3 rounded-md">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if eq .TotalQ 0}}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p class="text-lg">No questions in the library yet.</p>
|
||||
<a href="/upload" class="mt-4 inline-block text-blue-600 hover:underline text-sm">Upload a document first</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<form method="post" action="/test/new" class="space-y-6 max-w-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div>
|
||||
<label for="n-input" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Number of questions
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="number" id="n-input" name="n" value="10"
|
||||
min="1" max="{{.TotalQ}}"
|
||||
class="w-24 border border-gray-300 rounded-md px-3 py-1.5 text-sm shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-500" id="q-avail">
|
||||
{{.TotalQ}} available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .SourceStats}}
|
||||
<div>
|
||||
<label for="source-sel" class="block text-sm font-medium text-gray-700 mb-1">Source filter</label>
|
||||
<select id="source-sel" name="source"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
||||
onchange="(function(sel){
|
||||
var opt=sel.options[sel.selectedIndex];
|
||||
var c=parseInt(opt.dataset.count,10);
|
||||
document.getElementById('q-avail').textContent=c+' available';
|
||||
var ni=document.getElementById('n-input');
|
||||
ni.max=c;
|
||||
if(parseInt(ni.value,10)>c)ni.value=c;
|
||||
})(this)">
|
||||
<option value="" data-count="{{.TotalQ}}">All sources</option>
|
||||
{{range .SourceStats}}
|
||||
<option value="{{.Source}}" data-count="{{.Count}}">{{.Source}} ({{.Count}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 mb-2">Sampling mode</p>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="radio" name="mode" value="weighted" checked
|
||||
class="mt-0.5 text-blue-600 focus:ring-blue-500 flex-shrink-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">Focus on weak spots</p>
|
||||
<p class="text-xs text-gray-500">Questions you get wrong appear more often</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="radio" name="mode" value="uniform"
|
||||
class="mt-0.5 text-blue-600 focus:ring-blue-500 flex-shrink-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">Mix evenly</p>
|
||||
<p class="text-xs text-gray-500">All questions have equal probability</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-md
|
||||
text-sm font-semibold shadow-sm">
|
||||
Start test
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "content"}}
|
||||
<div class="mb-5">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-gray-500">Question {{.N}} of {{.Total}}</span>
|
||||
<span class="text-xs text-gray-400">{{.ProgressPct}}% done</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-500 rounded-full transition-all"
|
||||
style="width: {{.ProgressPct}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-5 mb-5 shadow-sm">
|
||||
<p class="text-gray-800 leading-relaxed text-base">{{.Question.Text}}</p>
|
||||
{{if .Question.Source}}
|
||||
<p class="text-xs text-gray-400 mt-3">{{.Question.Source}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/test/{{.TestID}}/q/{{.N}}">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
{{range .Answers}}
|
||||
<label class="flex items-start gap-4 p-4 bg-white border-2 border-gray-200 rounded-xl
|
||||
cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all
|
||||
has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
|
||||
<input type="radio" name="answer_id" value="{{.ID}}"
|
||||
class="mt-0.5 text-blue-600 focus:ring-blue-500 flex-shrink-0 w-4 h-4">
|
||||
<span class="text-sm text-gray-800 leading-relaxed">{{.Text}}</span>
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 px-4 rounded-xl
|
||||
text-sm font-semibold shadow-sm">
|
||||
{{if lt .N .Total}}Next question{{else}}Finish test{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{define "content"}}
|
||||
<div class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-3">Test Complete!</h1>
|
||||
<p class="text-gray-500 mb-8">Detailed results coming in the next phase.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<a href="/test/new"
|
||||
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-md
|
||||
text-sm font-semibold shadow-sm">
|
||||
Take another test
|
||||
</a>
|
||||
<a href="/"
|
||||
class="inline-block border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2.5
|
||||
rounded-md text-sm font-medium">
|
||||
Library
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user