Commit graph

30 commits

Author SHA1 Message Date
Daniel
4aa1352da7 feat: jobs move to the workbench, with their logs and a way to clear them
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 31s
**The badge is off the navbar.** It sat in the header of every page, for
everybody, polling every thirty seconds — for a number that means something to
the handful of people who start an extraction and nothing at all to a learner
sitting a session. Extraction is workbench business and it lives there now:
Settings → Tools → Jobs, and a link from the workbench itself, which is where
one is started.

To answer the question it raised: a job ages off the list after a day, and the
steps behind it after an hour. Which is to say it disappears when Redis forgets
it, on its own, with nothing to tell you it had.

**So there is now a way to clear one.** "Forget" takes a job off your list, with
a confirm beside it. It stops nothing that is running — the button is not
offered for a running job — and deletes nothing the job produced; it clears a
line somebody has read and dealt with so the ones they have not are not buried
under it. Only from your own list: the id alone is not authority over anybody
else's, and the keys behind it are shared.

**And a way to read one.** The details panel is called "Log" now, because that
is what it is — every step the job took, in order, and the reason it stopped if
it stopped. That reason has been recorded all along and shown nowhere.

Also, on the deck: the way out is a back link above the title like every other
page rather than a small grey button in the bar of card controls, and the four
buttons that are the whole interaction are full size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:41:22 +02:00
Daniel
3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00
Daniel
bb72aa5f60 feat: AMBOSS exam chrome, review as a study session, and no silent submissions
The exam player takes the window. The shell was sized against the header with
a number that did not include the navbar's own 32px of margin, so the block bar
— the one thing on the screen that must always be reachable — sat below the
fold and had to be scrolled to. Exam mode now hides the site chrome entirely
and is the viewport, which makes the arithmetic honest and matches what a board
looks like: item and block in a box at the left, the two arrows in the middle,
the tools at the right, the question-status rail down the side, and the clock,
Pause and End Block along the bottom.

Shortcuts is gone from the bar, and the labs open into the column beside the
question in both modes rather than a box over it.

Nothing is handed in behind the learner's back. The clock reaching zero stops
the block and says so; closing Time's Up submits, and the player stays put
showing the answers, which is the review. The server no longer settles an
expired attempt at all — listing sessions used to mark any paper whose clock
had run out, so opening a page could score a block the learner had walked away
from, and the first they knew of it was a result.

Reviewing an attempt is now the player with the answers in, not a dropdown and
a card. Same rail, same layout, same labs, same way out — and on a phone the
same burger opens the same question list, from one shared rule about which
routes are a session.

Also: the rule-out toggle sits beside its option instead of pinned to the far
edge of the card, so an option box is as wide as its own words; the voice
picker leaves the player, since a reader's voice is a setting and not a
decision to retake every session; figures carry no invented "Figure 1" — a
label is what prose refers to, and the backfill knew of no prose, so 346 of
them said only that an image was an image; and the landing page shows the two
modes happening rather than promising six things in a sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:37:58 +02:00
Daniel
c9abc4f310 feat: extracted questions are staged, and only acceptance takes an id
Extraction wrote straight into `questions`, so a machine's first attempt
took a permanent id the moment it was produced. Ids come from a sequence
and are never reissued: every rejected draft burned one, and every draft
that needed fixing was sitting in the bank while it was being fixed.

A run now lands in a batch of drafts with their own table and their own
sequence. They are read, corrected and decided there, and `accept` is
the only place a Question is created — a copy rather than a translation,
because every field a draft holds is a field a question has, so nothing
is lost at the moment of acceptance.

Accepting is all or nothing, and everything is checked before anything
is created: a call that reports failure must not leave questions behind
from the drafts it got through first. My own test caught that — the
first question existed before the second draft was refused.

Readiness is reported for every draft rather than only on the attempt to
accept it, so a reviewer sees what needs work before opening anything.
A decided draft keeps its row and records what it became, so a batch
reads as a history of what was decided rather than emptying as it is
worked through. An acceptance cannot be undone from here: the question
exists, and deciding twice would make a second one.

No embeddings for drafts. A vector is for finding a question in the
bank, and a draft is not in the bank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 22:57:08 +02:00
Daniel
5f61422daf feat: a share dialog that shows what is being shared
A copy button tells you nothing about what you are about to send. The
dialog names the session, counts its questions and shows the stem it
opens on, then offers the link with Copy and the places people actually
send one — email, WhatsApp, Telegram.

Sharing is the administrator's to allow. GET /quizzes/share-policy is
asked before the dialog offers to make a link, so a switch that has been
thrown reads as "not offered" rather than as a button that fails when
pressed. A link already issued keeps working either way.

Removes the second, lesser share block that sat inside the save panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:24:45 +02:00
Daniel
2267f53b55 feat: the player is a box; sharing and sign-up are the administrator's
The quiz player is a box the height of the window. The question used to
scroll the whole page, which took the session rail and the navigation off
screen exactly when you wanted them; now each column scrolls on its own
and the bar — Exit session, Previous, Next, Review — stays put.

Two site-wide switches, together under Settings → Site policy because
both are the administrator's and both apply to everyone:

  * Sharing can be turned off. That stops new links being made; one
    already handed to somebody keeps working, since revoking it would
    break something a learner has already given away.
  * Sign-up can be made invite-only, with single-use codes carrying a
    note of who each is for and, afterwards, who it let in. A spent code
    is kept rather than deleted — that record is the point of invite-only.
    The alphabet has no O/0 or I/1/l, because these get read aloud.

The registration form asks for a code only when the site needs one, via
an unauthenticated policy endpoint — it has to know before there is an
account to ask with. It never says whether a given code is valid before
the account exists, which would make it somewhere to guess them. The
first account is always allowed, or a new install would lock itself out
before an administrator existed to issue a code.

Flags fall back to their defaults when Redis is down, in the safe
direction each way: sharing keeps working, sign-up does not silently
open.

Found on the way: the registration form's three labels named nothing —
no `for`, no wrapping — so a screen reader announced unlabelled boxes.

Backend 261/261, frontend 328/328.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:17:35 +02:00
Daniel
765a477d4b feat: figures a question can actually have — managed, labelled, previewed
question_media replaced the two filename columns months ago: any number
of figures per question, each with a role, a label the prose can refer
to, a caption and an order. Only the editor's own endpoint ever read
them. The editor showed the two legacy text fields, and the player and
the answer review rendered the legacy paths — so the model existed and
nothing used it.

- FigureManager in the question editor: add from the image bank, name,
  caption, reorder, remove, per role. A figure with no caption is called
  out, because a caption is how anyone finds it again. The image id is
  shown, since that is what the link survives a rename by.
- FigureStrip on the player and the review. Explanation figures are
  labelled thumbnails that open full size and page between them — a
  stack of full-width radiographs between two paragraphs pushes the
  explanation off the screen, and "as in Figure 2" needs Figure 2 to be
  named where it sits. A stem figure stays full size: it is the question.
- question_figures.py is the single place rows become what a page
  renders, so the three views cannot disagree.
- Explanation figures are withheld until answers are revealed, the same
  rule the explanation itself follows.

The legacy paths still render where a question was never backfilled, so
nothing that worked before stops working.

Backend 242/242, frontend 290/290.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:59:43 +02:00
Daniel
a677b4be23 feat: an exam that runs out is a result; delete and reset go all the way down
An unsuspended exam keeps running. When its clock runs out it is
submitted with what was answered and the score counts — a learner who
ran out of time sat an exam, which is a result and not an accident to
hide. Previously it was graded, flagged expired=1, excluded from every
statistic, and the client was told the opposite ("submit manually").

- attempt_expiry.settle_if_expired: one path, used by resume and by the
  sessions list, so an exam left open elsewhere shows its score rather
  than "in progress" forever. Suspended attempts hold their clock and
  never expire.
- resume returns {expired_submitted, attempt_id}; the client opens the
  analysis. The suspend dialog and the leave warning now say what
  actually happens.
- delete: saved progress and device lock cleared; a study-plan block
  whose only completed attempt is deleted goes back to unfinished.
- POST /attempts/reset-all: typed RESET, removes attempts, answers,
  in-progress state, plan progress, reading marks, saved questions and
  question notes; leaves the account, authored content and AI chats.
  Settings → Your data, with the counts reported afterwards.

Also fixed on the way: the first version of the sessions-list change
mutated the dict it was iterating; the test only passed because it had
one attempt. Now two.

Backend 223/223, frontend 258/258.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:47:11 +02:00
Daniel
ac92793600 feat: study-plan blocks as modules, sessions that know their block
From the three recordings and the AMBOSS screenshots.

Study plans
- Blocks of about 40, split evenly: 202 questions is six blocks of
  33-34, not five of 50 and one of 2. Reseeded (no progress or reading
  existed yet); the seeder now splits the same way.
- A block has its own page, laid out as a course module: the plan's
  blocks down the left, this block's reading then its session in the
  middle, back / previous / next along the bottom. Study or exam mode
  is chosen there, before the session exists; afterwards the mode is
  shown, not offered. The plan page is the table of contents and links
  into blocks rather than starting anything.
- Progress on a block comes from the same /quizzes/sessions row the
  Sessions page shows, so the two cannot disagree.

Sessions <-> plans
- A session started from a block carries its place in the plan: the
  session list and the analysis both return `plan` (plan, block,
  position, previous and next block). The analysis shows a strip with
  the way back to the block and on to the next one.
- Submitting a session marks its block complete. Nothing ever set
  completed_at before — every block read as unfinished forever.

Recommendations
- Framed by the learner's chosen study objective: answers and bank
  material linked to a different exam are left out, and the page is
  titled for the exam. Unlinked material stays in, as elsewhere.

Backend 216/216, frontend 257/258 (the one failure is
ArticleSplitView, which is timing-flaky under the full run and is
unrelated to this change; being checked separately).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:31:21 +02:00
Daniel
ebb9e701ee fix: one sessions list, at /sessions, with plan material out of it
The page at /quizzes showed the same fifteen rows twice — once under a
"Sessions" tab as a list, once under a "Library" tab as cards — with
nothing distinguishing them. The navbar carried the duplication too,
with "Sessions" and "History" both pointing at the same page.

Board Review I-XII already exist as study plans. The Library tab was
showing the bulk quizzes those plans were built from, so the same twelve
titles appeared in both systems. Those quizzes are now origin='plan':
still real, still the parent of their questions via source_quiz_id, but
no longer offered as something to pick off a list. Once a learner has
actually sat one it is history, so the session list keeps it.

- QuizzesPage deleted; /sessions is the only listing
- /quizzes/* redirects to /sessions/*, preserving path and query
- submitting a session lands on its analysis, not the old score page
- the answer review drops its score hero, which the analysis owns and
  stated differently; a course quiz keeps its card, having no analysis
- delete-attempt moves to the analysis page, where the session lives

Backend 208/208, frontend 246/246.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:10:25 +02:00
Daniel
bc9dc290f8 feat: AMBOSS-style session management, question manager, showcase articles
Quiz management area (AMBOSS parity, verified against next.amboss.com):
- GET /quizzes/sessions returns one management row per accessible quiz —
  attempt state, live answered/total from Redis, last score and activity —
  so the page no longer fans out per-quiz requests.
- QuizzesPage rebuilt as a session list grouped by day with a progress bar,
  a state-aware primary action (Start / Resume / Review) and an action menu
  matching AMBOSS: Analysis, Repeat, Rename, Share, Edit, Category, Delete.
  Rename and delete confirm inline; no browser popups.
- Sessions / Library / Categories tabs replace the flat card grid.
- QuizPage honours ?restart=1 so Repeat always begins a fresh attempt.

Question manager (new moderator page at /questions/manage):
- GET /questions/manage/summary counts editorial gaps; /questions/bank gains
  a `needs` filter (category / explanation / difficulty / private) so the
  health tiles double as one-click filters.
- POST /questions/bulk applies category, difficulty, sharing or delete to up
  to 500 checked questions in one call, moderator-only.
- Question edit/create modals extracted to components/QuestionEditors.jsx and
  shared by the question bank and the manager instead of being duplicated.

Showcase articles:
- scripts/seed_showcase_articles.py seeds eight short starter articles across
  the main pediatric systems, each filed under a real category, with stable
  hex section IDs and links to bank questions from the same category.

Mobile: dedicated stylesheets for both pages — rows stack, the action menu
becomes a bottom sheet and the bulk bar docks to the bottom edge.

Tests: 9 new backend tests (session feed states, ordering, Redis-outage
degradation, visibility; bulk actions, gap filters, moderator gate) and 9 new
frontend tests. Full suites green: 72 backend, 106 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:27:53 +02:00
Daniel
179e51d143 feat: per-option explanations
Questions support an explanation per option, edited in the question dialog and shown in study feedback. Keys must match current options. Migration j3e4f5a6b708. 60 backend and 95 frontend tests pass.
2026-09-08 19:29:21 +02:00
Daniel
0fa8d0a689 feat: shareable quiz links, moderated comments and AI authoring
Quiz share links replace the PIN copy with a public /share/{token} landing page; owners can enable/revoke without showing the full link. Moderated article/question comments with approval flow, bounds and rate limits. Educator AI article drafts/refine and private card generation with Celery job polling. Migrations f2a1c9d4e801 and g4b7e2f5a903. 50 backend and 85 frontend tests pass.
2026-09-07 17:10:08 +02:00
Daniel
1ce3eec7cb feat: authorize tutor context and protect uploaded media
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
2026-09-07 15:04:00 +02:00
Daniel
a3a6ef7995 feat: redesign quiz runner and add study tools
Add Orthobullets-inspired numbered-answer UI, explicit study response confirmation, response statistics, review navigation, safe calculator, keyboard controls and sourced educator lab references. Persist attempt mode to prevent query-flag exam disclosure. Combined deployed-image backend suite (22), frontend suite (48), build and synthetic desktop/mobile browser checks pass. PostgreSQL round-trip and independent review remain release gates; no production deployment.
2026-09-07 03:10:23 +02:00
Daniel
f696b99569 fix: close custom-test review gaps and verify grading
Handle ownerless question revocation, legacy hide sharing, private deletion, selected-set grading including skips, UI validation/reparent/delete safeguards and offline-safe hierarchy migration. Verified 14 backend tests in deployed image, 13 frontend tests/build and real disposable PostgreSQL migration round-trip. Related tutor/image privacy work remains before deployment.
2026-09-07 02:07:58 +02:00
Daniel
affd7177b5 feat: add category-based custom tests and permission-safe sharing 2026-09-07 01:17:42 +02:00
Daniel
3cf21d4438 chore: preserve pre-revamp local changes
Recovery snapshot of the existing worktree before the Orthobullets-inspired revamp. Includes explanation images, classification snapshots, quiz visibility/resume fixes, quiz codes, TTS options and bot formatting. Secret heuristic and Python syntax checks passed; not a release or full behavioral validation.
2026-09-07 00:50:35 +02:00
Daniel
8137d5b20c Decouple course quizzes, replace passlib, add security hardening
- Fully decouple course quizzes from main quiz system (hidden from
  dashboard stats, history, search, results page)
- Course quiz results show "Back to Course" instead of retake/delete
- Add allow_review toggle for course creators to control answer review
- Show quiz title on course page, hide pool size from students
- Add course thumbnails to browse cards
- Replace passlib with bcrypt directly (compatible with existing hashes)
- Add HIBP breached password warnings on register/reset/change password
- Add CLI management tools (reset-password, set-role, stats, etc.)
- Fix quiz PATCH endpoint: ownership check instead of moderator-only
- Add max_length validation on course/module/lesson titles
- Fix score display bug on results page (0 of N when review disabled)
- Fix question count on course quiz start (show per-attempt, not pool)
- Improve suspend warning for timed course quizzes with max attempts
- Clean up validation error messages (show "Invalid email" not Pydantic dump)
- Add DDL migration for allow_review column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:31:05 +02:00
Daniel
2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- Fix tag filtering (sa_text import shadowing caused UnboundLocalError)
- Add TagBrowser component with per-section search
- Multi-category selection (OR within categories, AND with tags)
- AI image validation: has_figure field in extraction prompt
- Skip known branding images by MD5 hash + dimension filters
- Fix quiz timer auto-submit (wrong useEffect dependency)
- Fix QuizResponse schema: section_id nullable
- Fix Question.quiz_id → source_quiz_id attribute name
- Fix SQL injection in quizzes.py vector search
- Add PDF processing progress steps via Redis
- Add delete user from admin panel
- Admin page: no spinner flash on data refresh
- Upload progress: axios 1.x e.progress, remove manual Content-Type
- Duplicate model error: 409 with clear message
- Backend startup: retry DDL migration on lock timeout
- Replace all silent except:pass with warning logs
- Comprehensive multi-page documentation (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:26 +02:00
Daniel
12d99d3609 Add switchable embedding model, Polly toggle, job cancellation, and UI fixes
Embedding:
- Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env
- Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation)
- Passes dimensions=1024 to proxy; Redis setting overrides env var
- Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim)
- Test button in admin UI to verify model works
- Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names)

Polly:
- Global enable/disable toggle in Admin → More settings (stored in Redis)
- /tts/voices filters out polly/* when disabled
- /tts/speak rejects polly requests when disabled

Job cancellation:
- POST /quizzes/job/{job_id}/cancel endpoint
- Cancel button on JobsPage for running jobs
- Celery task checks Redis status at each chunk boundary and exits cleanly
- Fixes DB lock on restart caused by cancelled jobs leaving open transactions

Admin UI:
- Settings tab renamed to "More" (heading: More Settings)
- Model row overflow fixed (minWidth: 0 + ellipsis on model_id)
- Embedding model search shows all proxy models (no auto-filter by "embed")
- Navbar correctly excludes cancelled/failed jobs from "extracting" count

README:
- Added Rebuild & Restart section with commands
- Updated embedding model reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:44:11 +02:00
Daniel
b859d441eb Add extraction modes; fix resume; hide Nextcloud/Upload for users; delete PREP 2013 questions
Extraction modes (no restart needed — code ready for next Celery deploy):
- New QuizCreate.extraction_mode field: standard|questions_only|two_step|regex|ai_decide
- extraction_modes.py: independent implementations that don't touch standard path
  - questions_only: extract Q+options, correct_answer="PENDING" for manual fill
  - two_step: separate answer key section scan + phase1/2/3 matching
  - regex: AI detects answer pattern, generates regex, applies to full doc
  - ai_decide: AI reads samples from start+end and picks strategy
- DocumentDetailPage: Extraction Mode dropdown with description per mode
- quiz_tasks.py: routes to correct mode, standard path completely unchanged

Database:
- Deleted 11 orphaned questions from PREP 2013 extraction (quiz 12 was already deleted)
- 268 questions remaining (all PREP 2012)

UI fixes:
- Nextcloud section in Settings now only shown to moderators/admins
  (regular users can't upload PDFs so they don't need Nextcloud)
- Upload PDF already hidden in navbar for non-moderators (confirmed correct)
- Resume quiz: now async — study mode quiz data loaded BEFORE showing quiz
  so correct_answer is available immediately for feedback
- Resume saves and restores voice selection
- voice field added to ProgressSave schema and Redis storage
- Progress save dependency includes selectedVoice

Attempts:
- POST /attempts/start: reuses existing incomplete attempt by default (fresh=false)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:43:24 +02:00
Daniel
45add79db3 Fix quiz delete (500 error), add trash bin, quiz resume, hide/publish, remove JS dialogs
Critical bug fix:
- DELETE /quizzes/{id} was returning 500 due to {\"quiz_id\": None} update using
  the DB column name instead of Python attribute name (source_quiz_id).
  Fixed with {QuestionModel.source_quiz_id: None, synchronize_session=False}

Extraction fix:
- Two-phase extraction was incorrectly triggering for PREP 2012/2014 format
  documents that have 'Preferred Response:' in their explanation text.
  Fix: check for inline 'Correct Answer:' first — if found, always use standard
  extraction regardless of 'Preferred Response:' appearing elsewhere.

Quiz trash bin:
- DELETE /quizzes/{id} now soft-deletes (sets deleted_at)
- GET /quizzes/trash — list deleted quizzes (moderator)
- PATCH /quizzes/{id}/restore — restore from trash
- DELETE /quizzes/{id}/permanent — permanent delete (must be in trash first)
- TrashPage.jsx — accessible via Settings → Admin → Trash

Hide/publish quizzes:
- is_published column on quizzes (1=visible, 0=hidden)
- PATCH /quizzes/{id}/publish?published=false — hide from regular users
- Moderators see all quizzes; regular users only see published
- 👁/🙈 toggle button per quiz card (moderators only)

Quiz progress resume (cross-browser via Redis):
- POST /attempts/progress — save {answers, current_idx, mode} to Redis (7 days)
- GET /attempts/progress?quiz_id=N — retrieve saved progress
- DELETE /attempts/progress/{quiz_id} — clear on submit
- QuizPage auto-saves to Redis every 1.5s (debounced) while in progress
- ModeSelectScreen loads saved progress from server, shows Resume button
- Works across browsers, devices, and after logout

Delete attempt:
- DELETE /attempts/{id} — user can delete own attempt + clears reminders for that quiz

ConfirmButton component:
- Replaces all window.confirm() / window.prompt() across the app
- Double-click pattern: first click shows [Confirm] [Cancel] inline
- Applied to: QuizzesPage, DocumentDetailPage, QuizEditPage, QuestionBankPage

Category delete (QuestionBankPage):
- window.prompt() replaced with inline modal dialog with select dropdown
- User chooses where to move questions before deletion

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:51:41 +02:00
Daniel
0bc336a7d0 Quiz delete: detach questions to bank instead of deleting them
When deleting a quiz:
- Questions shared with other quizzes stay linked to those quizzes
- Questions exclusive to this quiz are detached (source_quiz_id cleared)
  and remain in the Question Bank as orphaned questions, not deleted
- This preserves the question bank integrity

Also improves manage.py CLI with extract, list-sections, and jobs commands

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:36:25 +02:00
Daniel
871206d891 Cross-browser job tracking, hamburger fix, CLI extract, quiz delete keeps bank questions
Jobs (cross-browser/cross-session):
- POST /quizzes/ stores job_id in Redis under user key (extraction:user_jobs:{uid})
- GET /quizzes/jobs returns all recent jobs for current user from any browser/session
- Navbar JobsBadge polls /quizzes/jobs API every 4s (not localStorage)
- Shows all recent jobs with status badges; links to quiz when complete
- Badge visible even after extraction completes so you can always get back

Mobile navbar fix:
- .navbar .container height was overriding dropdown to 52px (clipping all links)
- Fixed by using .navbar-inner class for the header row only

CLI extract command:
  python manage.py list-sections [doc_id]   — list docs + sections with IDs
  python manage.py extract <section_id>     — inline blocking extraction
  python manage.py extract <section_id> --bg  — background via Celery
  python manage.py jobs                    — show all extraction jobs in Redis
  python manage.py jobs --user <email>     — filter by user

Quiz delete + question bank:
- When a quiz is deleted, questions that belong ONLY to that quiz are deleted
- Questions shared with other quizzes (via junction) are kept in the bank

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:30:47 +02:00
Daniel
96a1a259f0 Async extraction with live progress + chunked large PDFs
Extraction is now fully async via Celery — UI shows a live progress panel,
job continues even if page is closed. Large documents are processed in
50-page chunks to extract all questions (not just first ~50 pages).

Backend:
- app/tasks/quiz_tasks.py: new Celery task 'extract_quiz'
  - Writes step-by-step progress to Redis (extraction:steps:{job_id})
  - Splits large page ranges into 50-page chunks, processes each separately
  - Reports per-chunk results and running total
  - Falls back to synchronous if Celery/Redis unavailable
- POST /quizzes/ now returns {job_id, status:"pending"} immediately
- GET /quizzes/job/{job_id} polls progress: steps[], status, quiz_id on completion
- Celery task list updated to include quiz_tasks

Frontend (DocumentDetailPage):
- ExtractionProgress modal component: monospace step log, auto-scrolls, spinner
- Polls job status every 2 seconds via /quizzes/job/{job_id}
- "Open Quiz →" button appears when done
- "✕ closes — job continues in background" shown while running
- beforeunload warning when job is active (preventing accidental close)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 01:55:24 +02:00
Daniel
f2d15c3c57 Junction table for shared questions; verification blocking; semantic bank search; bug fixes
Question sharing (edits now propagate):
- quiz_question_links junction table: quizzes reference questions, not own copies
- Existing questions migrated to junction (idempotent ON CONFLICT DO NOTHING)
- from-bank and from-category create quiz by adding junction links (no copying)
- Editing a question via QuizEditPage or bank Edit modal updates the ONE record
  visible in all quizzes that reference it
- Delete from quiz: only deletes Question record if no other quiz references it
- quiz_service: fixed position counter (was using len() incorrectly, now enumerate)
- question.quiz_id renamed to source_quiz_id in Python (DB column still quiz_id)
- All attempts/quizzes/search code updated to use junction helper or source_quiz_id

Bug fixes:
- PostgreSQL en_US.utf8 collation B-tree index corruption on users.email
  Fixed by ALTERing column to COLLATE "C" in migration + immediate repair
- get_current_user now blocks unverified users on ALL endpoints (403 + X-Unverified header)
  api/client.js intercepts X-Unverified=true and auto-logs out to /login
- Search (quiz + bank): correct_answer and explanation now included in matching_questions
  so study modals work correctly
- Keyword search: order_by updated to use source_quiz_id
- Bulk category: changed from PATCH with body array to POST with Pydantic body
  (fixes null category_id removal)
- Question bank: edit button added, answer highlighting removed from list view
- QuestionBankPage: Keyword/Semantic/Hybrid search toggle
- Login: login error message stays visible (removed immediate reload)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:55:13 +02:00
Daniel
a7a5bdff62 Proper question bank system with question categories
Architecture:
- Questions are primary objects in a bank, tagged with question categories
- QuestionCategory is a separate taxonomy from QuizCategory (different concepts)
- Extraction → questions added to bank, optionally tagged to a question category
- Quizzes can be created from: individual question selection, question category, or PDF extraction

Backend:
- QuestionCategory model + question_categories table
- question_category_id column on questions table (nullable, SET NULL on delete)
- GET/POST/PATCH/DELETE /api/question-categories/
- POST /api/question-categories/{id}/create-quiz — create quiz from all questions in a category
- PATCH /api/questions/{id}/category — assign single question to category
- PATCH /api/questions/bulk-category — assign multiple questions at once
- GET /api/questions/bank?category_id=&uncategorized= — filter by category
- QuizCreate schema now accepts question_category_id for extraction
- quiz_service.create_quiz_from_section accepts question_category_id param

Frontend:
- DocumentDetailPage: Add to Bank Category dropdown in Quiz Settings (optional)
  Labels extracted questions with the selected category on creation
- QuestionBankPage: full rewrite
  - Category chips for filtering (All / Uncategorized / named categories)
  - Create category button inline
  - Checkbox multi-select with bulk category assignment
  - Create Quiz modal: choose from selected questions OR all from a category
  - Each question shows its category badge and quiz source
  - Study modal with instant answer feedback

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:34:39 +02:00
Daniel
47ba213ae3 Major platform update: pgvector search, multi-provider TTS, settings page, CLI
Features:
- Hybrid semantic + keyword quiz search (pgvector HNSW + PostgreSQL ILIKE)
- AWS Bedrock Titan Embed V2 embeddings via LiteLLM proxy (0.71 cosine sim)
- Multi-provider TTS: OpenAI, AWS Polly (neural), ElevenLabs, Google Cloud TTS
- Unified Settings page (profile, theme, Nextcloud integration, admin shortcuts)
- Good morning/afternoon greeting on dashboard
- manage.py CLI: reset-password, list-users, reembed
- Email verification enforced: register no longer returns JWT for unverified users
- Quiz search with debounced input, semantic/keyword/title modes, highlighted snippets
- TTS button: loading/playing states, voice selector locked during playback
- TTS auto-stops when navigating between questions
- Footer added; mobile quiz nav overflow fixed; markdown theme body selector fixed
- OpenAI Alloy as default TTS voice; favicon added
- SMTP configured via smtp2go; password reset rate limiting (3/hour)
- PostgreSQL upgraded to pgvector/pgvector:pg16

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:03:10 +02:00
ifedan-ed
b876f13fac Initial commit: PDF Quiz Generator app
- FastAPI backend with JWT auth, roles (admin/moderator/user)
- PDF upload (up to 500MB) with streaming, PyMuPDF text extraction
- ChromaDB vectorization per page with metadata
- LiteLLM AI question extraction from PDF (not generation)
- Image extraction from PDF pages, graceful fallback
- Quiz modes: timed (countdown timer) + learning (answers shown inline)
- Page-by-page question navigation with dot navigator
- TTS endpoint using LiteLLM (Google Vertex / OpenAI voices)
- Admin dashboard: AI model management per task, user role management
- Moderator role: upload PDFs, create sections, generate quizzes
- Spaced repetition reminders via SMTP email (SM-2 intervals)
- APScheduler daily reminder jobs
- Celery + Redis for background PDF processing
- React frontend with all pages
- Docker Compose deployment (nginx + backend + celery + redis)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:04:53 +00:00