Commit graph

17 commits

Author SHA1 Message Date
Daniel
e72cdd6716 feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs
**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.

**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.

**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.

It found four things in its first hour:

- **A fresh deploy could not start.** `create_all()` ran before
  `CREATE EXTENSION vector`, so any database that had never had pgvector
  installed died on the first table with a vector column. Invisible here
  because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
  Media in the library is nobody's to read by default, and nothing made an
  exception for a drawing an article actually shows — so every illustration
  added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
  nginx's address for every request, so ten bad passwords from anybody locked
  out everybody, and no log line could say who. nginx now takes the real
  address from the proxy and overwrites the header on the way in; uvicorn runs
  with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
  820px in the stylesheet. Between them the menu button claimed the contents
  drawer and then toggled a class on a rail that was still in the layout: the
  contents did not open and the site menu did not either. The button was dead
  on every tablet.

And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.

The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.

CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 01:23:38 +02:00
Daniel
16aed6b6b0 feat: Cap on its own host, hints per topic, and an objective is asked for
Cap moved from /cap/ under this app to cap.pedshub.com, so anything else
on this machine can use the same instance. Caddy terminates it, the
backend keeps verifying over the compose network rather than going out
and back, and the widget endpoint is configuration rather than a path
baked into the component. Verified: a challenge is issued on the
subdomain, and a token that was never issued is still refused.

"Correct using hints" is now a per-topic figure. The knowledge profile's
accuracy bar was two-tone because /study-tools/recommendations carried
only `answered` and `correct`; the hint count existed lifetime-wide but
never per topic, and inferring one from the other would have been a
different set of answers drawn as though it were this one. The column
was already on attempt_answers, so it is a group-by, and the bar is
three-tone as the reference has it.

And the objective is asked for. It decides which questions exist, how
relevance is weighted, and what readiness measures against — and it was
possible to sit a whole board paper without ever being asked, because no
objective quietly means the entire bank. That is a reasonable default and
a poor thing to arrive at by accident. Five of six accounts here had
never set one.

It can be declined: "everything" is a real answer, and trapping somebody
behind a modal because a list failed to load would be worse than the gap
it closes. Declining is still a choice made, which is the point.

Also in this commit, from the exam-player work: Show answer in study mode
that reveals without recording an answer, review keyed on the attempt
being closed rather than every question being answered — a block that
timed out with nothing answered is over too — and the exam top and bottom
bars. That work found something worth knowing: the exam player is *served*
questions with no correct answer and no explanation, so review cannot
un-hide what it never had, and the player refetches the marked version
once the attempt closes. Nothing is revealed while a block is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:24:23 +02:00
Daniel
4ca7f6b1f2 feat: Cap replaces hCaptcha, self-hosted beside the app
Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.

The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.

Two things that had to be found rather than read:

Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.

And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.

Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.

Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.

And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:14:14 +02:00
Daniel
af59fdb960 fix: MinIO name collision, and refuse to write an article from fragments
MinIO was resolving to the wrong container
Putting the backend on danvics_milvus to reach the clinical index gave it a
second service called `minio`, and Docker resolved that one first. Every object
read failed with InvalidAccessKeyId while the bucket simply looked empty — all
435 stem images unservable, and nothing in the logs saying why. The quiz MinIO
now answers to `quiz-minio`, which nothing else on this host claims.

A topic named after a shelf retrieved headings, not prose
"Pediatric Pulmonology" returned ten chunks whose top hit was 29 characters —
`**270** Pediatric Pulmonology`, an index line. Chapter titles rank well against
a query that looks like a chapter title. The model was handed a prompt with
citations and no content and said so, which was the correct response and read as
a JSON failure.

Two gates, both stated in the code. A chunk under 200 characters is a heading or
a running header rather than something to write from. A topic whose passages
total under 3,000 characters is skipped with the count in the reason, rather than
asking a model to write a medical article out of fragments — it will either
refuse or invent, and only one of those is visible.

The 71 generated drafts are deleted at the user's request. Nothing linked to
them and generation is resumable, so the cost was model calls rather than work.

Question bank corrections, from the agent that ran alongside:
262 questions had OCR-mangled units repaired — `inEq/L`, `mrnol/L`, flattened
`10⁹` superscripts and the rest — each with a version snapshot written first, so
every edit is reversible from the existing question editor. 94 stem images that
belonged to the explanation were removed; PREP's own `Item Q37A` / `Item C37B`
labels turned out to be a far better signal than word cues, taking the confident
split from 69/58/308 to 300/81/54. 13 uncertain images are listed for a person.

203 backend, 223 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 17:31:53 +02:00
Daniel
025e5bb4ac feat: article CMS, three reading views, and articles written from the library
Standardises cross-references the way we agreed, and puts a CMS around articles
so hundreds of generated drafts are reviewable rather than merely present.

Links, made rename-proof
`[[7|Febrile seizures]]` resolves by id and displays the text — the id is the
part that must not change, the text is what keeps prose readable while you write
it. `[[old-slug]]` still resolves and is rewritten to the id form on save, not in
a migration: an article nobody has touched is not broken, and rewriting prose no
one asked to change is how an editor stops trusting the editor. Every slug an
article has ever had is kept, so a rename redirects instead of 404ing, and a save
reports markers pointing at nothing — at the moment the person who wrote the link
is still looking at it.

Three views of one topic
The full article to study from, the key points to revise from, the clinical view
to act from, with doses. They are views of one article rather than three
articles, so the numbers cannot drift apart and a question linked to the topic
still means one thing. Each section carries its variant; articles written before
this are the long view, unchanged.

CMS
draft → in review → published, with an author able to submit and only a
moderator able to publish. Every save snapshots what was there, restorable, and
restoring is itself snapshotted or the way back from a mistaken restore is gone.
The editorial queue is work rather than inventory: waiting for review, generated
and unread, published without sources, published with nothing to practise,
barely written. An empty bucket is drawn as good news, not as an alert.

Articles from the clinical library
The library index is 1.8M chunks of reference texts embedded with bge-m3 — the
same model PedsHub already uses, so our query vectors are directly comparable and
nothing had to be re-indexed. Retrieval supplies the facts and the provenance;
the model supplies the prose. References are built from the metadata of the
passages actually retrieved, never from the model, so a reference cannot be
invented — the same property that makes an AI Mode citation trustworthy. A topic
with fewer than three grounding passages is skipped rather than written from
memory. Everything lands as a draft.

Two things worth naming. The generated text is original writing grounded in those
books, not extracts from them: their facts are usable, their sentences are their
publishers'. And there are two Milvus servers on this host — the collection with
the data is the one reached as `milvus`, not the similarly named one on the other
stack, which I wired up first and which silently refused.

Also fixed along the way: `litellm==1.28.13` has been withdrawn from PyPI, so
requirements.txt could no longer be resolved from scratch and the image only
built because of a cached layer. Later additions go in their own layer until the
pins are refreshed.

182 backend, 223 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 17:13:07 +02:00
Daniel
db2df87fc6 feat: MinIO-backed media libraries, and file the last 316 questions
Storage
Media now goes through `storage_service`, which has two backends: the container
volume, and S3/MinIO. A volume can only be mounted by one host, has no presigned
URLs and no lifecycle rules, none of which suits ~860 MB of media. Reads fall
back to the volume when an object is missing, so the existing uploads keep
working and files can migrate gradually rather than in one risky pass.

A row stores the object key, never a URL: a URL embeds the backend, so a row
holding `http://minio:9000/...` breaks the moment the backend changes.

MinIO publishes no host ports — the backend reaches it over the compose network,
and 9000/9001 are already taken on this host by other stacks.

Image libraries (migration d2e3f4a5b6c7)
An image belongs to a library, and a person is granted a library the way they are
granted a category, so access can be given to some images without giving away all
of them. Tags reuse the shared `question_tags` vocabulary rather than inventing a
media-only one. Uploads are type- and size-checked, stored through the service,
and embedded so an image can be found by what it shows.

Classification finished
The 316 questions the chooser had declined are now filed with `--force`, which
takes the nearest candidate from the same shortlist the chooser saw. 306 were
forced, 10 the chooser accepted on this pass. No question sits on a bare system
any more:

  system only          2,730 -> 0
  condition/subsystem    214 -> 1,782
  full depth               4 -> 1,166

A forced match is a weaker signal than a chosen one, so expect more errors among
those 306 — but the original system stays as a cross-link, so nothing is lost and
they can be corrected by hand.

Tests: 8 new backend covering library scoping, edit confinement, shared-vocabulary
tags, storage indirection on upload, and type/size limits. 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgRcMaScVEL7TBLpnAoSV9
2026-09-10 06:45:22 +02:00
Daniel
519f2e572a feat: hybrid search on BGE-M3, with embedding provenance and a retry job
Search
- Retrieval was hybrid in name only: the keyword filter was applied to the SQL
  query, so results were the *intersection* of the two rankers. A question that
  matched the meaning but not the literal string could never be returned. It is
  now a union, fused with Reciprocal Rank Fusion (a text rank and a cosine
  distance are not on comparable scales, so RRF uses only their orderings).
- Added a generated `search_vector` tsvector + GIN index, so the lexical half is
  ranked full text rather than ILIKE substring matching.
- Chose Postgres + pgvector over OpenSearch/Elasticsearch: a search cluster
  would add a second datastore to keep in sync and a JVM on this host, to
  replace an index Postgres maintains inside the same transaction.
- Removed the keyword-only mode. It looks precise but silently drops the
  question that asks the same thing in different words.

Embeddings — measured on 500 real questions, using each question's own
explanation as a paraphrase query (known answer, no hand labelling):
  bge-small (local CPU, 384d)  R@1 0.840  R@5 0.953  186ms/query
  bge-m3 (LiteLLM proxy, 1024d) R@1 0.847  R@5 0.973   93ms/query
BGE-M3 wins on both quality and latency and needs no extra credential, since
llm.danvics.com already serves `openrouter-bge-m3`.

Three gaps this exposed, all fixed:
- Nothing recorded which model produced a stored vector, so changing models
  silently mixed incomparable spaces. `embedding_model` / `embedded_at` now
  stamp every vector, `GET /admin/embedding/health` reports current vs stale vs
  missing, and regeneration defaults to stale-only.
- The generator read the model from env while the stamp read a Redis override,
  so a vector could be labelled with a model that did not produce it. Both now
  resolve through one function, with a regression test.
- Embedding at creation is best effort, and a failure left a question invisible
  to semantic search forever. `retry_missing_embeddings` runs every 15 minutes
  via Celery beat and backfills missing or stale rows.
- Query embeddings are cached in Redis per model, so typing is not a network
  round-trip per keystroke.

`dimensions` is only sent to OpenAI's embedding-3 family; BGE-M3 rejects it.

Tests: 8 new backend tests (union not intersection, fusion ordering, per-ranker
failure degradation, provenance stamping, stale/missing accounting, generator
and stamp agreement). Full suites green: 95 backend, 127 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 23:45:33 +02:00
Daniel
e7f91e5e5b fix: scope quiz logging and bot status 2026-06-06 00:01:44 +02:00
Daniel
ffeb35922a Improve quiz UX and add Telegram bot 2026-05-12 15:57:27 +02:00
Daniel
d59c8bed6f Remove coach modes and improve quiz audio 2026-05-10 01:24:06 +02:00
Daniel
d0518d0737 Add comprehensive structured logging with Loki + Grafana
Backend logging:
- Centralized JSON logging config with LOG_LEVEL env var
- Request logging middleware: user, method, path, status, duration, request_id
- Fixed all 9 silent except:pass blocks to log warnings with tracebacks
- Celery workers use same structured JSON format

Infrastructure:
- Loki 3.3.2 for log storage (30-day retention)
- Promtail 3.3.2 for Docker container log shipping
- Grafana 10.3.1 with auto-provisioned Loki datasource
- Grafana on port 3002 (admin/pedshub_grafana)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:53:54 +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
4cbaa529f0 Workers x4, follow-up suggestions, singleton lock, admin error details
- docker-compose.yml: --workers 4 on uvicorn
- main.py: Redis singleton lock so only one worker runs scheduler + backfill (prevents 4x cron jobs)
- teach.py: system prompt requests 3 follow-up suggestions ("> " prefix format)
- teach.py: parse and return suggestions[] separately from reply
- TeachChat.jsx: render clickable suggestion chips after last assistant message (click fills input)
- admin.py: restore full error details for admin endpoints (ElevenLabs, Polly, LiteLLM)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:27:03 +02:00
Daniel
a5188c4edd Better fonts, hamburger mobile nav, jobs indicator, warm theme refinement
Fonts:
- Added Google Fonts: Inter (UI), Playfair Display (headings), Source Serif 4 (reading)
- Default theme now uses Inter — clean, modern, professional
- Warm Brown theme: body text uses Source Serif 4 (screen-optimized modern serif,
  not old-fashioned Georgia), question text uses Source Serif 4 for readability,
  headings use Playfair Display (elegant, editorial), UI elements use Inter
- Result: literary feel without looking like a Word document

Mobile Navbar (hamburger menu):
- Desktop: horizontal nav unchanged
- Mobile: logo + right-side controls (jobs badge + hamburger icon)
- Hamburger animates to X when open, full-width dropdown below navbar
- Active route highlighted; auto-closes on navigation

Jobs indicator:
- Amber spinner badge in navbar when extractions are running
- Tracks jobs in localStorage (pedquiz_jobs key)
- ExtractionProgress updates localStorage as steps arrive
- Click badge for dropdown showing current step per job

CSS:
- .nav-desktop / .nav-mobile-controls classes for responsive nav
- Default cards: slightly refined shadow and border

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:14:18 +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