diff --git a/README.md b/README.md index 1afd358e..1f4af880 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ The app runs as an authenticated Express/Postgres service with a browser fronten - Marp slide editing with preview and PPTX export. - Keyword, semantic, and hybrid search using Postgres/pgvector where configured. +### My Resources + +- Private teaching material any signed-in user can generate for themselves — nobody else sees it. +- Presentations are designed as slide decks (comparisons, tables, callouts, figures beside text), not written as markdown for a parser to guess at. +- Grounded in the indexed clinical library, and optionally PubMed and the web, each admin-enabled. +- Optional illustrations, several per resource, placed through the deck. +- Revise in place, and download as PowerPoint, Word or PDF. See [docs/my-resources.md](docs/my-resources.md). + ### Clinical Assistant - Optional MCP-backed clinical assistant integration. @@ -166,6 +174,8 @@ Primary references: - `docs/ai-providers.md` for model/provider setup. - `docs/speech.md` for server-side STT/TTS setup. - `docs/learning-hub.md` for the CMS and education workflow. +- `docs/my-resources.md` for private teaching material, the slide renderer, and search sources. +- `docs/retrieval-tuning.md` for how much corpus each feature retrieves, and what it costs. - `docs/configuration.md` for environment variables. - `docs/deployment.md` for production deployment. - `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes. diff --git a/docs/authentication.md b/docs/authentication.md index 64283a44..c03262b3 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -103,6 +103,8 @@ Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID. |---|---| | `/api/*` general | 200 req / min / IP | | `/api/auth/login` | 10 / 15 min | +| `/api/auth/login-code/request` | 5 / hour | +| `/api/auth/login-code/verify` | 10 / 15 min | | `/api/auth/register` | 5 / hour | | `/api/auth/forgot-password` | 5 / hour | | `/api/auth/resend-verification` | 3 / 15 min | @@ -111,6 +113,15 @@ Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID. Limits are per-IP (`express-rate-limit`). A clinic behind a single NAT shares the bucket; increase or switch to per-user keying if that becomes a problem. +Requesting a sign-in code is limited more tightly than attempting one, because +each request sends mail to somebody else's address — the cost of abuse lands on +the mailbox owner, not the caller. `LOGIN_CODE_RATE_LIMIT_MAX` overrides it. + +These are separate limiters rather than covered by the `/api/auth/login` one: +Express matches `app.use` paths on segment boundaries, so `/api/auth/login` does +**not** match `/api/auth/login-code/...`. A new sign-in endpoint needs its own +entry or it has no limit at all. + ## Login enumeration resistance `/api/auth/login` returns `"Invalid credentials"` for: @@ -121,6 +132,67 @@ the bucket; increase or switch to per-user keying if that becomes a problem. `"Email not verified"` is still returned for unverified accounts — deemed a necessary UX tradeoff over perfect indistinguishability. +## Sign-in codes + +A six-digit code emailed to the address being signed in with, offered beside the +password rather than instead of it. The screen asks for the email first, then +shows both routes: the code depends on mail being delivered and the password +does not, so neither is allowed to be the only way in. + +`POST /api/auth/login-code/request` → `POST /api/auth/login-code/verify`. + +What makes it a front door rather than a weaker side entrance: + +| | | +|---|---| +| Storage | bcrypt hash only, in `login_codes` — a code read out of the database is not a working credential | +| Lifetime | 10 minutes | +| Reuse | single use, marked used **before** the session is issued so a replay cannot race it | +| Supersession | requesting a new code deletes the previous one | +| Guessing | 5 wrong attempts burn the code; six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries at one code | +| Two-factor | still applies — a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it | + +Generation uses rejection sampling on `crypto.randomBytes`, not modulo, which +would make low digits slightly likelier. + +`loginCodes.sweep()` clears codes more than a day past expiry. It is fire and +forget: housekeeping never fails a request. + +### Frontend note + +`public/js/authFetch.js` keeps an allowlist of `/api` paths callable with no +verified account owner, and rejects everything else **before it is sent**. A new +pre-auth endpoint must be added there or it fails as a "Connection error" with +no request ever leaving the browser. + +## Registration invitations + +`registration_invites` holds codes that let someone register while +`registration_invite_only` is on. Only the hash is stored; the code is shown +once, at creation. + +Four states: `active`, `used`, `expired`, `revoked`. + +**Revoke** stops a live code and leaves the row, marked. **Delete** removes the +row, and is only permitted once the code can no longer be redeemed: + +```sql +(used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW())) +``` + +Deleting a code that could still be redeemed takes it off the list without +taking it out of anybody's inbox: the holder keeps something that looks valid, +it quietly stops working, and nothing is left to say who had it. Revoked rows +are kept because revoking records a decision somebody took. + +The condition is written to match the status the admin list displays. The +simpler `used OR expires_at <= NOW()` would also catch a revoked code whose date +had since passed — a row the screen still labels revoked and offers no delete +on, so button and query would disagree about the same row. + +`DELETE /api/admin/invites/spent` clears them in bulk under the same rule. It is +declared **before** `/invites/:id` or Express reads `spent` as an id. + ## Turnstile (Cloudflare bot protection) Applied to `/api/auth/register` and `/api/auth/forgot-password` when diff --git a/docs/configuration.md b/docs/configuration.md index d8e6583d..abfe7516 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,12 +72,15 @@ keys): | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile. Turnstile check is no-op when secret is unset. | | `LOKI_URL` | Optional Loki ingest URL for shipping audit/api/access logs. | | `NTFY_URL`, `NTFY_TOPIC` | Optional ntfy push for new-login / password-change notifications. | +| `LOGIN_RATE_LIMIT_MAX` | Sign-in attempts per IP per 15 min (default 10). Raised in the e2e stack so multi-worker Playwright runs do not trip it. | +| `LOGIN_CODE_RATE_LIMIT_MAX` | Emailed sign-in codes per IP per hour (default 5). Lower than the sign-in limit because each request sends mail to somebody else's address. See `docs/authentication.md`. | ### Integrations | Variable | Purpose | |---|---| | `NEXTCLOUD_URL` | Nextcloud base URL (per-user credentials entered in app). | +| `GOTENBERG_URL` | Document conversion service for PDF export (default `http://gotenberg:3000`). PowerPoint and Word are produced in-process and keep working when this is unreachable; only PDF fails. | | `S3_BUCKET`, `S3_REGION`, `S3_PREFIX`, `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` | Document object storage. `S3_FORCE_PATH_STYLE=true` for MinIO, Backblaze B2, most non-AWS providers. | ## `app_settings` — live runtime configuration diff --git a/docs/deployment.md b/docs/deployment.md index fcc85dae..8df531f1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,6 +6,24 @@ - Reverse proxy (Caddy, Nginx, Traefik) for TLS termination - At least one configured AI provider (Bedrock / Azure / Vertex / LiteLLM / OpenRouter) +## What the image carries + +Beyond Node, the runtime image installs a few tools that document export depends +on. They are in `Dockerfile` and worth knowing about before trimming it: + +| | For | +|---|---| +| `pandoc-cli` | Word (`.docx`) export | +| `python3`, `py3-lxml`, `py3-pillow` | the slide renderer. Both libraries are C extensions with no Alpine wheels, so they come from apk rather than pip — installing them from source would mean carrying a compiler in the runtime image | +| `python-pptx==1.0.2` (pip) | builds the decks. Pinned: unpinned, a rebuild from the same commit could produce different slides | +| `ffmpeg`, `curl`, `jq` | audio handling and entrypoint scripting | + +Roughly 58MB of that is Python. PDF conversion is **not** in the image — it goes +to Gotenberg over the network (`GOTENBERG_URL`, default `http://gotenberg:3000`), +so PowerPoint and Word still work when Gotenberg is down and only PDF fails. + +See [my-resources.md](my-resources.md) for what the renderer does. + ## Images | Image | Role | diff --git a/docs/my-resources.md b/docs/my-resources.md new file mode 100644 index 00000000..92dfe9ed --- /dev/null +++ b/docs/my-resources.md @@ -0,0 +1,177 @@ +# My Resources + +Teaching material a signed-in user generates for themselves — a deck for +tomorrow's session, a handout, a summary — kept privately and exported as +PowerPoint, Word or PDF. + +Deliberately separate from the Learning Hub. That is moderator-owned content +published into categories for everyone; this needs no role beyond being signed +in, and nothing here is shared. Every statement filters on `user_id`, and there +is no route that returns another person's work. Sharing, if it is ever wanted, +should be a deliberate feature rather than something that leaks out of a +forgotten `WHERE` clause. + +## What a resource is + +| Column | | +|---|---| +| `markdown` | the readable artifact — what Word renders and what a text edit edits | +| `deck` | for a presentation, the slide structure the model designed (see below) | +| `image_ids` | the illustration jobs belonging to this resource, in the order they were made | +| `topic`, `grounded_count` | what it was asked for and how many library excerpts it was written from | + +`MAX_PER_USER` caps how many a person may keep. + +## Sources + +One function, `gatherSources()`, answers "what is this written from" for both +generating and modifying, so the two cannot drift into offering different things +or searching them differently. + +- **The clinical library** — on by default. Semantic retrieval over the indexed + corpus. Budgets are in [retrieval-tuning.md](retrieval-tuning.md). +- **PubMed** — admin-enabled, optional API key. Returns structured records so a + reference carries a PMID somebody can look up. +- **The web** — admin-enabled, provider-configurable (Tavily, Serper, Brave, + SearXNG). +- **Illustrations** — see below. + +Each option hides itself when an administrator has not enabled it, so nothing +appears that a person could tick and then be refused. + +Nothing here may fail a generation. A retrieval or search that comes back empty +is reported as a reason and the resource is written from what is available. + +### Searching is the route's job, not the model's + +Both searches run up front on the topic, and their results go into the prompt as +findings. They are **not** offered as tools. + +They were, once. Tested live against a question explicitly about recent trials, +the model never called them — with or without corpus grounding, and no matter +how the tool description was worded, because the prompt ends "Output ONLY Pandoc +markdown" and a model told to output only markdown does not emit a tool call. +Calling `callAI` with the tool directly produced a correct call, so the plumbing +was never the problem. The search only ever needed the topic, and the route +knows the topic before it calls the model. + +### Keyword searches get the topic, retrieval gets more + +PubMed ANDs every mapped term, so one unrecognised word takes a query to zero: +`"febrile seizures"` returns six results and `"febrile seizures in under-fives"` +returns none. A query that finds nothing is retried against progressively +shorter versions of itself, longest first, and the response says which query +actually worked. Those retries are spaced — three `esearch` calls back to back +trips NCBI's three-a-second limit without an API key. + +When modifying, the library search gets the topic *plus* the instruction — +retrieval is semantic and benefits from context — while PubMed and the web get +the topic alone. + +### Empty searches must not invite invention + +When a search was asked for and came back empty, the prompt says not to invent a +citation, a PMID or a URL to fill the gap. Without that the model supplies them +from memory, and a fabricated PMID is indistinguishable from a real one. + +## Presentations are designed, not written + +A presentation is described as a **deck**: the model returns JSON naming a +layout per slide and the prompt for each figure it wants. Articles stay +markdown, which is what prose wants. + +Markdown could express about five of the things the renderer can draw, so the +model had no way to say "put this figure beside these three bullets" or "make +this a comparison with two labelled columns" — the parser inferred a layout from +the shape of a list, and inferring is what made every deck look the same. + +Layouts: `title`, `section`, `bullets`, `two`, `compare`, `table`, `callout`, +`figure`, `image`. See `src/utils/deckSchema.js` for what each accepts. + +Markdown is still produced, serialised from the deck, so Word export and text +editing keep working and the stored artifact stays readable by a person. The +deck is stored alongside it because that serialisation is lossy by design: +round-tripping through markdown would discard the layout choices. + +Nothing costs more than the thing that went wrong — a reply that is not a deck +falls back to asking for markdown; a malformed slide degrades to bullets; a +comparison with one column is not a comparison; JSON wrapped in fences or a +covering sentence is read rather than refused. + +## Illustrations + +`resourceImages.js`, **not** the shared `imageTool.dispatch` — that one permits +exactly one image per request, which is right for a chat reply and wrong for a +twelve-slide deck, and the clinical assistant and Learning Hub depend on that +rule. Same queue, same storage, same `my_resources` workflow, same asset +endpoint; only the number differs, bounded at `MAX_IMAGES` because each figure +is a paid request. + +"Use 3 diagrams" in the instructions is read as the number it is. Writing +"include a diagram of the airway" switches the illustration option on and says +why, rather than the request being dropped in silence; switching it off by hand +sticks. + +`my_resources` is its own image workflow rather than a reuse of `learning_hub` +because `generated_image_links` only accepts `learning_hub` assets — that is the +barrier keeping a private illustration out of published content. + +### Getting a model to illustrate at all + +Two things had to be right, both measured: + +1. The illustration guidance is the **last** thing in the prompt. Placed before + the output rules it lost: the model returned 3297 characters of markdown and + zero tool calls, while the same tool and wording in a shorter prompt produced + three calls. +2. Even last, it loses to a prompt carrying thirty library excerpts — + deterministically: library off → three calls, library on → none. So when the + author names a number the call is **required** rather than offered. With no + number named the choice stays the model's. + +A model that has just made three tool calls also tends to sign off instead of +writing — `"I'll create the presentation and the three teaching diagrams."` was +once returned as the resource, 61 characters, because only a completely empty +body counted as missing. A body with no title block and no heading is now +treated as missing whatever its length. + +## Export + +| Format | Built by | +|---|---| +| `pptx` | `scripts/render_pptx.py` (python-pptx) from the stored deck | +| `docx` | pandoc, from the markdown | +| `pdf` | Gotenberg (LibreOffice), from whichever office file above | + +Pandoc's pptx writer was the ceiling on how good a deck could be, and the model +on top made no difference to it: a handful of reference layouts, no per-slide +layout, no positioning, no control over how large an image is drawn. It also +leaves a bare `` on every shape, so slides overflowed until autofit +was injected into its emitted OOXML by hand. + +The renderer sizes text to fit before writing the file rather than trusting +autofit — LibreOffice ignores `` when converting to PDF, which +is how slides were being cut off mid-sentence. Wrapped bullet lines hang under +the text. Images are drawn at their own aspect ratio. + +If the renderer fails for any reason, pandoc still produces a deck: a plainer +deck beats a failed download. The log line is +`deck renderer failed, falling back to pandoc`. + +Figures are fetched to a scratch directory for the renderer and removed +afterwards. One that cannot be fetched is left out rather than failing a +download that works without it. + +**Runtime dependency:** the image carries `python3`, `py3-lxml`, `py3-pillow` +(apk — both are C extensions with no Alpine wheels) and `python-pptx` pinned at +1.0.2 from pip. Roughly 58MB. Unpinned, a rebuild from the same commit could +produce different decks. + +## Modify + +`POST /api/my-resources/:id/refine` rewrites a resource in place, keeping its +id, its downloads and its References section. It offers the same four sources as +generating — it had none, so "add what the 2024 trial showed" was answered from +the model's memory rather than by looking anything up. + +The previous version is replaced, not versioned. diff --git a/docs/retrieval-tuning.md b/docs/retrieval-tuning.md index 9799e269..0eed4bbd 100644 --- a/docs/retrieval-tuning.md +++ b/docs/retrieval-tuning.md @@ -60,6 +60,9 @@ clamped on read so a bad value cannot break a search. `search_limit` is how many excerpts to request; `context_chars` is how much text to pull around each one. +See [my-resources.md](my-resources.md) for the rest of that feature — its +sources, the deck renderer and illustrations. + **My Resources shares the Learning budget deliberately.** Both generate a whole teaching resource from a topic, so they want the same shape of context. If they ever need to diverge, `src/utils/learningRetrieval.js` is the single place that diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js index fa0513d4..285bb8ac 100644 --- a/src/utils/documentExport.js +++ b/src/utils/documentExport.js @@ -176,6 +176,12 @@ function attachFigures(deck, files, figureIds) { }); var slides = (deck.slides || []).map(function (slide) { var copy = Object.assign({}, slide); + // The renderer reads whatever path this field holds and embeds that file in + // the download. Nothing today can put a path in a stored deck — normalise() + // never copies one and the edit endpoint writes markdown only — but the + // field is cleared before it is set, so the only paths that can reach the + // renderer are the figures just fetched for this export. + delete copy.image; if (copy.image_job && byJob[copy.image_job]) copy.image = byJob[copy.image_job]; // A figure slide whose picture never arrived is still a slide of text. if (!copy.image && (copy.type === 'image' || copy.type === 'figure')) {