Compare commits

...

10 Commits

Author SHA1 Message Date
Jānis Kacēns 5f68a80d14 feat: show random cat picture on results page based on score 2026-05-11 17:00:53 +03:00
Jānis Kacēns 6b486a558a Phase 9: containerise & deploy to Portainer 2026-05-11 16:57:19 +03:00
Jānis Kacēns 2477130dd9 Phase 8: history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:50:55 +03:00
Jānis Kacēns 715c1e4fe5 Phase 7: results & review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:47:01 +03:00
Jānis Kacēns 968479ff51 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>
2026-05-11 13:56:44 +03:00
Jānis Kacēns 177b4e8fd8 Phase 5: library & stats
- Full library home page: question list with per-user mastery stats,
  search/source filter, sort (A-Z, weakest first, most-seen), source
  breakdown in header, Take a test CTA.
- GET/POST /questions/{id}: view and edit question text, source, answers;
  radio-select correct answer; shows seen×/correct% stat.
- POST /questions/{id}/delete: hard delete (cascades to answers via FK).
- repo: ListQuestions supports SortWeakest/SortMostSeen via LEFT JOIN;
  added CountBySource, UpdateQuestion, UpdateAnswers, DeleteQuestion.
- render: added pct template func (correct*100/seen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:49:22 +03:00
Jānis Kacēns 5199c1fa16 Phase 4: upload, LLM extraction, import review flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:15:04 +03:00
Jānis Kacēns e53e7662e9 Phase 3: PDF/DOCX extraction, chunking, LLM client with mock interface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:03:04 +03:00
Jānis Kacēns d9de37d3d8 Phase 2: auth, session management, layout, PWA manifest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:54:37 +03:00
Jānis Kacēns 0bc9160d97 Phase 1: database schema, migrations, repository, user seeding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:38:31 +03:00
51 changed files with 3756 additions and 4 deletions
+11
View File
@@ -0,0 +1,11 @@
data/
*.db
*.db-shm
*.db-wal
.env
.git/
.claude/
node_modules/
out/
tmp/
qbank
+2 -1
View File
@@ -1,5 +1,6 @@
OPENAI_API_KEY=sk-... OPENAI_API_KEY=sk-...
SESSION_SECRET=change-me-to-a-random-32-char-string SESSION_SECRET=change-me-to-a-random-32-char-string
DATA_DIR=./data DATA_DIR=./data
PORT=8080 PORT=8079
ADMIN_USERS=alice:password1,bob:password2 ADMIN_USERS=alice:password1,bob:password2
LLM_MODEL=gpt-4o-mini
+49
View File
@@ -0,0 +1,49 @@
# ── Stage 1: build ────────────────────────────────────────────────────────────
FROM golang:1.25-alpine AS builder
WORKDIR /src
# Install wget for downloading the Tailwind standalone CLI.
RUN apk add --no-cache wget
# Download Tailwind v3 standalone CLI for the target architecture.
# Supported: linux/amd64, linux/arm64.
RUN ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) TW_ARCH=x64 ;; \
aarch64) TW_ARCH=arm64 ;; \
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
esac && \
wget -qO /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-${TW_ARCH}" && \
chmod +x /usr/local/bin/tailwindcss
# Fetch Go module dependencies (cached separately from source).
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the source.
COPY . .
# Compile Tailwind CSS and switch the template from CDN to the compiled file.
RUN tailwindcss -i web/templates/input.css -o web/static/tailwind.css --minify && \
sed -i 's|<script src="https://cdn.tailwindcss.com"></script>|<link rel="stylesheet" href="/static/tailwind.css">|' \
web/templates/layout.html
# Build the Go binary.
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/qbank ./cmd/server
# ── Stage 2: run ──────────────────────────────────────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/qbank /app/qbank
COPY --from=builder /src/web/templates /app/web/templates
COPY --from=builder /src/web/static /app/web/static
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/qbank"]
+104 -1
View File
@@ -2,40 +2,113 @@ package main
import ( import (
"context" "context"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"qbank/internal/auth"
"qbank/internal/config" "qbank/internal/config"
"qbank/internal/db"
"qbank/internal/handlers"
"qbank/internal/llm"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
runHealthcheck()
return
}
cfg := config.Load() cfg := config.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger) slog.SetDefault(logger)
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
slog.Error("create data dir", "err", err)
os.Exit(1)
}
database, err := db.Open(filepath.Join(cfg.DataDir, "qbank.db"))
if err != nil {
slog.Error("open database", "err", err)
os.Exit(1)
}
defer database.Close()
if err := db.Seed(database, cfg.AdminUsers); err != nil {
slog.Error("seed users", "err", err)
os.Exit(1)
}
repo := db.New(database)
authMgr := auth.NewManager(database)
renderer := handlers.NewRenderer("web/templates")
llmClient := llm.New(cfg.OpenAIAPIKey, cfg.LLMModel)
ensureIcons("web/static")
authH := handlers.NewAuthHandler(authMgr, repo, renderer)
homeH := handlers.NewHomeHandler(authMgr, repo, renderer)
uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir)
questionH := handlers.NewQuestionHandler(authMgr, repo, renderer)
testH := handlers.NewTestHandler(authMgr, repo, renderer)
historyH := handlers.NewHistoryHandler(authMgr, repo, renderer)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(requestLogger(logger)) r.Use(requestLogger(logger))
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(authMgr.SM.LoadAndSave)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) w.Write([]byte("OK"))
}) })
r.Get("/sw.js", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/static/sw.js")
})
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
r.Get("/login", authH.LoginGet)
r.Post("/login", authH.LoginPost)
r.Post("/logout", authH.Logout)
r.Group(func(r chi.Router) {
r.Use(authMgr.RequireAuth)
r.Get("/", homeH.Handle)
r.Get("/upload", uploadH.UploadGet)
r.Post("/upload", uploadH.UploadPost)
r.Get("/import/{id}", uploadH.ImportGet)
r.Post("/import/{id}", uploadH.ImportPost)
r.Get("/questions/{id}", questionH.Show)
r.Post("/questions/{id}", questionH.Edit)
r.Post("/questions/{id}/delete", questionH.Delete)
r.Get("/test/new", testH.NewGet)
r.Post("/test/new", testH.NewPost)
r.Get("/test/{id}/q/{n}", testH.QuestionGet)
r.Post("/test/{id}/q/{n}", testH.QuestionPost)
r.Get("/test/{id}/results", testH.ResultsGet)
r.Get("/history", historyH.Handle)
})
srv := &http.Server{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: r, Handler: r,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 120 * time.Second, // LLM extraction can take a while
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
@@ -59,6 +132,36 @@ func main() {
slog.Info("server stopped") slog.Info("server stopped")
} }
func runHealthcheck() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
resp, err := http.Get("http://localhost:" + port + "/healthz")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
}
func ensureIcons(dir string) {
for _, size := range []int{192, 512} {
path := filepath.Join(dir, fmt.Sprintf("icon-%d.png", size))
if _, err := os.Stat(path); err == nil {
continue
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
blue := color.RGBA{R: 37, G: 99, B: 235, A: 255}
draw.Draw(img, img.Bounds(), &image.Uniform{C: blue}, image.Point{}, draw.Src)
f, err := os.Create(path)
if err != nil {
slog.Warn("create icon", "path", path, "err", err)
continue
}
png.Encode(f, img)
f.Close()
}
}
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+23
View File
@@ -0,0 +1,23 @@
services:
qbank:
image: ghcr.io/<your-github-username>/qbank:latest
container_name: qbank
restart: unless-stopped
ports:
- "8080:8080"
environment:
DATA_DIR: /data
PORT: "8080"
OPENAI_API_KEY: ${OPENAI_API_KEY}
SESSION_SECRET: ${SESSION_SECRET}
ADMIN_USERS: ${ADMIN_USERS}
volumes:
- qbank-data:/data
healthcheck:
test: ["CMD", "/app/qbank", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
volumes:
qbank-data:
+18 -1
View File
@@ -1,5 +1,22 @@
module qbank module qbank
go 1.24.6 go 1.25.0
require github.com/go-chi/chi/v5 v5.2.5 require github.com/go-chi/chi/v5 v5.2.5
require (
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sashabaranov/go-openai v1.41.2 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)
+29
View File
@@ -1,2 +1,31 @@
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
+133
View File
@@ -0,0 +1,133 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/hex"
"net/http"
"time"
"github.com/alexedwards/scs/v2"
)
type contextKey string
const ctxUser contextKey = "user"
// User is the authenticated user stored in request context.
type User struct {
ID int64
Name string
}
// SQLiteStore implements scs.Store using a modernc.org/sqlite connection.
type SQLiteStore struct {
db *sql.DB
}
func NewStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Delete(token string) error {
_, err := s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
return err
}
func (s *SQLiteStore) Find(token string) ([]byte, bool, error) {
var data []byte
err := s.db.QueryRow(
"SELECT data FROM sessions WHERE token = ? AND expiry > ?",
token, time.Now().Unix(),
).Scan(&data)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return data, true, nil
}
func (s *SQLiteStore) Commit(token string, b []byte, expiry time.Time) error {
_, err := s.db.Exec(
"INSERT OR REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, ?)",
token, b, expiry.Unix(),
)
return err
}
// Manager wraps scs.SessionManager with application-level helpers.
type Manager struct {
SM *scs.SessionManager
}
func NewManager(db *sql.DB) *Manager {
sm := scs.New()
sm.Store = NewStore(db)
sm.Lifetime = 30 * 24 * time.Hour
sm.Cookie.HttpOnly = true
sm.Cookie.SameSite = http.SameSiteLaxMode
return &Manager{SM: sm}
}
// SetUser persists the authenticated user in the session.
func (m *Manager) SetUser(r *http.Request, id int64, name string) {
m.SM.Put(r.Context(), "userID", id)
m.SM.Put(r.Context(), "userName", name)
}
// ClearUser destroys the session (used on logout).
func (m *Manager) ClearUser(r *http.Request) error {
return m.SM.Destroy(r.Context())
}
// UserFromCtx returns the authenticated user from context, or nil.
func UserFromCtx(ctx context.Context) *User {
u, _ := ctx.Value(ctxUser).(*User)
return u
}
// RequireAuth redirects unauthenticated requests to /login.
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := m.SM.GetInt64(r.Context(), "userID")
if id == 0 {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
name := m.SM.GetString(r.Context(), "userName")
ctx := context.WithValue(r.Context(), ctxUser, &User{ID: id, Name: name})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// CSRFToken returns the session's CSRF token, creating one if needed.
func (m *Manager) CSRFToken(r *http.Request) string {
tok := m.SM.GetString(r.Context(), "csrf")
if tok != "" {
return tok
}
b := make([]byte, 16)
rand.Read(b)
tok = hex.EncodeToString(b)
m.SM.Put(r.Context(), "csrf", tok)
return tok
}
// CheckCSRF returns true if the form's csrf_token matches the session token.
func (m *Manager) CheckCSRF(r *http.Request) bool {
session := m.SM.GetString(r.Context(), "csrf")
form := r.FormValue("csrf_token")
return session != "" && subtle.ConstantTimeCompare([]byte(session), []byte(form)) == 1
}
// SetFlash stores a one-time flash message in the session.
func (m *Manager) SetFlash(r *http.Request, msg string) {
m.SM.Put(r.Context(), "flash", msg)
}
// PopFlash reads and removes the flash message.
func (m *Manager) PopFlash(r *http.Request) string {
return m.SM.PopString(r.Context(), "flash")
}
+3 -1
View File
@@ -11,6 +11,7 @@ type Config struct {
DataDir string DataDir string
Port string Port string
AdminUsers []AdminUser AdminUsers []AdminUser
LLMModel string // defaults to gpt-4o-mini
} }
type AdminUser struct { type AdminUser struct {
@@ -23,7 +24,8 @@ func Load() *Config {
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
SessionSecret: os.Getenv("SESSION_SECRET"), SessionSecret: os.Getenv("SESSION_SECRET"),
DataDir: envOr("DATA_DIR", "./data"), DataDir: envOr("DATA_DIR", "./data"),
Port: envOr("PORT", "8080"), Port: envOr("PORT", "8079"),
LLMModel: envOr("LLM_MODEL", "gpt-4o-mini"),
} }
cfg.AdminUsers = parseAdminUsers(os.Getenv("ADMIN_USERS")) cfg.AdminUsers = parseAdminUsers(os.Getenv("ADMIN_USERS"))
return cfg return cfg
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"database/sql"
_ "embed"
"fmt"
_ "modernc.org/sqlite"
"golang.org/x/crypto/bcrypt"
"qbank/internal/config"
)
//go:embed schema.sql
var schema string
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
for _, stmt := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
} {
if _, err := db.Exec(stmt); err != nil {
db.Close()
return nil, fmt.Errorf("pragma %q: %w", stmt, err)
}
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
return db, nil
}
func Seed(db *sql.DB, users []config.AdminUser) error {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil
}
for _, u := range users {
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password for %q: %w", u.Name, err)
}
if _, err := db.Exec("INSERT INTO users (name, password_hash) VALUES (?, ?)", u.Name, string(hash)); err != nil {
return fmt.Errorf("insert user %q: %w", u.Name, err)
}
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package db_test
import (
"os"
"path/filepath"
"testing"
"qbank/internal/config"
"qbank/internal/db"
)
func TestOpenAndSeed(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer database.Close()
// All tables must exist.
tables := []string{"users", "questions", "answers", "tests", "test_answers", "user_question_stats"}
for _, table := range tables {
var name string
err := database.QueryRow(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
).Scan(&name)
if err != nil {
t.Errorf("table %q missing: %v", table, err)
}
}
// Seed two users.
users := []config.AdminUser{
{Name: "alice", Password: "pass1"},
{Name: "bob", Password: "pass2"},
}
if err := db.Seed(database, users); err != nil {
t.Fatalf("Seed: %v", err)
}
var count int
database.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count != 2 {
t.Errorf("want 2 users, got %d", count)
}
// Second Seed call must be a no-op (users table non-empty).
if err := db.Seed(database, users); err != nil {
t.Fatalf("second Seed: %v", err)
}
database.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count != 2 {
t.Errorf("after second seed: want 2 users, got %d", count)
}
// GetUserByName must find alice.
repo := db.New(database)
u, err := repo.GetUserByName("alice")
if err != nil {
t.Fatalf("GetUserByName: %v", err)
}
if u.Name != "alice" {
t.Errorf("want alice, got %q", u.Name)
}
if u.PasswordHash == "" {
t.Error("password hash is empty")
}
_ = os.Remove(dbPath)
}
+567
View File
@@ -0,0 +1,567 @@
package db
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"qbank/internal/models"
)
const timeLayout = "2006-01-02 15:04:05"
func parseTime(s string) time.Time {
t, _ := time.Parse(timeLayout, s)
return t
}
func parseNullTime(s sql.NullString) sql.NullTime {
if !s.Valid {
return sql.NullTime{}
}
t, err := time.Parse(timeLayout, s.String)
if err != nil {
return sql.NullTime{}
}
return sql.NullTime{Time: t, Valid: true}
}
// QuestionID computes the canonical ID for a question from its text.
func QuestionID(text string) string {
h := sha256.Sum256([]byte(text))
return fmt.Sprintf("%x", h[:8])
}
type SortOrder int
const (
SortAlpha SortOrder = iota // alphabetical by question text
SortWeakest // lowest accuracy first (requires UserID)
SortMostSeen // most-seen first (requires UserID)
)
type ListFilter struct {
Source string
Search string
Sort SortOrder
UserID int64
}
type Repo struct {
db *sql.DB
}
func New(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) CreateUser(name, passwordHash string) (int64, error) {
res, err := r.db.Exec("INSERT INTO users (name, password_hash) VALUES (?, ?)", name, passwordHash)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (r *Repo) GetUserByName(name string) (*models.User, error) {
u := &models.User{}
var createdAt string
err := r.db.QueryRow(
"SELECT id, name, password_hash, created_at FROM users WHERE name = ?", name,
).Scan(&u.ID, &u.Name, &u.PasswordHash, &createdAt)
if err != nil {
return nil, err
}
u.CreatedAt = parseTime(createdAt)
return u, nil
}
// InsertQuestion inserts q and its answers in a transaction. Duplicate questions
// (same text hash) are silently ignored; their answers are not re-inserted.
func (r *Repo) InsertQuestion(q *models.Question, answers []*models.Answer) error {
q.ID = QuestionID(q.Text)
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT OR IGNORE INTO questions (id, text, source) VALUES (?, ?, ?)",
q.ID, q.Text, q.Source,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return tx.Commit() // already exists
}
for i, a := range answers {
a.QuestionID = q.ID
a.Position = i
res, err := tx.Exec(
"INSERT INTO answers (question_id, text, is_correct, position) VALUES (?, ?, ?, ?)",
a.QuestionID, a.Text, a.IsCorrect, a.Position,
)
if err != nil {
return err
}
a.ID, _ = res.LastInsertId()
}
return tx.Commit()
}
func (r *Repo) GetQuestion(id string) (*models.Question, []*models.Answer, error) {
q := &models.Question{}
var createdAt string
err := r.db.QueryRow(
"SELECT id, text, source, created_at FROM questions WHERE id = ?", id,
).Scan(&q.ID, &q.Text, &q.Source, &createdAt)
if err != nil {
return nil, nil, err
}
q.CreatedAt = parseTime(createdAt)
rows, err := r.db.Query(
"SELECT id, question_id, text, is_correct, position FROM answers WHERE question_id = ? ORDER BY position",
id,
)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var answers []*models.Answer
for rows.Next() {
a := &models.Answer{}
if err := rows.Scan(&a.ID, &a.QuestionID, &a.Text, &a.IsCorrect, &a.Position); err != nil {
return nil, nil, err
}
answers = append(answers, a)
}
return q, answers, rows.Err()
}
func (r *Repo) ListQuestions(f ListFilter) ([]*models.Question, error) {
var args []any
join := ""
if f.UserID != 0 && (f.Sort == SortWeakest || f.Sort == SortMostSeen) {
join = " LEFT JOIN user_question_stats s ON s.question_id = q.id AND s.user_id = ?"
args = append(args, f.UserID)
}
var where []string
if f.Source != "" {
where = append(where, "q.source = ?")
args = append(args, f.Source)
}
if f.Search != "" {
where = append(where, "q.text LIKE ?")
args = append(args, "%"+f.Search+"%")
}
query := "SELECT q.id, q.text, q.source, q.created_at FROM questions q" + join
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
switch f.Sort {
case SortWeakest:
query += " ORDER BY CASE WHEN s.times_seen IS NULL OR s.times_seen = 0 THEN 0.0 ELSE CAST(s.times_correct AS REAL) / s.times_seen END ASC, q.text COLLATE NOCASE ASC"
case SortMostSeen:
query += " ORDER BY COALESCE(s.times_seen, 0) DESC, q.text COLLATE NOCASE ASC"
default:
query += " ORDER BY q.text COLLATE NOCASE ASC"
}
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var qs []*models.Question
for rows.Next() {
q := &models.Question{}
var createdAt string
if err := rows.Scan(&q.ID, &q.Text, &q.Source, &createdAt); err != nil {
return nil, err
}
q.CreatedAt = parseTime(createdAt)
qs = append(qs, q)
}
return qs, rows.Err()
}
func (r *Repo) ListSources() ([]string, error) {
rows, err := r.db.Query(
"SELECT DISTINCT source FROM questions WHERE source != '' ORDER BY source",
)
if err != nil {
return nil, err
}
defer rows.Close()
var sources []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
sources = append(sources, s)
}
return sources, rows.Err()
}
// SourceStat holds a source name with its question count.
type SourceStat struct {
Source string
Count int
}
func (r *Repo) CountBySource() ([]SourceStat, error) {
rows, err := r.db.Query(
"SELECT source, COUNT(*) FROM questions WHERE source != '' GROUP BY source ORDER BY source",
)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []SourceStat
for rows.Next() {
var s SourceStat
if err := rows.Scan(&s.Source, &s.Count); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, rows.Err()
}
func (r *Repo) UpdateQuestion(id, text, source string) error {
_, err := r.db.Exec("UPDATE questions SET text = ?, source = ? WHERE id = ?", text, source, id)
return err
}
// AnswerUpdate carries the fields to write for a single answer row.
type AnswerUpdate struct {
ID int64
Text string
IsCorrect bool
}
func (r *Repo) UpdateAnswers(updates []AnswerUpdate) error {
for _, u := range updates {
if _, err := r.db.Exec(
"UPDATE answers SET text = ?, is_correct = ? WHERE id = ?",
u.Text, u.IsCorrect, u.ID,
); err != nil {
return err
}
}
return nil
}
func (r *Repo) DeleteQuestion(id string) error {
_, err := r.db.Exec("DELETE FROM questions WHERE id = ?", id)
return err
}
func (r *Repo) CountQuestions() (int, error) {
var n int
return n, r.db.QueryRow("SELECT COUNT(*) FROM questions").Scan(&n)
}
func (r *Repo) CountAnswers() (int, error) {
var n int
return n, r.db.QueryRow("SELECT COUNT(*) FROM answers").Scan(&n)
}
func (r *Repo) CreateTest(userID int64, questionIDs []string) (int64, error) {
ids, err := json.Marshal(questionIDs)
if err != nil {
return 0, err
}
res, err := r.db.Exec(
"INSERT INTO tests (user_id, n_questions, question_ids) VALUES (?, ?, ?)",
userID, len(questionIDs), string(ids),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (r *Repo) RecordAnswer(testID int64, questionID string, selectedAnswerID *int64, isCorrect bool) error {
_, err := r.db.Exec(`
INSERT INTO test_answers (test_id, question_id, selected_answer_id, is_correct, answered_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (test_id, question_id) DO UPDATE SET
selected_answer_id = excluded.selected_answer_id,
is_correct = excluded.is_correct,
answered_at = excluded.answered_at`,
testID, questionID, selectedAnswerID, isCorrect,
)
return err
}
func (r *Repo) FinishTest(id int64) error {
_, err := r.db.Exec("UPDATE tests SET completed_at = CURRENT_TIMESTAMP WHERE id = ?", id)
return err
}
func (r *Repo) GetTest(id int64) (*models.Test, error) {
t := &models.Test{}
var createdAt string
var completedAt sql.NullString
var ids string
err := r.db.QueryRow(
"SELECT id, user_id, created_at, completed_at, n_questions, question_ids FROM tests WHERE id = ?", id,
).Scan(&t.ID, &t.UserID, &createdAt, &completedAt, &t.NQuestions, &ids)
if err != nil {
return nil, err
}
t.CreatedAt = parseTime(createdAt)
t.CompletedAt = parseNullTime(completedAt)
if err := json.Unmarshal([]byte(ids), &t.QuestionIDs); err != nil {
return nil, err
}
return t, nil
}
func (r *Repo) ListTestsForUser(userID int64) ([]*models.Test, error) {
rows, err := r.db.Query(`
SELECT id, user_id, created_at, completed_at, n_questions, question_ids
FROM tests WHERE user_id = ? ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var tests []*models.Test
for rows.Next() {
t := &models.Test{}
var createdAt string
var completedAt sql.NullString
var ids string
if err := rows.Scan(&t.ID, &t.UserID, &createdAt, &completedAt, &t.NQuestions, &ids); err != nil {
return nil, err
}
t.CreatedAt = parseTime(createdAt)
t.CompletedAt = parseNullTime(completedAt)
if err := json.Unmarshal([]byte(ids), &t.QuestionIDs); err != nil {
return nil, err
}
tests = append(tests, t)
}
return tests, rows.Err()
}
func (r *Repo) GetTestAnswers(testID int64) ([]*models.TestAnswer, error) {
rows, err := r.db.Query(`
SELECT test_id, question_id, selected_answer_id, is_correct, answered_at
FROM test_answers WHERE test_id = ?`, testID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var answers []*models.TestAnswer
for rows.Next() {
ta := &models.TestAnswer{}
var answeredAt sql.NullString
if err := rows.Scan(&ta.TestID, &ta.QuestionID, &ta.SelectedAnswerID, &ta.IsCorrect, &answeredAt); err != nil {
return nil, err
}
ta.AnsweredAt = parseNullTime(answeredAt)
answers = append(answers, ta)
}
return answers, rows.Err()
}
func (r *Repo) UpsertStat(userID int64, questionID string, gotItRight bool) error {
correct := 0
if gotItRight {
correct = 1
}
_, err := r.db.Exec(`
INSERT INTO user_question_stats (user_id, question_id, times_seen, times_correct, last_seen_at)
VALUES (?, ?, 1, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, question_id) DO UPDATE SET
times_seen = times_seen + 1,
times_correct = times_correct + excluded.times_correct,
last_seen_at = CURRENT_TIMESTAMP`,
userID, questionID, correct,
)
return err
}
func (r *Repo) GetStatsForUser(userID int64, questionIDs []string) (map[string]*models.UserQuestionStat, error) {
result := make(map[string]*models.UserQuestionStat, len(questionIDs))
if len(questionIDs) == 0 {
return result, nil
}
placeholders := make([]string, len(questionIDs))
args := make([]any, 0, len(questionIDs)+1)
args = append(args, userID)
for i, id := range questionIDs {
placeholders[i] = "?"
args = append(args, id)
}
rows, err := r.db.Query(fmt.Sprintf(`
SELECT user_id, question_id, times_seen, times_correct, last_seen_at
FROM user_question_stats
WHERE user_id = ? AND question_id IN (%s)`,
strings.Join(placeholders, ",")),
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
s := &models.UserQuestionStat{}
var lastSeen sql.NullString
if err := rows.Scan(&s.UserID, &s.QuestionID, &s.TimesSeen, &s.TimesCorrect, &lastSeen); err != nil {
return nil, err
}
s.LastSeenAt = parseNullTime(lastSeen)
result[s.QuestionID] = s
}
return result, rows.Err()
}
// ── History ──────────────────────────────────────────────────────────────────
// GetCorrectCountsForUser returns a map of test_id → correct-answer count for
// all completed tests belonging to userID.
func (r *Repo) GetCorrectCountsForUser(userID int64) (map[int64]int, error) {
rows, err := r.db.Query(`
SELECT ta.test_id, SUM(CASE WHEN ta.is_correct = 1 THEN 1 ELSE 0 END)
FROM test_answers ta
JOIN tests t ON ta.test_id = t.id
WHERE t.user_id = ? AND t.completed_at IS NOT NULL
GROUP BY ta.test_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[int64]int)
for rows.Next() {
var testID int64
var correct int
if err := rows.Scan(&testID, &correct); err != nil {
return nil, err
}
result[testID] = correct
}
return result, rows.Err()
}
// GetAggregateStats returns total correct and total answered across all
// completed tests for userID.
func (r *Repo) GetAggregateStats(userID int64) (totalCorrect, totalAnswered int, err error) {
err = r.db.QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN ta.is_correct = 1 THEN 1 ELSE 0 END), 0),
COALESCE(COUNT(ta.question_id), 0)
FROM test_answers ta
JOIN tests t ON ta.test_id = t.id
WHERE t.user_id = ? AND t.completed_at IS NOT NULL`, userID,
).Scan(&totalCorrect, &totalAnswered)
return
}
// WeakSpot is a question the user has answered incorrectly more than once.
type WeakSpot struct {
QuestionID string
QuestionText string
TimesWrong int
TimesSeen int
}
// GetWeakSpots returns up to 10 questions the user has gotten wrong more than
// once, ordered by wrong-answer count descending.
func (r *Repo) GetWeakSpots(userID int64) ([]*WeakSpot, error) {
rows, err := r.db.Query(`
SELECT uqs.question_id, q.text, uqs.times_seen,
(uqs.times_seen - uqs.times_correct) AS times_wrong
FROM user_question_stats uqs
JOIN questions q ON uqs.question_id = q.id
WHERE uqs.user_id = ? AND (uqs.times_seen - uqs.times_correct) > 1
ORDER BY times_wrong DESC, uqs.times_seen DESC
LIMIT 10`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var spots []*WeakSpot
for rows.Next() {
s := &WeakSpot{}
if err := rows.Scan(&s.QuestionID, &s.QuestionText, &s.TimesSeen, &s.TimesWrong); err != nil {
return nil, err
}
spots = append(spots, s)
}
return spots, rows.Err()
}
// ── Draft (import review) ────────────────────────────────────────────────────
func newDraftID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func (r *Repo) CreateDraft(userID int64, source string, questions []models.DraftQuestion) (string, error) {
data, err := json.Marshal(questions)
if err != nil {
return "", err
}
id := newDraftID()
_, err = r.db.Exec(
"INSERT INTO import_drafts (id, user_id, source, questions) VALUES (?, ?, ?, ?)",
id, userID, source, string(data),
)
if err != nil {
return "", err
}
return id, nil
}
func (r *Repo) GetDraftForUser(id string, userID int64) (*models.Draft, error) {
d := &models.Draft{}
var questionsJSON, createdAt string
err := r.db.QueryRow(
"SELECT id, user_id, source, questions, created_at FROM import_drafts WHERE id = ? AND user_id = ?",
id, userID,
).Scan(&d.ID, &d.UserID, &d.Source, &questionsJSON, &createdAt)
if err != nil {
return nil, err
}
d.CreatedAt = parseTime(createdAt)
if err := json.Unmarshal([]byte(questionsJSON), &d.Questions); err != nil {
return nil, err
}
return d, nil
}
func (r *Repo) DeleteDraft(id string) error {
_, err := r.db.Exec("DELETE FROM import_drafts WHERE id = ?", id)
return err
}
+67
View File
@@ -0,0 +1,67 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
source TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL,
position INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
n_questions INTEGER NOT NULL,
question_ids TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS test_answers (
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
question_id TEXT NOT NULL REFERENCES questions(id),
selected_answer_id INTEGER REFERENCES answers(id),
is_correct BOOLEAN,
answered_at DATETIME,
PRIMARY KEY (test_id, question_id)
);
CREATE TABLE IF NOT EXISTS user_question_stats (
user_id INTEGER NOT NULL REFERENCES users(id),
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
times_seen INTEGER NOT NULL DEFAULT 0,
times_correct INTEGER NOT NULL DEFAULT 0,
last_seen_at DATETIME,
PRIMARY KEY (user_id, question_id)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry);
CREATE TABLE IF NOT EXISTS import_drafts (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
source TEXT NOT NULL DEFAULT '',
questions TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_test_answers_test ON test_answers(test_id);
CREATE INDEX IF NOT EXISTS idx_answers_question ON answers(question_id);
CREATE INDEX IF NOT EXISTS idx_stats_user ON user_question_stats(user_id);
+63
View File
@@ -0,0 +1,63 @@
package handlers
import (
"log/slog"
"net/http"
"golang.org/x/crypto/bcrypt"
"qbank/internal/auth"
"qbank/internal/db"
)
type AuthHandler struct {
auth *auth.Manager
repo *db.Repo
render *Renderer
}
func NewAuthHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *AuthHandler {
return &AuthHandler{auth: a, repo: repo, render: r}
}
func (h *AuthHandler) LoginGet(w http.ResponseWriter, r *http.Request) {
h.render.Render(w, http.StatusOK, "login", map[string]any{
"CSRFToken": h.auth.CSRFToken(r),
})
}
func (h *AuthHandler) LoginPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
user, err := h.repo.GetUserByName(username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
slog.Info("login failed", "username", username)
h.render.Render(w, http.StatusUnauthorized, "login", map[string]any{
"CSRFToken": h.auth.CSRFToken(r),
"Error": "Invalid username or password.",
})
return
}
h.auth.SetUser(r, user.ID, user.Name)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
if err := h.auth.ClearUser(r); err != nil {
slog.Error("logout", "err", err)
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+76
View File
@@ -0,0 +1,76 @@
package handlers
import (
"log/slog"
"net/http"
"qbank/internal/auth"
"qbank/internal/db"
"qbank/internal/models"
)
type HistoryHandler struct {
auth *auth.Manager
repo *db.Repo
render *Renderer
}
func NewHistoryHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *HistoryHandler {
return &HistoryHandler{auth: a, repo: repo, render: r}
}
// TestHistoryItem pairs a test with its correct-answer count.
type TestHistoryItem struct {
*models.Test
NCorrect int
}
func (h *HistoryHandler) Handle(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromCtx(r.Context())
tests, err := h.repo.ListTestsForUser(user.ID)
if err != nil {
slog.Error("list tests for history", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
correctCounts, err := h.repo.GetCorrectCountsForUser(user.ID)
if err != nil {
slog.Error("get correct counts", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
items := make([]TestHistoryItem, 0, len(tests))
for _, t := range tests {
if !t.CompletedAt.Valid {
continue // skip in-progress tests
}
items = append(items, TestHistoryItem{
Test: t,
NCorrect: correctCounts[t.ID],
})
}
totalCorrect, totalAnswered, err := h.repo.GetAggregateStats(user.ID)
if err != nil {
slog.Error("get aggregate stats", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
weakSpots, err := h.repo.GetWeakSpots(user.ID)
if err != nil {
slog.Error("get weak spots", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
data := BaseData(h.auth, r)
data["Items"] = items
data["TotalCorrect"] = totalCorrect
data["TotalAnswered"] = totalAnswered
data["WeakSpots"] = weakSpots
h.render.Render(w, http.StatusOK, "history", data)
}
+89
View File
@@ -0,0 +1,89 @@
package handlers
import (
"log/slog"
"net/http"
"qbank/internal/auth"
"qbank/internal/db"
"qbank/internal/models"
)
// QuestionRow pairs a question with its per-user stat for the library display.
type QuestionRow struct {
Q *models.Question
Stat *models.UserQuestionStat // nil = never seen in a test
}
type HomeHandler struct {
auth *auth.Manager
repo *db.Repo
render *Renderer
}
func NewHomeHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *HomeHandler {
return &HomeHandler{auth: a, repo: repo, render: r}
}
func (h *HomeHandler) Handle(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromCtx(r.Context())
sortParam := r.URL.Query().Get("sort")
search := r.URL.Query().Get("q")
source := r.URL.Query().Get("source")
var sortOrder db.SortOrder
switch sortParam {
case "weakest":
sortOrder = db.SortWeakest
case "seen":
sortOrder = db.SortMostSeen
default:
sortParam = "alpha"
sortOrder = db.SortAlpha
}
questions, err := h.repo.ListQuestions(db.ListFilter{
Source: source,
Search: search,
Sort: sortOrder,
UserID: user.ID,
})
if err != nil {
slog.Error("list questions", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
ids := make([]string, len(questions))
for i, q := range questions {
ids[i] = q.ID
}
stats, err := h.repo.GetStatsForUser(user.ID, ids)
if err != nil {
slog.Error("get stats", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
rows := make([]QuestionRow, len(questions))
for i, q := range questions {
rows[i] = QuestionRow{Q: q, Stat: stats[q.ID]}
}
totalQ, _ := h.repo.CountQuestions()
totalA, _ := h.repo.CountAnswers()
sourceCounts, _ := h.repo.CountBySource()
data := BaseData(h.auth, r)
data["Questions"] = rows
data["TotalQ"] = totalQ
data["TotalA"] = totalA
data["SourceStats"] = sourceCounts
data["Sort"] = sortParam
data["Search"] = search
data["SelectedSource"] = source
h.render.Render(w, http.StatusOK, "home", data)
}
+112
View File
@@ -0,0 +1,112 @@
package handlers
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"qbank/internal/auth"
"qbank/internal/db"
)
type QuestionHandler struct {
auth *auth.Manager
repo *db.Repo
render *Renderer
}
func NewQuestionHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *QuestionHandler {
return &QuestionHandler{auth: a, repo: repo, render: r}
}
func (h *QuestionHandler) Show(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user := auth.UserFromCtx(r.Context())
q, answers, err := h.repo.GetQuestion(id)
if err != nil {
HTTPError(w, http.StatusNotFound)
return
}
stats, _ := h.repo.GetStatsForUser(user.ID, []string{id})
data := BaseData(h.auth, r)
data["Question"] = q
data["Answers"] = answers
data["Stat"] = stats[id]
h.render.Render(w, http.StatusOK, "question", data)
}
func (h *QuestionHandler) Edit(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := r.ParseForm(); err != nil {
HTTPError(w, http.StatusBadRequest)
return
}
_, answers, err := h.repo.GetQuestion(id)
if err != nil {
HTTPError(w, http.StatusNotFound)
return
}
text := r.FormValue("q_text")
source := r.FormValue("source")
correctIdx, _ := strconv.Atoi(r.FormValue("correct"))
if err := h.repo.UpdateQuestion(id, text, source); err != nil {
slog.Error("update question", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
updates := make([]db.AnswerUpdate, len(answers))
for i, a := range answers {
updates[i] = db.AnswerUpdate{
ID: a.ID,
Text: r.FormValue(fmt.Sprintf("a_text_%d", i)),
IsCorrect: i == correctIdx,
}
}
if err := h.repo.UpdateAnswers(updates); err != nil {
slog.Error("update answers", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
h.auth.SetFlash(r, "Question saved.")
http.Redirect(w, r, "/questions/"+id, http.StatusSeeOther)
}
func (h *QuestionHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
r.ParseForm()
if err := h.repo.DeleteQuestion(id); err != nil {
slog.Error("delete question", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
h.auth.SetFlash(r, "Question deleted.")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+59
View File
@@ -0,0 +1,59 @@
package handlers
import (
"html/template"
"log/slog"
"net/http"
"path/filepath"
"qbank/internal/auth"
)
var tmplFuncs = template.FuncMap{
"inc": func(i int) int { return i + 1 },
"pct": func(correct, seen int) int {
if seen == 0 {
return 0
}
return correct * 100 / seen
},
}
// Renderer parses and executes HTML templates from a directory.
type Renderer struct {
dir string
}
func NewRenderer(dir string) *Renderer { return &Renderer{dir: dir} }
// Render executes layout.html + <name>.html, passing data to the "layout" template.
func (r *Renderer) Render(w http.ResponseWriter, status int, name string, data any) {
t, err := template.New("").Funcs(tmplFuncs).ParseFiles(
filepath.Join(r.dir, "layout.html"),
filepath.Join(r.dir, name+".html"),
)
if err != nil {
slog.Error("parse template", "name", name, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
slog.Error("execute template", "name", name, "err", err)
}
}
// HTTPError writes a plain-text HTTP error.
func HTTPError(w http.ResponseWriter, status int) {
http.Error(w, http.StatusText(status), status)
}
// BaseData builds the common template map (User, CSRFToken, Flash).
func BaseData(a *auth.Manager, r *http.Request) map[string]any {
return map[string]any{
"User": auth.UserFromCtx(r.Context()),
"CSRFToken": a.CSRFToken(r),
"Flash": a.PopFlash(r),
}
}
+393
View File
@@ -0,0 +1,393 @@
package handlers
import (
"encoding/binary"
"encoding/hex"
"fmt"
"log/slog"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"qbank/internal/auth"
"qbank/internal/db"
"qbank/internal/models"
"qbank/internal/sampling"
)
// happyCatThreshold is the minimum score percentage to show a happy cat.
const happyCatThreshold = 60
type TestHandler struct {
auth *auth.Manager
repo *db.Repo
render *Renderer
}
func NewTestHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *TestHandler {
return &TestHandler{auth: a, repo: repo, render: r}
}
func (h *TestHandler) NewGet(w http.ResponseWriter, r *http.Request) {
totalQ, _ := h.repo.CountQuestions()
sourceCounts, _ := h.repo.CountBySource()
data := BaseData(h.auth, r)
data["TotalQ"] = totalQ
data["SourceStats"] = sourceCounts
h.render.Render(w, http.StatusOK, "test_new", data)
}
func (h *TestHandler) NewPost(w http.ResponseWriter, r *http.Request) {
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := r.ParseForm(); err != nil {
HTTPError(w, http.StatusBadRequest)
return
}
user := auth.UserFromCtx(r.Context())
source := r.FormValue("source")
mode := r.FormValue("mode")
n, _ := strconv.Atoi(r.FormValue("n"))
if n <= 0 {
n = 10
}
questions, err := h.repo.ListQuestions(db.ListFilter{Source: source})
if err != nil {
slog.Error("list questions for test", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
if len(questions) == 0 {
totalQ, _ := h.repo.CountQuestions()
sourceCounts, _ := h.repo.CountBySource()
data := BaseData(h.auth, r)
data["TotalQ"] = totalQ
data["SourceStats"] = sourceCounts
data["Error"] = "No questions available for the selected filter."
h.render.Render(w, http.StatusOK, "test_new", data)
return
}
if n > len(questions) {
n = len(questions)
}
ids := make([]string, len(questions))
for i, q := range questions {
ids[i] = q.ID
}
var candidates []sampling.Candidate
if mode == "uniform" {
candidates = make([]sampling.Candidate, len(questions))
for i, q := range questions {
candidates[i] = sampling.Candidate{ID: q.ID, Weight: 1.0}
}
} else {
stats, err := h.repo.GetStatsForUser(user.ID, ids)
if err != nil {
slog.Error("get stats for test", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
now := time.Now()
candidates = make([]sampling.Candidate, len(questions))
for i, q := range questions {
candidates[i] = sampling.Candidate{
ID: q.ID,
Weight: sampling.ComputeWeight(stats[q.ID], now),
}
}
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
picked := sampling.SelectWeighted(candidates, n, rng)
// Shuffle presentation order independently of selection order.
rng.Shuffle(len(picked), func(i, j int) { picked[i], picked[j] = picked[j], picked[i] })
pickedIDs := make([]string, len(picked))
for i, c := range picked {
pickedIDs[i] = c.ID
}
testID, err := h.repo.CreateTest(user.ID, pickedIDs)
if err != nil {
slog.Error("create test", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/test/%d/q/1", testID), http.StatusSeeOther)
}
func (h *TestHandler) QuestionGet(w http.ResponseWriter, r *http.Request) {
n, test, ok := h.loadTestAndN(w, r)
if !ok {
return
}
questionID := test.QuestionIDs[n-1]
q, answers, err := h.repo.GetQuestion(questionID)
if err != nil {
slog.Error("get question for test", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
data := BaseData(h.auth, r)
data["TestID"] = test.ID
data["N"] = n
data["Total"] = test.NQuestions
data["Question"] = q
data["Answers"] = deterministicShuffle(answers, test.ID, questionID)
data["ProgressPct"] = (n - 1) * 100 / test.NQuestions
h.render.Render(w, http.StatusOK, "test_question", data)
}
func (h *TestHandler) QuestionPost(w http.ResponseWriter, r *http.Request) {
n, test, ok := h.loadTestAndN(w, r)
if !ok {
return
}
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := r.ParseForm(); err != nil {
HTTPError(w, http.StatusBadRequest)
return
}
user := auth.UserFromCtx(r.Context())
questionID := test.QuestionIDs[n-1]
_, answers, err := h.repo.GetQuestion(questionID)
if err != nil {
slog.Error("get question for answer", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
var selectedID *int64
var isCorrect bool
if rawID := r.FormValue("answer_id"); rawID != "" {
id, err := strconv.ParseInt(rawID, 10, 64)
if err == nil {
selectedID = &id
for _, a := range answers {
if a.ID == id && a.IsCorrect {
isCorrect = true
break
}
}
}
}
if err := h.repo.RecordAnswer(test.ID, questionID, selectedID, isCorrect); err != nil {
slog.Error("record answer", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
if err := h.repo.UpsertStat(user.ID, questionID, isCorrect); err != nil {
slog.Error("upsert stat", "err", err)
}
if n < test.NQuestions {
http.Redirect(w, r, fmt.Sprintf("/test/%d/q/%d", test.ID, n+1), http.StatusSeeOther)
return
}
if err := h.repo.FinishTest(test.ID); err != nil {
slog.Error("finish test", "err", err)
}
http.Redirect(w, r, fmt.Sprintf("/test/%d/results", test.ID), http.StatusSeeOther)
}
// ResultItem holds per-question data for the results page.
type ResultItem struct {
Question *models.Question
Answers []*ResultAnswer
UserRight bool // user selected the correct answer
Unanswered bool // user skipped without selecting
}
// ResultAnswer annotates each answer with display markers.
type ResultAnswer struct {
*models.Answer
UserPicked bool // user selected this answer
}
func (h *TestHandler) ResultsGet(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromCtx(r.Context())
testID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
HTTPError(w, http.StatusNotFound)
return
}
test, err := h.repo.GetTest(testID)
if err != nil || test.UserID != user.ID {
HTTPError(w, http.StatusNotFound)
return
}
testAnswers, err := h.repo.GetTestAnswers(testID)
if err != nil {
slog.Error("get test answers", "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
// Index test answers by question ID for quick lookup.
taByQ := make(map[string]*models.TestAnswer, len(testAnswers))
for _, ta := range testAnswers {
taByQ[ta.QuestionID] = ta
}
var items []ResultItem
nCorrect := 0
for _, qid := range test.QuestionIDs {
q, answers, err := h.repo.GetQuestion(qid)
if err != nil {
slog.Error("get question for results", "qid", qid, "err", err)
HTTPError(w, http.StatusInternalServerError)
return
}
ta := taByQ[qid]
var selectedID int64
unanswered := ta == nil || !ta.SelectedAnswerID.Valid
if !unanswered {
selectedID = ta.SelectedAnswerID.Int64
}
userRight := ta != nil && ta.IsCorrect.Valid && ta.IsCorrect.Bool
if userRight {
nCorrect++
}
ra := make([]*ResultAnswer, len(answers))
for i, a := range answers {
ra[i] = &ResultAnswer{
Answer: a,
UserPicked: !unanswered && a.ID == selectedID,
}
}
items = append(items, ResultItem{
Question: q,
Answers: ra,
UserRight: userRight,
Unanswered: unanswered,
})
}
var timeTaken string
if test.CompletedAt.Valid {
d := test.CompletedAt.Time.Sub(test.CreatedAt).Round(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
if h > 0 {
timeTaken = fmt.Sprintf("%dh %dm %ds", h, m, s)
} else if m > 0 {
timeTaken = fmt.Sprintf("%dm %ds", m, s)
} else {
timeTaken = fmt.Sprintf("%ds", s)
}
}
mood := "sad"
if test.NQuestions > 0 && nCorrect*100/test.NQuestions >= happyCatThreshold {
mood = "happy"
}
catURL := randomCatURL(mood)
data := BaseData(h.auth, r)
data["Test"] = test
data["Items"] = items
data["NCorrect"] = nCorrect
data["TimeTaken"] = timeTaken
data["CatURL"] = catURL
h.render.Render(w, http.StatusOK, "test_results", data)
}
// loadTestAndN extracts and validates the test ID and question number (n) from
// URL params. Returns the 1-based question index and the test, or writes an
// error and returns false.
func (h *TestHandler) loadTestAndN(w http.ResponseWriter, r *http.Request) (int, *models.Test, bool) {
user := auth.UserFromCtx(r.Context())
testID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
HTTPError(w, http.StatusNotFound)
return 0, nil, false
}
n, err := strconv.Atoi(chi.URLParam(r, "n"))
if err != nil {
HTTPError(w, http.StatusNotFound)
return 0, nil, false
}
test, err := h.repo.GetTest(testID)
if err != nil || test.UserID != user.ID {
HTTPError(w, http.StatusNotFound)
return 0, nil, false
}
if n < 1 || n > test.NQuestions {
HTTPError(w, http.StatusNotFound)
return 0, nil, false
}
return n, test, true
}
// randomCatURL picks a random image from web/static/cats/<mood>/ and returns
// its URL path, or an empty string if the folder is missing or empty.
func randomCatURL(mood string) string {
dir := "web/static/cats/" + mood
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
var images []string
for _, e := range entries {
if e.IsDir() {
continue
}
n := strings.ToLower(e.Name())
if strings.HasSuffix(n, ".jpg") || strings.HasSuffix(n, ".jpeg") ||
strings.HasSuffix(n, ".png") || strings.HasSuffix(n, ".gif") ||
strings.HasSuffix(n, ".webp") {
images = append(images, e.Name())
}
}
if len(images) == 0 {
return ""
}
return "/static/cats/" + mood + "/" + images[rand.Intn(len(images))]
}
// deterministicShuffle returns a copy of answers shuffled by a seed derived
// from the test ID and question ID, so the order is stable across page reloads.
func deterministicShuffle(answers []*models.Answer, testID int64, questionID string) []*models.Answer {
b, _ := hex.DecodeString(questionID)
qInt := int64(binary.BigEndian.Uint64(b))
rng := rand.New(rand.NewSource(testID ^ qInt))
out := make([]*models.Answer, len(answers))
copy(out, answers)
rng.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
return out
}
+233
View File
@@ -0,0 +1,233 @@
package handlers
import (
"bytes"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"qbank/internal/auth"
"qbank/internal/db"
"qbank/internal/llm"
"qbank/internal/models"
"qbank/internal/parse"
)
type UploadHandler struct {
auth *auth.Manager
repo *db.Repo
llm *llm.Client
render *Renderer
dataDir string
}
func NewUploadHandler(a *auth.Manager, repo *db.Repo, llmClient *llm.Client, r *Renderer, dataDir string) *UploadHandler {
return &UploadHandler{auth: a, repo: repo, llm: llmClient, render: r, dataDir: dataDir}
}
func (h *UploadHandler) UploadGet(w http.ResponseWriter, r *http.Request) {
data := BaseData(h.auth, r)
h.render.Render(w, http.StatusOK, "upload", data)
}
func (h *UploadHandler) UploadPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MB
renderErr := func(msg string) {
data := BaseData(h.auth, r)
data["Error"] = msg
h.render.Render(w, http.StatusUnprocessableEntity, "upload", data)
}
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
renderErr("File too large (max 20 MB).")
return
}
file, header, err := r.FormFile("file")
if err != nil {
renderErr("No file selected.")
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".pdf" && ext != ".docx" {
renderErr("Unsupported file type. Upload a .pdf or .docx file.")
return
}
// Read file once; use for both saving and extraction.
data, err := io.ReadAll(file)
if err != nil {
renderErr("Failed to read uploaded file.")
return
}
// Save to disk.
uploadsDir := filepath.Join(h.dataDir, "uploads")
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
renderErr("Server error: could not create uploads directory.")
return
}
safeName := filepath.Base(header.Filename)
savedPath := filepath.Join(uploadsDir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), safeName))
if err := os.WriteFile(savedPath, data, 0644); err != nil {
renderErr("Server error: could not save file.")
return
}
// Extract text.
var text string
switch ext {
case ".pdf":
text, err = parse.ExtractPDF(bytes.NewReader(data))
if errors.Is(err, parse.ErrScanPDF) {
renderErr("This PDF appears to be image-only (scan-based). Please convert it to a text PDF first.")
return
}
case ".docx":
text, err = parse.ExtractDOCX(bytes.NewReader(data))
}
if err != nil {
renderErr("Could not extract text from the document: " + err.Error())
return
}
// Chunk and call LLM.
chunks := parse.Chunk(text, 10_000)
seen := make(map[string]bool)
var draftQs []models.DraftQuestion
for _, chunk := range chunks {
qs, err := h.llm.ExtractQuestions(r.Context(), chunk)
if err != nil {
slog.Warn("llm chunk failed", "err", err)
continue
}
for _, q := range qs {
key := db.QuestionID(q.Question)
if seen[key] {
continue
}
seen[key] = true
dq := models.DraftQuestion{Text: q.Question}
for _, a := range q.Answers {
dq.Answers = append(dq.Answers, models.DraftAnswer{Text: a.Text, IsCorrect: a.Correct})
}
draftQs = append(draftQs, dq)
}
}
source := strings.TrimSpace(r.FormValue("source"))
if source == "" {
source = strings.TrimSuffix(safeName, ext)
}
userID := auth.UserFromCtx(r.Context()).ID
draftID, err := h.repo.CreateDraft(userID, source, draftQs)
if err != nil {
renderErr("Server error: could not create import draft.")
return
}
http.Redirect(w, r, "/import/"+draftID, http.StatusSeeOther)
}
func (h *UploadHandler) ImportGet(w http.ResponseWriter, r *http.Request) {
draftID := chi.URLParam(r, "id")
userID := auth.UserFromCtx(r.Context()).ID
draft, err := h.repo.GetDraftForUser(draftID, userID)
if err != nil {
HTTPError(w, http.StatusNotFound)
return
}
data := BaseData(h.auth, r)
data["Draft"] = draft
h.render.Render(w, http.StatusOK, "import", data)
}
func (h *UploadHandler) ImportPost(w http.ResponseWriter, r *http.Request) {
draftID := chi.URLParam(r, "id")
userID := auth.UserFromCtx(r.Context()).ID
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
draft, err := h.repo.GetDraftForUser(draftID, userID)
if err != nil {
HTTPError(w, http.StatusNotFound)
return
}
source := strings.TrimSpace(r.FormValue("source"))
var imported, skipped int
for i, dq := range draft.Questions {
if r.FormValue(fmt.Sprintf("delete_%d", i)) == "on" {
skipped++
continue
}
text := strings.TrimSpace(r.FormValue(fmt.Sprintf("q_text_%d", i)))
if text == "" {
skipped++
continue
}
correctIdx := r.FormValue(fmt.Sprintf("correct_%d", i))
var answers []*models.Answer
for j := range dq.Answers {
aText := strings.TrimSpace(r.FormValue(fmt.Sprintf("a_text_%d_%d", i, j)))
if aText == "" {
continue
}
answers = append(answers, &models.Answer{
Text: aText,
IsCorrect: fmt.Sprintf("%d", j) == correctIdx,
})
}
var nCorrect int
for _, a := range answers {
if a.IsCorrect {
nCorrect++
}
}
if len(answers) < 2 || nCorrect != 1 {
skipped++
continue
}
q := &models.Question{Text: text, Source: source}
if err := h.repo.InsertQuestion(q, answers); err != nil {
slog.Error("insert question", "err", err)
skipped++
continue
}
imported++
}
if err := h.repo.DeleteDraft(draftID); err != nil {
slog.Error("delete draft", "id", draftID, "err", err)
}
h.auth.SetFlash(r, fmt.Sprintf("Imported %d question(s), %d skipped.", imported, skipped))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+119
View File
@@ -0,0 +1,119 @@
package llm
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
openai "github.com/sashabaranov/go-openai"
)
// ParsedQuestion is a question extracted from a document chunk by the LLM.
type ParsedQuestion struct {
Question string
Answers []ParsedAnswer
}
// ParsedAnswer is one answer choice for a ParsedQuestion.
type ParsedAnswer struct {
Text string
Correct bool
}
// ChatClient is the interface for creating chat completions.
// The concrete *openai.Client satisfies this interface.
type ChatClient interface {
CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error)
}
// Client wraps a ChatClient with question-extraction logic.
type Client struct {
cc ChatClient
model string
}
// New creates a Client backed by the real OpenAI API.
func New(apiKey, model string) *Client {
if model == "" {
model = "gpt-4o-mini"
}
return &Client{cc: openai.NewClient(apiKey), model: model}
}
// NewWithClient creates a Client with an injected ChatClient (useful for tests).
func NewWithClient(cc ChatClient, model string) *Client {
return &Client{cc: cc, model: model}
}
const systemPrompt = `You extract multiple-choice questions from study material. Return every question found. Exactly one answer per question must be marked correct. If the source doesn't clearly mark a correct answer, omit that question entirely. Do not invent questions not present in the text.
Respond with JSON matching this schema exactly:
{"questions":[{"question":"<text>","answers":[{"text":"<text>","correct":false},{"text":"<text>","correct":true}]}]}`
type llmResponse struct {
Questions []struct {
Question string `json:"question"`
Answers []struct {
Text string `json:"text"`
Correct bool `json:"correct"`
} `json:"answers"`
} `json:"questions"`
}
// ExtractQuestions sends chunk to the LLM and returns validated, deduplicated questions.
// Questions that do not have exactly one correct answer are silently dropped.
func (c *Client) ExtractQuestions(ctx context.Context, chunk string) ([]ParsedQuestion, error) {
resp, err := c.cc.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: c.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: chunk},
},
ResponseFormat: &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONObject,
},
})
if err != nil {
return nil, fmt.Errorf("openai: %w", err)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("openai: empty response")
}
var raw llmResponse
if err := json.Unmarshal([]byte(resp.Choices[0].Message.Content), &raw); err != nil {
return nil, fmt.Errorf("parse llm response: %w", err)
}
seen := make(map[string]bool)
var out []ParsedQuestion
for _, q := range raw.Questions {
var nCorrect int
for _, a := range q.Answers {
if a.Correct {
nCorrect++
}
}
if nCorrect != 1 {
continue
}
key := textHash(q.Question)
if seen[key] {
continue
}
seen[key] = true
pq := ParsedQuestion{Question: q.Question}
for _, a := range q.Answers {
pq.Answers = append(pq.Answers, ParsedAnswer{Text: a.Text, Correct: a.Correct})
}
out = append(out, pq)
}
return out, nil
}
func textHash(s string) string {
h := sha256.Sum256([]byte(s))
return fmt.Sprintf("%x", h[:8])
}
+129
View File
@@ -0,0 +1,129 @@
package llm_test
import (
"context"
"encoding/json"
"testing"
openai "github.com/sashabaranov/go-openai"
"qbank/internal/llm"
)
// mockChat implements llm.ChatClient for testing.
type mockChat struct{ body string }
func (m *mockChat) CreateChatCompletion(_ context.Context, _ openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error) {
return openai.ChatCompletionResponse{
Choices: []openai.ChatCompletionChoice{
{Message: openai.ChatCompletionMessage{Content: m.body}},
},
}, nil
}
func mockClient(t *testing.T, questions []map[string]any) *llm.Client {
t.Helper()
body, err := json.Marshal(map[string]any{"questions": questions})
if err != nil {
t.Fatal(err)
}
return llm.NewWithClient(&mockChat{body: string(body)}, "test-model")
}
func TestExtractQuestions_HappyPath(t *testing.T) {
qs, err := mockClient(t, []map[string]any{
{
"question": "What is 2+2?",
"answers": []map[string]any{
{"text": "3", "correct": false},
{"text": "4", "correct": true},
{"text": "5", "correct": false},
},
},
}).ExtractQuestions(context.Background(), "text")
if err != nil {
t.Fatalf("ExtractQuestions: %v", err)
}
if len(qs) != 1 {
t.Fatalf("want 1 question, got %d", len(qs))
}
if qs[0].Question != "What is 2+2?" {
t.Errorf("wrong question text: %q", qs[0].Question)
}
if len(qs[0].Answers) != 3 {
t.Errorf("want 3 answers, got %d", len(qs[0].Answers))
}
}
func TestExtractQuestions_DropsInvalid(t *testing.T) {
qs, err := mockClient(t, []map[string]any{
{
"question": "Two correct — should drop",
"answers": []map[string]any{
{"text": "A", "correct": true},
{"text": "B", "correct": true},
},
},
{
"question": "Zero correct — should drop",
"answers": []map[string]any{
{"text": "A", "correct": false},
{"text": "B", "correct": false},
},
},
{
"question": "Valid question",
"answers": []map[string]any{
{"text": "Wrong", "correct": false},
{"text": "Right", "correct": true},
},
},
}).ExtractQuestions(context.Background(), "text")
if err != nil {
t.Fatalf("ExtractQuestions: %v", err)
}
if len(qs) != 1 {
t.Fatalf("want 1 question after dropping invalid, got %d", len(qs))
}
if qs[0].Question != "Valid question" {
t.Errorf("wrong question kept: %q", qs[0].Question)
}
}
func TestExtractQuestions_Dedup(t *testing.T) {
qs, err := mockClient(t, []map[string]any{
{
"question": "Duplicate?",
"answers": []map[string]any{
{"text": "Yes", "correct": true},
{"text": "No", "correct": false},
},
},
{
"question": "Duplicate?",
"answers": []map[string]any{
{"text": "Yes", "correct": true},
{"text": "No", "correct": false},
},
},
}).ExtractQuestions(context.Background(), "text")
if err != nil {
t.Fatalf("ExtractQuestions: %v", err)
}
if len(qs) != 1 {
t.Errorf("want 1 unique question after dedup, got %d", len(qs))
}
}
func TestExtractQuestions_EmptyResponse(t *testing.T) {
qs, err := mockClient(t, []map[string]any{}).ExtractQuestions(context.Background(), "text")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(qs) != 0 {
t.Errorf("want 0 questions for empty response, got %d", len(qs))
}
}
+72
View File
@@ -0,0 +1,72 @@
package models
import (
"database/sql"
"time"
)
type User struct {
ID int64
Name string
PasswordHash string
CreatedAt time.Time
}
type Question struct {
ID string // sha256(text)[:8 bytes] hex = 16 chars
Text string
Source string
CreatedAt time.Time
}
type Answer struct {
ID int64
QuestionID string
Text string
IsCorrect bool
Position int
}
type Test struct {
ID int64
UserID int64
CreatedAt time.Time
CompletedAt sql.NullTime
NQuestions int
QuestionIDs []string // unmarshaled from JSON
}
type TestAnswer struct {
TestID int64
QuestionID string
SelectedAnswerID sql.NullInt64
IsCorrect sql.NullBool
AnsweredAt sql.NullTime
}
// Draft holds LLM-extracted questions pending user review before import.
type Draft struct {
ID string
UserID int64
Source string
Questions []DraftQuestion
CreatedAt time.Time
}
type DraftQuestion struct {
Text string `json:"text"`
Answers []DraftAnswer `json:"answers"`
}
type DraftAnswer struct {
Text string `json:"text"`
IsCorrect bool `json:"is_correct"`
}
type UserQuestionStat struct {
UserID int64
QuestionID string
TimesSeen int
TimesCorrect int
LastSeenAt sql.NullTime
}
+53
View File
@@ -0,0 +1,53 @@
package parse_test
import (
"bytes"
"strings"
"testing"
"qbank/internal/parse"
)
// TestAcceptanceDOCXPipeline verifies the full DOCX → text → chunk pipeline
// using a handcrafted in-memory docx with known content.
func TestAcceptanceDOCXPipeline(t *testing.T) {
const docXML = `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>1. Which keyword declares a variable in Go?</w:t></w:r></w:p>
<w:p><w:r><w:t>A) var</w:t></w:r></w:p>
<w:p><w:r><w:t>B) let</w:t></w:r></w:p>
<w:p><w:r><w:t>C) dim</w:t></w:r></w:p>
<w:p><w:r><w:t>Correct: A</w:t></w:r></w:p>
<w:p><w:r><w:t>2. What does fmt.Println return?</w:t></w:r></w:p>
<w:p><w:r><w:t>A) Nothing</w:t></w:r></w:p>
<w:p><w:r><w:t>B) n int, err error</w:t></w:r></w:p>
<w:p><w:r><w:t>Correct: B</w:t></w:r></w:p>
</w:body>
</w:document>`
docx := buildDocx(t, docXML)
text, err := parse.ExtractDOCX(bytes.NewReader(docx))
if err != nil {
t.Fatalf("ExtractDOCX: %v", err)
}
wantPhrases := []string{
"Which keyword declares a variable",
"fmt.Println",
"n int, err error",
}
for _, phrase := range wantPhrases {
if !strings.Contains(text, phrase) {
t.Errorf("text missing %q\nfull text:\n%s", phrase, text)
}
}
// Chunking should produce at least 1 chunk.
chunks := parse.Chunk(text, 10_000)
if len(chunks) == 0 {
t.Error("Chunk returned 0 chunks for non-empty text")
}
t.Logf("extracted %d chars, %d chunk(s)", len(text), len(chunks))
}
+31
View File
@@ -0,0 +1,31 @@
package parse
import "strings"
// Chunk splits text on double-newlines and builds chunks of at most maxRunes.
// A single paragraph longer than maxRunes is kept as its own chunk.
func Chunk(text string, maxRunes int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
var cur strings.Builder
for _, p := range paragraphs {
p = strings.TrimSpace(p)
if p == "" {
continue
}
pLen := len([]rune(p))
if cur.Len() > 0 && len([]rune(cur.String()))+2+pLen > maxRunes {
chunks = append(chunks, cur.String())
cur.Reset()
}
if cur.Len() > 0 {
cur.WriteString("\n\n")
}
cur.WriteString(p)
}
if cur.Len() > 0 {
chunks = append(chunks, cur.String())
}
return chunks
}
+52
View File
@@ -0,0 +1,52 @@
package parse_test
import (
"strings"
"testing"
"qbank/internal/parse"
)
func TestChunk(t *testing.T) {
t.Run("small text stays in one chunk", func(t *testing.T) {
text := "Para one.\n\nPara two.\n\nPara three."
chunks := parse.Chunk(text, 1000)
if len(chunks) != 1 {
t.Errorf("want 1 chunk, got %d: %v", len(chunks), chunks)
}
if !strings.Contains(chunks[0], "Para one") || !strings.Contains(chunks[0], "Para three") {
t.Errorf("content lost: %q", chunks[0])
}
})
t.Run("paragraphs split when over limit", func(t *testing.T) {
para := strings.Repeat("x", 600)
text := para + "\n\n" + para + "\n\n" + para
chunks := parse.Chunk(text, 1000)
if len(chunks) < 2 {
t.Errorf("want ≥2 chunks for 1800-rune input with 1000 limit, got %d", len(chunks))
}
// No chunk should combine paragraphs past the limit
for i, c := range chunks {
if len([]rune(c)) > 1200 {
t.Errorf("chunk %d is %d runes, too large", i, len([]rune(c)))
}
}
})
t.Run("single oversized paragraph kept as own chunk", func(t *testing.T) {
bigPara := strings.Repeat("x", 2000)
chunks := parse.Chunk(bigPara, 1000)
if len(chunks) != 1 {
t.Errorf("want 1 chunk for single oversized para, got %d", len(chunks))
}
})
t.Run("empty paragraphs ignored", func(t *testing.T) {
text := "\n\nPara one.\n\n\n\nPara two.\n\n"
chunks := parse.Chunk(text, 1000)
if len(chunks) != 1 {
t.Errorf("want 1 chunk after ignoring blanks, got %d", len(chunks))
}
})
}
+74
View File
@@ -0,0 +1,74 @@
package parse
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
)
// ExtractDOCX reads a .docx file and returns its plain text.
// DOCX is a ZIP archive; we unzip word/document.xml, walk <w:t> nodes
// for text, and emit a newline at each <w:p> boundary.
func ExtractDOCX(r io.Reader) (string, error) {
data, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("read docx: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", fmt.Errorf("open docx zip: %w", err)
}
var docFile *zip.File
for _, f := range zr.File {
if f.Name == "word/document.xml" {
docFile = f
break
}
}
if docFile == nil {
return "", errors.New("word/document.xml not found in docx")
}
rc, err := docFile.Open()
if err != nil {
return "", fmt.Errorf("open document.xml: %w", err)
}
defer rc.Close()
return parseDocXML(rc)
}
func parseDocXML(r io.Reader) (string, error) {
dec := xml.NewDecoder(r)
var sb strings.Builder
var inText bool
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("parse document.xml: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Local == "t" {
inText = true
}
case xml.EndElement:
if t.Name.Local == "t" {
inText = false
}
if t.Name.Local == "p" {
sb.WriteByte('\n')
}
case xml.CharData:
if inText {
sb.Write([]byte(t))
}
}
}
return strings.TrimSpace(sb.String()), nil
}
+62
View File
@@ -0,0 +1,62 @@
package parse_test
import (
"archive/zip"
"bytes"
"strings"
"testing"
"qbank/internal/parse"
)
func TestExtractDOCX(t *testing.T) {
const docXML = `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>Question 1: What is Go?</w:t></w:r></w:p>
<w:p><w:r><w:t>A) A compiled language</w:t></w:r></w:p>
<w:p><w:r><w:t>B) An interpreted language</w:t></w:r></w:p>
<w:p><w:r><w:t>C) A markup language</w:t></w:r></w:p>
</w:body>
</w:document>`
docx := buildDocx(t, docXML)
text, err := parse.ExtractDOCX(bytes.NewReader(docx))
if err != nil {
t.Fatalf("ExtractDOCX: %v", err)
}
for _, want := range []string{"Question 1", "compiled language", "interpreted language"} {
if !strings.Contains(text, want) {
t.Errorf("output missing %q; got:\n%s", want, text)
}
}
}
func TestExtractDOCX_MissingXML(t *testing.T) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
w.Close()
_, err := parse.ExtractDOCX(bytes.NewReader(buf.Bytes()))
if err == nil {
t.Error("expected error for docx without document.xml")
}
}
func buildDocx(t *testing.T, xmlContent string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
f, err := w.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte(xmlContent)); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
+62
View File
@@ -0,0 +1,62 @@
package parse
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"unicode"
"github.com/ledongthuc/pdf"
)
// ErrScanPDF is returned when extracted text is empty or non-textual,
// indicating a scan-based (image-only) PDF that cannot be parsed.
var ErrScanPDF = errors.New("scan-based PDF: please convert to text first")
// ExtractPDF reads a PDF and returns its concatenated plain text.
// Returns ErrScanPDF if the content appears to be empty or non-textual.
func ExtractPDF(r io.Reader) (string, error) {
data, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("read pdf: %w", err)
}
reader, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", fmt.Errorf("parse pdf: %w", err)
}
var sb strings.Builder
for i := 1; i <= reader.NumPage(); i++ {
page := reader.Page(i)
if page.V.IsNull() {
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
continue
}
sb.WriteString(text)
sb.WriteByte('\n')
}
text := sb.String()
if isGibberish(text) {
return "", ErrScanPDF
}
return text, nil
}
// isGibberish returns true when text is too short or has < 2% alphanumeric content.
func isGibberish(text string) bool {
runes := []rune(text)
if len(runes) < 50 {
return true
}
var alpha int
for _, c := range runes {
if unicode.IsLetter(c) || unicode.IsDigit(c) {
alpha++
}
}
return float64(alpha)/float64(len(runes)) < 0.02
}
+37
View File
@@ -0,0 +1,37 @@
package parse
import (
"strings"
"testing"
)
func TestIsGibberish(t *testing.T) {
tests := []struct {
name string
text string
want bool
}{
{"empty", "", true},
{"too short", "hello", true},
{"exactly 50 letters", strings.Repeat("a", 50), false},
{"49 letters", strings.Repeat("a", 49), true},
{"all punctuation", strings.Repeat(".", 100), true},
{"1% alpha", strings.Repeat(".", 99) + "a", true},
{"2% alpha exactly", strings.Repeat(".", 49) + "a" + strings.Repeat(".", 49) + "a", false},
{"normal text", "The quick brown fox jumps over the lazy dog. " + strings.Repeat("word ", 10), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isGibberish(tt.text); got != tt.want {
t.Errorf("isGibberish(%q…) = %v, want %v", tt.text[:min(len(tt.text), 20)], got, tt.want)
}
})
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+114
View File
@@ -0,0 +1,114 @@
package sampling_test
import (
"database/sql"
"fmt"
"math/rand"
"testing"
"time"
"qbank/internal/models"
"qbank/internal/sampling"
)
func TestSelectWeighted_Distribution(t *testing.T) {
// Fixed reference time so weights don't drift with wall clock.
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
recentSeen := sql.NullTime{Time: now, Valid: true}
// Build a pool of 100 candidates:
// 10 mastered (seen=10, correct=10) → base=max(0.15, 1/12)=0.15, recency=1.0, w=0.15
// 10 weak (seen=10, correct=1) → base=max(0.15, 10/12)≈0.833, recency=1.0, w≈0.833
// 10 unseen (no stat row) → w=1.0 (UnseenBaseWeight*RecencyMaxMult)
// 70 average (seen=5, correct=3) → base≈(2+1)/(5+2)≈0.429, recency=1.0, w≈0.429
type entry struct {
id string
stat *models.UserQuestionStat
}
var entries []entry
statWith := func(seen, correct int) *models.UserQuestionStat {
return &models.UserQuestionStat{TimesSeen: seen, TimesCorrect: correct, LastSeenAt: recentSeen}
}
for i := 0; i < 10; i++ {
entries = append(entries, entry{fmt.Sprintf("mastered-%d", i), statWith(10, 10)})
}
for i := 0; i < 10; i++ {
entries = append(entries, entry{fmt.Sprintf("weak-%d", i), statWith(10, 1)})
}
for i := 0; i < 10; i++ {
entries = append(entries, entry{fmt.Sprintf("unseen-%d", i), nil})
}
for i := 0; i < 70; i++ {
entries = append(entries, entry{fmt.Sprintf("avg-%d", i), statWith(5, 3)})
}
// Build candidate list.
candidates := make([]sampling.Candidate, len(entries))
for i, e := range entries {
candidates[i] = sampling.Candidate{
ID: e.id,
Weight: sampling.ComputeWeight(e.stat, now),
}
}
// Sample n=1 from the pool 10,000 times with a seeded RNG.
rng := rand.New(rand.NewSource(42))
counts := make(map[string]int, len(candidates))
const runs = 10_000
for range runs {
sel := sampling.SelectWeighted(candidates, 1, rng)
counts[sel[0].ID]++
}
// Compute group averages.
masteredTotal, weakTotal := 0, 0
for i := 0; i < 10; i++ {
masteredTotal += counts[fmt.Sprintf("mastered-%d", i)]
weakTotal += counts[fmt.Sprintf("weak-%d", i)]
}
masteredAvg := float64(masteredTotal) / 10.0
weakAvg := float64(weakTotal) / 10.0
t.Logf("mastered avg %.1f, weak avg %.1f, ratio %.2f", masteredAvg, weakAvg, weakAvg/masteredAvg)
// Weak questions must appear >3× more often than mastered ones.
if weakAvg < masteredAvg*3 {
t.Errorf("want weakAvg > masteredAvg*3, got weakAvg=%.1f masteredAvg=%.1f", weakAvg, masteredAvg)
}
// Mastered questions must still appear (floor weight working).
if masteredTotal < 50 {
t.Errorf("want masteredTotal >= 50 (floor weight), got %d", masteredTotal)
}
}
func TestComputeWeight_Unseen(t *testing.T) {
w := sampling.ComputeWeight(nil, time.Now())
if w != sampling.UnseenBaseWeight*sampling.RecencyMaxMult {
t.Errorf("unseen weight: got %v, want %v", w, sampling.UnseenBaseWeight*sampling.RecencyMaxMult)
}
}
func TestComputeWeight_FloorEnforced(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
stat := &models.UserQuestionStat{
TimesSeen: 100,
TimesCorrect: 100,
LastSeenAt: sql.NullTime{Time: now, Valid: true},
}
w := sampling.ComputeWeight(stat, now)
if w < sampling.FloorWeight {
t.Errorf("weight %v below FloorWeight %v", w, sampling.FloorWeight)
}
}
func TestSelectWeighted_AllReturned_WhenNGeLen(t *testing.T) {
rng := rand.New(rand.NewSource(1))
cands := []sampling.Candidate{{ID: "a", Weight: 1}, {ID: "b", Weight: 2}}
got := sampling.SelectWeighted(cands, 10, rng)
if len(got) != 2 {
t.Errorf("want 2, got %d", len(got))
}
}
+46
View File
@@ -0,0 +1,46 @@
package sampling
import (
"math"
"math/rand"
"sort"
)
// Candidate is a question ID paired with its sampling weight.
type Candidate struct {
ID string
Weight float64
}
// SelectWeighted picks n distinct candidates using the A-Res weighted
// reservoir algorithm (EfraimidisSpirakis). Each item's selection
// probability is proportional to its weight. O(m log m) time.
func SelectWeighted(candidates []Candidate, n int, rng *rand.Rand) []Candidate {
if n >= len(candidates) {
out := make([]Candidate, len(candidates))
copy(out, candidates)
return out
}
type keyed struct {
c Candidate
key float64
}
keys := make([]keyed, len(candidates))
for i, c := range candidates {
u := rng.Float64()
if u == 0 {
u = 1e-12 // avoid log(0) / pow weirdness
}
keys[i] = keyed{c, math.Pow(u, 1.0/c.Weight)}
}
sort.Slice(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
out := make([]Candidate, n)
for i := range out {
out[i] = keys[i].c
}
return out
}
+41
View File
@@ -0,0 +1,41 @@
package sampling
import (
"math"
"time"
"qbank/internal/models"
)
const (
FloorWeight = 0.15 // mastered questions still appear at ~15% base rate
RecencyCapDays = 30.0 // days until recency multiplier saturates
RecencyMaxMult = 2.0 // peak recency multiplier
UnseenBaseWeight = 0.5 // base weight for questions with no stats row
)
// ComputeWeight returns the sampling weight for a question given its per-user
// stat. A nil stat means the question has never been seen.
func ComputeWeight(stat *models.UserQuestionStat, now time.Time) float64 {
if stat == nil {
// Unseen: mid-range base + full recency = 1.0
return UnseenBaseWeight * RecencyMaxMult
}
s := float64(stat.TimesSeen)
c := float64(stat.TimesCorrect)
// Laplace-smoothed error rate dampens noise from small samples.
errorRate := (s - c + 1) / (s + 2)
base := math.Max(FloorWeight, errorRate)
var daysSince float64
if stat.LastSeenAt.Valid {
daysSince = now.Sub(stat.LastSeenAt.Time).Hours() / 24
} else {
daysSince = RecencyCapDays
}
recency := 1 + math.Min(daysSince/RecencyCapDays, 1.0)
return base * recency
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./web/templates/**/*.html"],
theme: { extend: {} },
plugins: [],
}
View File
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "QBank",
"short_name": "QBank",
"start_url": "/",
"display": "standalone",
"background_color": "#f9fafb",
"theme_color": "#2563eb",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+20
View File
@@ -0,0 +1,20 @@
const CACHE = 'qbank-v1';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
self.addEventListener('fetch', e => {
// Network-first: serve fresh, fall back to cache for GET requests.
if (e.request.method !== 'GET') return;
e.respondWith(
fetch(e.request)
.then(res => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
})
.catch(() => caches.match(e.request))
);
});
+66
View File
@@ -0,0 +1,66 @@
{{define "content"}}
<div class="flex items-center justify-between mb-5">
<h1 class="text-xl font-bold text-gray-800">Test History</h1>
<a href="/test/new"
class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold
px-4 py-2 rounded-lg shadow-sm">
Take a test
</a>
</div>
<!-- Aggregate stat -->
{{if gt .TotalAnswered 0}}
<div class="bg-white border border-gray-200 rounded-xl p-5 mb-6 shadow-sm flex items-center gap-4">
<div class="text-3xl font-bold text-gray-900">{{pct .TotalCorrect .TotalAnswered}}%</div>
<div>
<p class="text-sm font-medium text-gray-700">Overall accuracy</p>
<p class="text-xs text-gray-400">{{.TotalCorrect}} correct out of {{.TotalAnswered}} answered</p>
</div>
</div>
{{end}}
<!-- Past tests -->
{{if .Items}}
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden mb-6">
<div class="divide-y divide-gray-100">
{{range .Items}}
<div class="flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors">
<div>
<p class="text-sm font-medium text-gray-800">
{{.NCorrect}} / {{.NQuestions}}
<span class="text-gray-400 font-normal">({{pct .NCorrect .NQuestions}}%)</span>
</p>
<p class="text-xs text-gray-400 mt-0.5">{{.CreatedAt.Format "2 Jan 2006, 15:04"}}</p>
</div>
<a href="/test/{{.ID}}/results"
class="text-xs text-blue-600 hover:text-blue-800 font-medium">
Review →
</a>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="text-center py-12 text-gray-400">
<p class="text-sm">No completed tests yet.</p>
</div>
{{end}}
<!-- Weak spots -->
{{if .WeakSpots}}
<h2 class="text-base font-semibold text-gray-700 mb-3">Weak spots</h2>
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<div class="divide-y divide-gray-100">
{{range .WeakSpots}}
<a href="/questions/{{.QuestionID}}"
class="flex items-start justify-between px-5 py-4 hover:bg-gray-50 transition-colors gap-4">
<p class="text-sm text-gray-800 leading-relaxed line-clamp-2">{{.QuestionText}}</p>
<span class="flex-shrink-0 text-xs text-red-500 font-medium whitespace-nowrap">
{{.TimesWrong}}✗ / {{.TimesSeen}}
</span>
</a>
{{end}}
</div>
</div>
{{end}}
{{end}}
+79
View File
@@ -0,0 +1,79 @@
{{define "content"}}
<div class="mb-5 flex items-baseline justify-between">
<h1 class="text-2xl font-bold text-gray-800">Library</h1>
<a href="/upload" class="text-sm text-blue-600 hover:underline">+ Upload</a>
</div>
{{if eq .TotalQ 0}}
<div class="text-center py-16 text-gray-400">
<p class="text-lg">No questions yet.</p>
<a href="/upload" class="mt-4 inline-block text-blue-600 hover:underline text-sm">Upload a document to get started</a>
</div>
{{else}}
<div class="mb-5 text-sm text-gray-500">
{{.TotalQ}} question{{if ne .TotalQ 1}}s{{end}} · {{.TotalA}} answer{{if ne .TotalA 1}}s{{end}}
{{if .SourceStats}}
<span class="mx-1">·</span>
{{range $i, $s := .SourceStats}}{{if $i}}<span class="mx-0.5 text-gray-300">|</span>{{end}}<span>{{$s.Source}} ({{$s.Count}})</span>{{end}}
{{end}}
</div>
<form method="get" action="/" class="flex flex-wrap gap-2 mb-4">
<input type="text" name="q" value="{{.Search}}" placeholder="Search…"
class="flex-1 min-w-0 border border-gray-300 rounded-md px-3 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500">
{{if .SourceStats}}
<select name="source" onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-2 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="">All sources</option>
{{range .SourceStats}}
<option value="{{.Source}}" {{if eq .Source $.SelectedSource}}selected{{end}}>{{.Source}}</option>
{{end}}
</select>
{{end}}
<select name="sort" onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-2 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="alpha" {{if eq .Sort "alpha"}}selected{{end}}>AZ</option>
<option value="weakest" {{if eq .Sort "weakest"}}selected{{end}}>Weakest first</option>
<option value="seen" {{if eq .Sort "seen"}}selected{{end}}>Most seen</option>
</select>
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded-md text-sm font-medium">
Search
</button>
</form>
{{if .Questions}}
<div class="space-y-2">
{{range .Questions}}
<a href="/questions/{{.Q.ID}}"
class="block bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-300 hover:shadow-sm transition-all">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-gray-800 line-clamp-2">{{.Q.Text}}</p>
<span class="text-xs text-gray-400 whitespace-nowrap mt-0.5 flex-shrink-0">
{{if .Stat}}{{.Stat.TimesSeen}}× · {{pct .Stat.TimesCorrect .Stat.TimesSeen}}%{{else}}—{{end}}
</span>
</div>
{{if .Q.Source}}
<p class="text-xs text-gray-400 mt-1">{{.Q.Source}}</p>
{{end}}
</a>
{{end}}
</div>
{{else}}
<p class="text-center py-8 text-gray-400 text-sm">No questions match your filter.</p>
{{end}}
<div class="mt-6 text-center">
<a href="/test/new"
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-8 py-2.5 rounded-md
text-sm font-semibold shadow-sm">
Take a test
</a>
</div>
{{end}}
{{end}}
+81
View File
@@ -0,0 +1,81 @@
{{define "content"}}
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-800">Review Import</h1>
<p class="text-sm text-gray-500 mt-1">
{{len .Draft.Questions}} question(s) found.
Edit any text, choose the correct answer, then confirm.
Check "Delete" to skip a question.
</p>
</div>
<a href="/upload" class="text-sm text-gray-500 hover:underline whitespace-nowrap mt-1">← Upload another</a>
</div>
{{if eq (len .Draft.Questions) 0}}
<div class="text-center py-16 text-gray-400">
<p class="text-lg">No questions were found in this document.</p>
<p class="text-sm mt-2">The file may not contain multiple-choice questions, or the LLM was unable to extract them.</p>
<a href="/upload" class="mt-6 inline-block text-blue-600 hover:underline text-sm">Try another file</a>
</div>
{{else}}
<form method="post" action="/import/{{.Draft.ID}}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-700 whitespace-nowrap">Source</label>
<input type="text" name="source" value="{{.Draft.Source}}"
class="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{range $i, $q := .Draft.Questions}}
<div class="border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div class="bg-gray-50 border-b border-gray-200 px-4 py-2 flex items-center justify-between">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Question {{inc $i}}
</span>
<label class="flex items-center gap-2 text-sm text-red-600 cursor-pointer select-none">
<input type="checkbox" name="delete_{{$i}}"
class="rounded border-gray-300 text-red-500 focus:ring-red-400">
Delete
</label>
</div>
<div class="p-4 space-y-3">
<textarea name="q_text_{{$i}}" rows="3" required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
resize-y">{{$q.Text}}</textarea>
<div class="space-y-2">
{{range $j, $a := $q.Answers}}
<div class="flex items-center gap-3">
<input type="radio" name="correct_{{$i}}" value="{{$j}}"
{{if $a.IsCorrect}}checked{{end}}
class="text-blue-600 focus:ring-blue-500 flex-shrink-0">
<input type="text" name="a_text_{{$i}}_{{$j}}" value="{{$a.Text}}" required
class="flex-1 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 focus:border-blue-500">
</div>
{{end}}
</div>
<p class="text-xs text-gray-400">Select the radio button next to the correct answer.</p>
</div>
</div>
{{end}}
<div class="flex gap-3 pt-2 pb-8">
<button type="submit"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-md
text-sm font-semibold shadow-sm">
Confirm import
</button>
<a href="/upload"
class="px-4 py-2.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700
hover:bg-gray-50 text-center">
Cancel
</a>
</div>
</form>
{{end}}
{{end}}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+73
View File
@@ -0,0 +1,73 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QBank</title>
<!-- Development: Tailwind CDN. Production Docker build replaces this with compiled CSS. -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="manifest" href="/static/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="QBank">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<meta name="theme-color" content="#2563eb">
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body class="bg-gray-50 min-h-screen text-gray-900">
{{if .User}}
<header class="bg-blue-600 text-white shadow-md">
<div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" class="text-lg font-bold tracking-tight">QBank</a>
<div class="flex items-center gap-3">
<nav class="hidden sm:flex items-center gap-5 text-sm font-medium">
<a href="/" class="hover:underline underline-offset-2">Library</a>
<a href="/upload" class="hover:underline underline-offset-2">Upload</a>
<a href="/test/new" class="hover:underline underline-offset-2">Take Test</a>
<a href="/history" class="hover:underline underline-offset-2">History</a>
</nav>
<span class="text-sm opacity-75 hidden sm:inline">{{.User.Name}}</span>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit"
class="text-xs bg-blue-700 hover:bg-blue-800 px-3 py-1.5 rounded-md font-medium">
Logout
</button>
</form>
</div>
</div>
<nav class="sm:hidden border-t border-blue-500 overflow-x-auto">
<div class="flex px-4 py-2 gap-5 text-sm font-medium whitespace-nowrap items-center">
<a href="/" class="hover:underline">Library</a>
<a href="/upload" class="hover:underline">Upload</a>
<a href="/test/new" class="hover:underline">Take Test</a>
<a href="/history" class="hover:underline">History</a>
<span class="opacity-75 ml-auto">{{.User.Name}}</span>
</div>
</nav>
</header>
{{else}}
<header class="bg-blue-600 text-white shadow-md">
<div class="max-w-2xl mx-auto px-4 py-3">
<span class="text-lg font-bold tracking-tight">QBank</span>
</div>
</header>
{{end}}
{{if .Flash}}
<div class="max-w-2xl mx-auto px-4 pt-4">
<div class="bg-green-50 border border-green-200 text-green-800 text-sm px-4 py-3 rounded-md">
{{.Flash}}
</div>
</div>
{{end}}
<main class="max-w-2xl mx-auto px-4 py-6">
{{block "content" .}}{{end}}
</main>
</body>
</html>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<div class="max-w-sm mx-auto mt-12">
<h1 class="text-2xl font-bold mb-8 text-center text-gray-800">Sign in to QBank</h1>
{{if .Error}}
<div class="mb-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3 rounded-md">
{{.Error}}
</div>
{{end}}
<form method="post" action="/login" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" name="username" required autofocus
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" name="password" required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</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">
Sign in
</button>
</form>
</div>
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "content"}}
<div class="mb-5 flex items-center justify-between">
<a href="/" class="text-sm text-gray-500 hover:underline">← Library</a>
{{if .Stat}}
<p class="text-xs text-gray-500">
Seen {{.Stat.TimesSeen}}× · {{.Stat.TimesCorrect}} correct ({{pct .Stat.TimesCorrect .Stat.TimesSeen}}%)
</p>
{{else}}
<p class="text-xs text-gray-400">Not yet seen in a test</p>
{{end}}
</div>
<form method="post" action="/questions/{{.Question.ID}}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Question</label>
<textarea name="q_text" rows="4" required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y">{{.Question.Text}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Source</label>
<input type="text" name="source" value="{{.Question.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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Answers</label>
<div class="space-y-2">
{{range $i, $a := .Answers}}
<div class="flex items-center gap-3">
<input type="radio" name="correct" value="{{$i}}"
{{if $a.IsCorrect}}checked{{end}}
class="text-blue-600 focus:ring-blue-500 flex-shrink-0">
<input type="text" name="a_text_{{$i}}" value="{{$a.Text}}" required
class="flex-1 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">
</div>
{{end}}
</div>
<p class="text-xs text-gray-400 mt-1">Select the radio button next to the correct answer.</p>
</div>
<div class="flex gap-3 pt-2">
<button type="submit"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-md
text-sm font-semibold shadow-sm">
Save changes
</button>
<a href="/"
class="px-4 py-2.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700
hover:bg-gray-50 text-center">
Cancel
</a>
</div>
</form>
<div class="mt-6 pt-6 border-t border-gray-200">
<form method="post" action="/questions/{{.Question.ID}}/delete">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit"
onclick="return confirm('Delete this question? This cannot be undone.')"
class="text-sm text-red-600 hover:text-red-800 hover:underline">
Delete this question
</button>
</form>
</div>
{{end}}
+86
View File
@@ -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}}
+41
View File
@@ -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}}
+82
View File
@@ -0,0 +1,82 @@
{{define "content"}}
{{$n := .NCorrect}}
{{$total := .Test.NQuestions}}
<!-- Score summary -->
<div class="bg-white border border-gray-200 rounded-xl p-6 mb-6 shadow-sm text-center">
<p class="text-4xl font-bold text-gray-900 mb-1">{{$n}} / {{$total}}</p>
<p class="text-lg text-gray-500 mb-1">{{pct $n $total}}%</p>
{{if .TimeTaken}}
<p class="text-sm text-gray-400">Time: {{.TimeTaken}}</p>
{{end}}
</div>
<!-- Cat reaction -->
{{if .CatURL}}
<div class="flex justify-center mb-6">
<img src="{{.CatURL}}"
alt=""
class="rounded-2xl shadow-md max-h-64 w-auto object-cover">
</div>
{{end}}
<!-- Per-question review -->
<div class="space-y-5 mb-8">
{{range $i, $item := .Items}}
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<!-- Question header -->
<div class="px-5 pt-5 pb-3 border-b border-gray-100">
<div class="flex items-start gap-3">
<span class="mt-0.5 flex-shrink-0 text-lg leading-none">
{{if $item.Unanswered}}⬜{{else if $item.UserRight}}✅{{else}}❌{{end}}
</span>
<p class="text-gray-800 text-sm leading-relaxed font-medium">{{$item.Question.Text}}</p>
</div>
{{if $item.Question.Source}}
<p class="text-xs text-gray-400 mt-2 pl-8">{{$item.Question.Source}}</p>
{{end}}
</div>
<!-- Answers -->
<ul class="divide-y divide-gray-100">
{{range $item.Answers}}
<li class="px-5 py-3 flex items-start gap-3
{{if and .IsCorrect .UserPicked}}bg-green-50
{{else if .IsCorrect}}bg-green-50
{{else if .UserPicked}}bg-red-50
{{end}}">
<span class="flex-shrink-0 w-5 text-base leading-none mt-0.5">
{{if and .IsCorrect .UserPicked}}✅
{{else if .IsCorrect}}✅
{{else if .UserPicked}}❌
{{else}}&nbsp;{{end}}
</span>
<span class="text-sm leading-relaxed
{{if .IsCorrect}}text-green-800 font-medium
{{else if .UserPicked}}text-red-700
{{else}}text-gray-600{{end}}">
{{.Text}}
</span>
</li>
{{end}}
</ul>
</div>
{{end}}
</div>
<!-- Actions -->
<div class="flex gap-3">
<a href="/test/new"
class="flex-1 text-center bg-blue-600 hover:bg-blue-700 text-white py-3 px-4
rounded-xl text-sm font-semibold shadow-sm">
Take another test
</a>
<a href="/"
class="flex-1 text-center border border-gray-300 text-gray-700 hover:bg-gray-50
py-3 px-4 rounded-xl text-sm font-medium">
Library
</a>
</div>
{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<div class="max-w-lg">
<h1 class="text-2xl font-bold text-gray-800 mb-1">Upload Document</h1>
<p class="text-sm text-gray-500 mb-6">Accepts PDF or DOCX. Max 20 MB.</p>
{{if .Error}}
<div class="mb-5 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3 rounded-md">
{{.Error}}
</div>
{{end}}
<form method="post" action="/upload" enctype="multipart/form-data" class="space-y-5">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">File</label>
<input type="file" name="file" accept=".pdf,.docx" required
class="block w-full text-sm text-gray-700
file:mr-3 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-medium
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Source name <span class="text-gray-400 font-normal">(optional — defaults to filename)</span>
</label>
<input type="text" name="source" placeholder="e.g. Chapter 3"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</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">
Extract questions
</button>
</form>
</div>
{{end}}