Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f68a80d14 | |||
| 6b486a558a | |||
| 2477130dd9 | |||
| 715c1e4fe5 | |||
| 968479ff51 | |||
| 177b4e8fd8 | |||
| 5199c1fa16 | |||
| e53e7662e9 | |||
| d9de37d3d8 | |||
| 0bc9160d97 |
@@ -0,0 +1,11 @@
|
|||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
tmp/
|
||||||
|
qbank
|
||||||
+2
-1
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (Efraimidis–Spirakis). 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./web/templates/**/*.html"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 554 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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}}
|
||||||
@@ -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}}>A–Z</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}}
|
||||||
@@ -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}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 mb-1">New Test</h1>
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="mt-3 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3 rounded-md">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq .TotalQ 0}}
|
||||||
|
<div class="text-center py-12 text-gray-400">
|
||||||
|
<p class="text-lg">No questions in the library yet.</p>
|
||||||
|
<a href="/upload" class="mt-4 inline-block text-blue-600 hover:underline text-sm">Upload a document first</a>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/test/new" class="space-y-6 max-w-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="n-input" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Number of questions
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="number" id="n-input" name="n" value="10"
|
||||||
|
min="1" max="{{.TotalQ}}"
|
||||||
|
class="w-24 border border-gray-300 rounded-md px-3 py-1.5 text-sm shadow-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<span class="text-sm text-gray-500" id="q-avail">
|
||||||
|
{{.TotalQ}} available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .SourceStats}}
|
||||||
|
<div>
|
||||||
|
<label for="source-sel" class="block text-sm font-medium text-gray-700 mb-1">Source filter</label>
|
||||||
|
<select id="source-sel" name="source"
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm shadow-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
||||||
|
onchange="(function(sel){
|
||||||
|
var opt=sel.options[sel.selectedIndex];
|
||||||
|
var c=parseInt(opt.dataset.count,10);
|
||||||
|
document.getElementById('q-avail').textContent=c+' available';
|
||||||
|
var ni=document.getElementById('n-input');
|
||||||
|
ni.max=c;
|
||||||
|
if(parseInt(ni.value,10)>c)ni.value=c;
|
||||||
|
})(this)">
|
||||||
|
<option value="" data-count="{{.TotalQ}}">All sources</option>
|
||||||
|
{{range .SourceStats}}
|
||||||
|
<option value="{{.Source}}" data-count="{{.Count}}">{{.Source}} ({{.Count}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="block text-sm font-medium text-gray-700 mb-2">Sampling mode</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="radio" name="mode" value="weighted" checked
|
||||||
|
class="mt-0.5 text-blue-600 focus:ring-blue-500 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800">Focus on weak spots</p>
|
||||||
|
<p class="text-xs text-gray-500">Questions you get wrong appear more often</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="radio" name="mode" value="uniform"
|
||||||
|
class="mt-0.5 text-blue-600 focus:ring-blue-500 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800">Mix evenly</p>
|
||||||
|
<p class="text-xs text-gray-500">All questions have equal probability</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-md
|
||||||
|
text-sm font-semibold shadow-sm">
|
||||||
|
Start test
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mb-5">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-xs font-medium text-gray-500">Question {{.N}} of {{.Total}}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{.ProgressPct}}% done</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-blue-500 rounded-full transition-all"
|
||||||
|
style="width: {{.ProgressPct}}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-5 mb-5 shadow-sm">
|
||||||
|
<p class="text-gray-800 leading-relaxed text-base">{{.Question.Text}}</p>
|
||||||
|
{{if .Question.Source}}
|
||||||
|
<p class="text-xs text-gray-400 mt-3">{{.Question.Source}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/test/{{.TestID}}/q/{{.N}}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
{{range .Answers}}
|
||||||
|
<label class="flex items-start gap-4 p-4 bg-white border-2 border-gray-200 rounded-xl
|
||||||
|
cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all
|
||||||
|
has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
|
||||||
|
<input type="radio" name="answer_id" value="{{.ID}}"
|
||||||
|
class="mt-0.5 text-blue-600 focus:ring-blue-500 flex-shrink-0 w-4 h-4">
|
||||||
|
<span class="text-sm text-gray-800 leading-relaxed">{{.Text}}</span>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 px-4 rounded-xl
|
||||||
|
text-sm font-semibold shadow-sm">
|
||||||
|
{{if lt .N .Total}}Next question{{else}}Finish test{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,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}} {{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}}
|
||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user