Architecture
Status: v2, the contract the services are built against. A change to a contract below is a pull request here first, and the owner of every affected service is told.
Overview
Everything runs on one cloud account and lives in one repository. Four application services, two workers and one static frontend on a managed Kubernetes cluster, one managed PostgreSQL with pgvector, one object storage bucket, one inference API for the language model and the embeddings. Every container is horizontally scalable; PostgreSQL is the only state.
browser ── web (static SPA) ────────────┐
▼
ingress-nginx (TLS, path routing)
│ │ │
/api/account /api/ingest /api/rag
▼ ▼ ▼
account ingest rag
│ │ │
└────── Vultr Managed PostgreSQL + pgvector ──────┘
│ │
Vultr Object Storage Vultr Serverless Inference
(uploads, S3 API) (OpenAI-compatible API)
| Part | Directory | Owner | Role |
|---|---|---|---|
| core | core/ |
Agent G | Python library: models, settings, auth, rate-limit middleware, provider interfaces, usage accounting. Copied into the images of account, ingest, rag. Never published as a package. |
| account | account/ |
Agent F | magic-link login, allowed domains, vouchers, credit balance, admin panel backend, visitor and access logs, blocks and bans |
| ingest | ingest/ |
Agent C | notebooks and sources: upload endpoints; the ingest worker (lasteverlm-ingest-worker) parses, chunks, embeds, summarizes |
| rag | rag/ |
Agent D | retrieval, cited answers, message history; the rag worker (lasteverlm-rag-worker) generates outputs |
| web | web/ |
Agent E | wizard, two-column notebook view, viewer, login, credits, admin panel |
| infra | infra/ |
Agent B | cluster provisioning, Helm chart, ingress, migrations runner, deploy automation, smoke test |
One repository, isolated contexts: every directory above is its own
Keep the Why project with its own context/, parent: .., and the
root holds what is family-wide.
Rules that hold everywhere:
- Services share the database, never each other's APIs. The schema is the contract between them. There are no service-to-service HTTP calls.
- Every stage is an interface with one implementation. New source kinds, chunkers, embedding or LLM providers, retrievers are added as modules, never by editing callers.
- Configuration from the environment only. A service fails at startup when a required value is missing. Secrets never enter a repository.
- Every model call is accounted. One
usagerow per LLM or embedding call, priced at write time. Zero balance means 402, for admins as well. - Nothing is shipped but containers. Python source goes into the image; no PyPI, no conda, no compiled artifacts. One image per service, tagged with the Git SHA and the release tag, in GitHub's container registry.
- Every process can run any number of times. API pods hold no state; background work is claimed from PostgreSQL by worker pods; uploads live in object storage; sessions are JWTs.
Platform (Vultr)
| Need | Service | Notes |
|---|---|---|
| Cluster | Vultr Kubernetes Engine | one namespace lasteverlm, ingress-nginx, cert-manager with Let's Encrypt |
| Database, vectors | Vultr Managed PostgreSQL | extension is named vector: CREATE EXTENSION IF NOT EXISTS vector; |
| Uploads | Vultr Object Storage | S3-compatible, one bucket, accessed with boto3 |
| LLM, embeddings | Vultr Serverless Inference | OpenAI-compatible, base URL https://api.vultrinference.com/v1, used through the OpenAI SDK so any compatible provider can replace it |
| Speech | Vultr Serverless Inference | text-to-speech for audio outputs and speech-to-text for audio sources (/v1/audio/speech, /v1/audio/transcriptions); the platform runs both speech models in the United States only (TTS in atl, STT in sjc), so the text of an audio output and the bytes of an audio source leave the EU for that call, while documents, chunks and vectors stay in the Amsterdam region |
| external SMTP | configured through the environment (SMTP_*, Kubernetes Secret lasteverlm-smtp), shown read-only in the admin panel; not a Vultr service |
Data model (PostgreSQL)
All ids are uuid. All timestamps are timestamptz default now().
Migrations are numbered SQL files, each service keeps the ones for its
own tables in db/migrations/, and infra applies all of them in one
make migrate. Plain SQL, no ORM-specific migration tooling.
-- account
users (id, email unique, is_admin bool, created_at, last_login_at)
magic_links (id, user_email, token_hash, expires_at, used_at, created_at)
vouchers (id, code_hash unique, amount_cents, created_by, redeemed_by, redeemed_at, created_at)
ledger (id, user_id, amount_cents, reason, voucher_id, created_at) -- credits only, positive
settings (key primary key, value jsonb, updated_at) -- allowed_domains
login_log (id, user_id, at)
visit_log (id, at, path, user_id null, ip_hash, ua_family) -- no raw IP, 30 days
-- ingest
notebooks (id, owner uuid, title, created_at)
library_sources (id, owner uuid, name, kind, dedupe_key text null, url text null, size_bytes bigint null,
storage_path, status, error, summary text null, claimed_at, claimed_by, attempts, created_at)
-- the processed unit, one per user and content; unique (owner, dedupe_key)
sources (id, notebook_id, library_source_id, created_at) -- the link of a library source into a notebook; its id is the public source id
chunks (id, library_source_id, idx, page, text, embedding vector(1536)) -- one set per library source, searched from every notebook that links it
-- rag
messages (id, notebook_id, role, content, citations jsonb, created_at)
outputs (id, notebook_id, kind, title, content jsonb, status, error, share_token text null unique,
source_id uuid null, claimed_at timestamptz null, claimed_by text null, attempts int default 0, created_at)
-- shared (owned by core's migration, applied after infra's)
usage (id, user_id, notebook_id null, service, action, action_id, kind, model,
tokens_in, tokens_out, cost_usd_cents numeric, usd_per_eur numeric,
cost_cents numeric, charge_cents numeric, created_at)
access_log (id, at, ip inet, path, method, status, user_id null, service) -- raw IP, security purpose, 30 days
ip_blocks (ip inet primary key, blocked_until, strikes int, updated_at)
ip_bans (ip inet primary key, reason, created_at, lifted_at null)
sources.kind:pdf|text|url|image|audio|generated(audiois an MP3 or WAV upload transcribed bySTT_MODELinto one passage without pages, the platform returns no timestamps;generatedis a finished output indexed as a source by rag, see Outputs).sources.status:added|processing|ready|failed.addedmeans stored but not read yet: adding a source only keeps it, and the ingest worker claims it once the source is used (POST /sources/process); generated rows never have it. The API also showswaiting_for_credit, never stored: a queued (processing) source whose owner has no credit, which the ingest worker does not claim until the balance is positive again.sources.summary: one summary per source, written by ingest at the end of processing, used by the output generators.outputs.kind:report|quiz|table|mindmap|infographic|audio|decision|chart|flashcards.outputs.status:waiting(sources still processing) |processing|ready|failed.claimed_at,claimed_by,attemptsonsourcesandoutputsmake those tables the job queues of the two workers (see Workers).chunks.embeddingdimension followsEMBEDDING_DIMENSION; the migration takes it as a parameter, default 1536.usage.kind:llm|embedding|tts|stt|decision|fee(an action's fixed fee: cost 0, the fee as charge, one row peraction_id).usage.action: the user action a row bills, one of core'sUSAGE_ACTIONS(chat_answer,output:<kind>,source:<kind>,summary,judge, ...;unknownfor rows from before actions existed);usage.action_idgroups all rows of one action.usage.service:ingest|rag. Forttsandsttthe priced unit is seconds of audio as the platform reports them (generated speech fortts, the uploaded file forstt), rounded up per call:tokens_inholds the seconds,tokens_outis 0, andMODEL_PRICESlistsTTS_MODELandSTT_MODELwithinin cents per million seconds. Fordecisionit is the input tokens the decision model reports (usage.input_tokens; it generates no text):tokens_inholds them,tokens_outis 0, andMODEL_PRICESlistsDECISION_MODELwithinin cents per million tokens.- Balance =
sum(ledger.amount_cents) - sum(usage.charge_cents)per user, exact (sub-cent). Usage rows are the debit; there is no second ledger write.cost_usd_centsis what a call really cost as the platform prices it (US cents),usd_per_eurthe rate it was converted at,cost_centsthat cost in euro cents andcharge_centswhat the user pays in euro cents; all are fixed when the row is booked (core/context/billing.md). Credits, vouchers and balances are euro cents. - Storage object path:
<owner>/<library_id>/<name>for an upload,<notebook_id>/<output_id>/<name>for a file an output produces (audio.mp3). Deleting a library source or an output deletes its objects; detaching a source from a notebook deletes nothing. - Source library, per user: a file or image is deduplicated by
sha256:<hex>of its bytes (a PDF by bytes plus capture mode), a URL byurl:<normalised URL>; the same content again links the existing entry instead of storing or processing it (one set of chunks and embeddings per entry, billed once at first processing). Nothing is shared between users; the same bytes from two users are two entries. Generated outputs are library rows of kindgeneratedowned by the user, listed byGET /library?kind=generatedwith their origin notebook (origin: {"id", "title"},idnull once that notebook is deleted); linking one into another notebook ({"library_id"}) reuses its chunks at no cost. Rag owns their whole lifecycle (trash, restore, purge, see Outputs); ingest's trash and purge skip kindgenerated, and deleting a notebook or detaching a source removes only links. A new user's first notebook list, library listing or upload copies the admin-marked starter notebook (settings keystarter_notebook, set in Admin): ready copies of its uploads with their objects, chunks and embeddings, flaggedstarter, no processing and no charge; rag copies its ready outputs into the new notebook (ingest/context/seeds.md). Without a starter nothing is seeded. Copies are the user's own: nothing is shared between users. Removing a source from a notebook detaches the link; deleting an entry in the library detaches it everywhere and moves it to the trash, where it stays for the user'strash_dayspreference (default 14) before the ingest worker purges it with its chunks and object, hourly and at start; it can be restored or purged at once meanwhile, and uploading the same bytes again restores it (ingest/context/library.md). Deleting a notebook detaches its sources and frees no storage (ingest/context/library.md). - Web page versions (
ingest/context/url-sources.md): a page is fetched on first use and storesfetched_atandcontent_hash(sha256 of the whitespace-normalised extracted text).POST /sources/{id}/refetchcompares before any model call: unchanged moves onlychecked_atand charges nothing; changed createsversion + 1, which takes the dedupe key and every link, while the old row stays read-only withsuperseded_by. Every read version keeps an inert snapshot beside it (_snapshot.html, rebuilt from an allowlist, with its own CSP;snapshot_urlpresigned,final_urlafter redirects); an unchanged refetch backfills a missing one. The web shows it in a sandboxed iframe without scripts or same origin. - Structured URL data (
ingest/context/url-sources.md): JSON, NDJSON, XML (RSS, Atom), CSV, TSV and YAML are read one value per line with its key path or column names (languages.Python: 12345,row 3: city: Graz; price: 12); positions (row <n>:,[<n>],@<name>) are never values, which rag's number check relies on. defusedxml without entities, YAMLsafe_load, caps on depth, values and columns. - Model-made text (images, vision PDF pages, audio): an image or rendered page over 1.5 MP is read in overlapping tiles of at most 1.5 MP (
ingest/docs/vision-eval.md); every transcript passes a deterministic loop guard, a looping one is retried once with other sampling settings and then fails the source;read_by {vision, stt}and per-chunkvisionsay which model made the text.
Credits and accounting
- Prices per model live in config (
MODEL_PRICES, JSON, cents per million tokens, in and out), set by the deployment; core ships no default prices (core/context/pricing.md).MODEL_PRICESare US cents, as the platform lists them. The writer of a usage row prices it:cost_usd_cents = (tokens_in * price_in + tokens_out * price_out) / 1e6,cost_cents = cost_usd_cents / usd_per_eurandcharge_cents = cost_cents * (1 + margin_pct / 100), all exact. - Pricing rules: account's
settingsrowpricing,{"usd_per_eur": <US dollars per euro>, "default_margin_pct": 0, "actions": {"<action>": {"margin_pct"?, "fixed_cents"?}}}, edited in the admin panel, re-read by every booking process at most every 30 seconds. The rate is required and has no default: without it nothing is booked. An action's own margin wins over the default; a fixed fee (euro cents) is booked once peraction_idas its ownfeerow. Margin 0 and no fees: the charge is the cost in euros. - Core provides
record_usage(conn, user_id, notebook_id, service, action, action_id, usage, prices=, rules=),estimate_charge(prices, rules, action, model, tokens_in, tokens_out),balance_cents(conn, user_id),has_credit(conn, user_id)andis_admin(conn, user_id)(async twins with anaprefix), pluslasteverlm_core.web.admin_only, the FastAPI dependency behind every admin endpoint of ingest and rag. Services call these, never writeusageby hand. - Core provides the object-storage client (
lasteverlm_core.storage: put, get, delete, presigned GET with an expiry). Ingest and rag use it; no service carries its own S3 code. - A voucher is a single-use code; redeeming it writes one positive ledger row. Default amount
VOUCHER_DEFAULT_CENTS(1000 in code, 500 in production). The code is looked up by its hash and also stored encrypted with a separate key (VOUCHER_KEY), so an admin can show it again and send it by mail; viewers never see it. - At the start of a user action a service checks the credit, for every user, admins included (
core/context/credit.md). A short single call (a chat answer, a judge on request, a voice sample) needsbalance_cents > 0. An output's creation, a confirmed agent plan and a queued source read need the balance to cover their estimated maximum plus the work already funded and still waiting (held: ingest'sfunded_centsof queued reads, rag'soutputs.reserved_cents, running plan steps; ingest and rag compute the same sum); otherwise 402credit_insufficientwithneeded_cents,held_centsandavailable_cents. An action that has started runs to the end, so a balance can end below zero; the next action then waits for a top-up. The maintainer tests with vouchers like everyone else.
Authentication
- Bearer token on every request. Two schemes, resolved by one function in core,
current_user(authorization) -> User: - JWT, HS256 with
AUTH_JWT_SECRET,sub= user id, 7 days, issued by account after a magic link. DEMO_TOKENfrom config, resolves to the demo user00000000-0000-4000-8000-000000000001, seeded by account's migration with emaildemo@localhostand an initial ledger credit. Exists for development and recorded demos; unset in production.- Every service answers
GET /healthzwith{"status", "version", "commit"}fromAPP_VERSIONandAPP_COMMIT(the image tag and the full SHA, set by the chart; "unknown" when unset), plus"dependencies",{"postgres"?, "storage"?, "inference"?: "ok" | "down"}, for the dependencies the service uses once it runs core'sDependencyProbe(checked at most every 15 s, 2 s each).statusstays"ok"while the process answers; a dependency outage never fails the liveness probe. - Magic link:
POST /api/account/auth/request-linkanswers 202 for an address that may sign in and 403{"error", "code": "not_allowed"}for any other, no mail in that case (account/context/auth.md); the mail carries<PUBLIC_BASE_URL>/auth/callback?token=...and the token itself for the paste form; the frontend exchanges the token atGET /api/account/auth/callback?token=...for{jwt, user}. Tokens are stored hashed, single use, 15 minutes. - Who may sign in: addresses in
ADMIN_EMAILS, addresses on the allowlist (settingskeyallowed_addresses) and addresses whose domain is on it (settingskeyallowed_domains, seeded once fromAUTH_ALLOWED_DOMAINS). Both lists are edited entry by entry in the admin panel. Not in any repository. - Roles:
user,viewer,admin(users.role). The first user ever created and addresses inADMIN_EMAILSare admins; a user whose domain is in the allowlist'sviewer_domainssigns in as viewer; an admin changes roles in the panel. A viewer reads every admin endpoint of account, ingest and rag (core'sadmin_readdependency); every write stays admin only (admin_only).is_adminis kept in sync with the role for compatibility. - The JWT lives 24 hours (
JWT_TTL_DAYS, default 1); sign-out is client-side; the secret must be at least 32 bytes.
API contract v1
JSON in and out unless stated. Errors: {"error": "<message>"} with 4xx/5xx, plus a stable "code" where a client must tell refusals apart (today not_allowed). 401 unauthenticated, 402 credit exhausted, 403 not admin, 404 not yours or missing.
account, prefix /api/account
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /auth/request-link |
{"email"} |
202, or 403 code: not_allowed, or 429 code: too_many_links (five links per address per hour) |
| GET | /auth/callback?token= |
{"jwt", "user": User} |
|
| GET | /me |
User |
|
| GET, PUT | /me/preferences |
PUT: any subset, null clears |
Preferences |
| POST | /vouchers/redeem |
{"code"} |
User |
| POST | /visits |
{"path"} |
204 |
| GET | /admin/users |
[AdminUser] (viewer or admin) |
|
| PATCH | /admin/users/{id} |
{"role": "user"\|"viewer"\|"admin"} |
AdminUser; 409 for the last admin or the demo user |
| GET | /admin/logins?limit= |
[{"user_id","email","at"}] |
|
| GET | /admin/visits?days= |
[{"day","path","visits","unique"}] |
|
| GET | /admin/settings/smtp |
Smtp (read-only, from the environment, never the password) |
|
| POST | /admin/settings/smtp/test |
202 | |
| GET | /admin/system |
SystemInfo: version and commit of the build, every service's health and build, pods and deployments from the cluster API (cluster: false and empty lists outside a cluster or without the read role) |
|
| GET | /admin/settings/allowlist |
Allowlist |
|
| POST | /admin/settings/domains |
{"domain"} |
201 Allowlist; 409 listed, 422 bad value |
| DELETE | /admin/settings/domains/{domain} |
204 | |
| POST | /admin/settings/addresses |
{"address"} |
201 Allowlist; 409 listed, 422 bad value |
| DELETE | /admin/settings/addresses/{address} |
204 | |
| POST | /admin/vouchers |
{"amount_cents", "count"} |
{"codes": []} once |
| GET | /admin/vouchers?limit= |
[AdminVoucher], newest first |
|
| DELETE | /admin/vouchers/{id} |
204 open; 409 redeemed | |
| GET | /admin/login-attempts?limit= |
[{"at","email","ip","outcome": "allowed|refused"}], newest first, kept RETENTION_DAYS |
|
| GET, PUT | /admin/settings/ratelimit |
PUT: {"per_minute","block_minutes","ban_after_strikes","strike_window_hours"} |
RateLimitRules |
| DELETE | /admin/settings/ratelimit |
204, back to the environment defaults | |
| GET | /admin/stats |
AccountStats |
|
| DELETE | /admin/users/{id} |
admin only | 204; deletes a user and everything they own, orchestrated: rag's purge, then ingest's purge (both on the admin's bearer, RAG_URL/INGEST_URL), then account's rows (the user, usage, the login log); vouchers and login_attempts stay as history. 409 for the caller's own account and the demo user; 502 {"error", "code": "purge_failed", "step": "rag"|"ingest"|"account", "message"} when a step fails, with nothing of account's touched, so the user is still listed and the call can be repeated |
| GET | /healthz |
{"status": "ok"} |
User: {"id", "email", "role": "user"|"viewer"|"admin", "is_admin", "balance_cents", "created_at", "preferences": Preferences}
Preferences: {"ui_language": "de"|"en"|null, "output_language": <code>|null, "audio_voice": <voice id>|null, "trash_days": 1..365|null} -- null: not set; the web resolves defaults
AdminUser: {"id", "email", "is_admin", "created_at", "last_login_at", "balance_cents", "usage_cents"}
Smtp: {"host", "port", "username", "from_address", "starttls"} -- empty host: mail not configured
Allowlist: {"allowed_domains": [], "allowed_addresses": [], "viewer_domains": []} -- viewer_domains: users from these domains sign in as viewers
AdminVoucher: {"id", "amount_cents", "created_by", "created_at", "redeemed_by", "redeemed_at", "status": "open|redeemed"}
RateLimitRules: {"per_minute", "block_minutes", "ban_after_strikes", "strike_window_hours", "ban_max_days", "retention_days"} -- the last two read-only
SystemInfo: {"version", "commit", "cluster", "services": [{"name", "version", "commit", "healthy", "dependencies": {"postgres"|"storage"|"inference": "ok"|"down"} (what the service's healthz reports, null until it does)}], "pods": [{"name", "service", "image_tag", "ready", "restarts", "node", "started_at", "phase", "reason" (the container's waiting or terminated reason, or a termination within the last ten minutes, null when running), "template_hash"}], "deployments": [{"name", "replicas", "ready", "updated", "progressing" (true only while a rollout is under way), "available", "replica_sets" (counted from the pods' template hashes)}]}. The admin's architecture diagram colours its nodes from this: green when every replica is ready without restarts, amber for a rollout or partial readiness, red only for failure (a failing pod reason, climbing restarts, none ready outside a rollout, healthz down), grey when unknown; the external boxes take `dependencies`.
AccountStats: {"users", "logins_24h", "vouchers": {"issued", "redeemed", "open"}, "credits_granted_cents",
"usage_cost_cents": {"total", "last_24h"}, "blocks_active", "bans_active"}
ingest, prefix /api/ingest
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /notebooks |
[Notebook] |
|
| POST | /notebooks |
{"title"} |
Notebook |
| DELETE | /notebooks/{id} |
204 | |
| GET | /notebooks/{id}/sources |
[Source] |
|
| POST | /notebooks/{id}/sources |
multipart file (+ field capture: auto|text|vision for PDFs), or {"url"}, or {"library_id"} |
202 Source (new link, status: "processing" when newly processed); 200 Source when already in this notebook |
| DELETE | /sources/{id} |
204, detaches the source from the notebook only | |
| GET | /library |
[LibraryEntry], newest first, the user's own, never generated |
|
| GET | /library/{id} |
LibraryEntry |
|
| DELETE | /library/{id} |
204, detached from every notebook and moved to the trash (chunks stay) | |
| GET | /library/trash |
[TrashEntry]: the entry plus deleted_at and purge_at |
|
| POST | /library/{id}/restore |
204, back in the library, not re-linked | |
| DELETE | /library/{id}/purge |
204 at once, from the trash only (404 otherwise): chunks and object go | |
| GET | /admin/stats |
viewer or admin | IngestStats |
| DELETE | /admin/users/{id} |
admin only | 204; account's second step when a user is deleted: library objects (originals, converted audio), library rows, trash, notebooks and their sources (rag's rows cascade from the notebooks); idempotent; objects before rows, a storage error is a 502 naming the key |
| GET | /healthz |
{"status": "ok"} |
Notebook: {"id", "title", "created_at"}
Source: {"id", "notebook_id", "library_id", "name", "kind", "capture" (PDFs), "demo", "preview_url" (image, PDF, text, audio: presigned GET, 1 h; url: the URL; for audio the file that was transcribed, the converted MP3 when the upload was converted), "original_url" (audio only: presigned GET of the uploaded original when it was converted, else null), "status", "error", "created_at"}
LibraryEntry: {"id", "name", "kind", "capture" (PDFs), "demo", "preview_url" (image, PDF, text, audio: presigned GET, 1 h; url: the URL; for audio the file that was transcribed, the converted MP3 when the upload was converted), "original_url" (audio only: presigned GET of the uploaded original when it was converted, else null), "status", "error", "size_bytes", "url", "created_at", "notebooks": [{"id", "title", "source_id"}]}
IngestStats: {"sources": {"processing", "ready", "failed"}, "jobs": {"pending", "running", "failed"}, "chunks", "source_bytes"}
Status codes: POST /notebooks 201, POST /notebooks/{id}/sources 202, DELETE 204, {"url"} 501 until the url loader exists.
Processing runs after the upload response: store the file, parse,
chunk (about 800 tokens, 100 overlap), embed, insert chunks, set
ready or failed with error. The pipeline is the worker
entrypoint of the ingest package; in v1 it runs in-process as a
background task, later as its own pod with no code change. No queue in
v1; the frontend polls GET /sources.
rag, prefix /api/rag
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /notebooks/{id}/conversations |
[Conversation] |
|
| POST | /notebooks/{id}/conversations |
{"title"}? (default "Chat N") |
Conversation |
| DELETE | /conversations/{id} |
204, messages included (closing a chat tab) | |
| GET | /conversations/{id}/messages |
[Message] |
|
| GET | /chunks/{id} |
ChunkView (the cited passage verbatim; 404 unless the caller owns it) |
|
| POST | /notebooks/{id}/ask |
{"question", "conversation_id", "scope": {"output_id"}?} |
Answer; 422 without a conversation |
| PATCH | /conversations/{id} |
{"title"} (1–80 chars) |
Conversation |
| POST | /outputs/voice-sample |
{"voice", "speed"?, "language"?} |
{"url", "cached"}: a short MP3 preview, synthesized once per voice/speed/language and then served from storage (one tts usage row for the first caller, 402 without credit, 503 without TTS) |
| GET | /outputs/options |
{"languages": [{"code","label"}], "audio": {"voices": [{"id","label","gender"}], "speeds": [], "default_voice"}} |
|
| POST | /notebooks/{id}/outputs |
{"kind", "options"?} — options per kind: {"language"} for text kinds, {"language", "voice", "speed"} for audio; 422 outside the catalogue |
202 Output with status waiting or processing |
| GET | /notebooks/{id}/outputs |
[Output] |
|
| GET | /library/outputs?kind=&trash= |
[Output], the user's outputs across notebooks (trash=true: the trashed ones) |
|
| GET | /outputs/{id} |
Output |
|
| DELETE | /outputs/{id} |
204, moves the output to the trash (deleted_at), its generated library row with it |
|
| POST | /outputs/{id}/restore |
Output |
|
| DELETE | /outputs/{id}/purge |
204; 409 unless trashed | |
| DELETE | /notebooks/{id}/outputs |
204, trashes every output of the notebook; the web calls it before ingest's notebook delete when the user chooses to delete the documents too | |
| POST | /outputs/{id}/share |
{"share_token"} |
|
| DELETE | /outputs/{id}/share |
204 | |
| GET | /shared/{token} |
no auth | Output without ids, read-only |
| GET | /admin/stats |
viewer or admin | RagStats |
| DELETE | /admin/users/{id} |
admin only | 204; account's first step when a user is deleted: the stored files of the outputs in the user's notebooks, then rag's rows there, in one transaction; idempotent; a file that cannot be deleted is a 502 naming its key and no row is touched |
| GET | /healthz |
{"status": "ok"} |
Answer: {"message_id", "answer", "citations": [Citation], "units", "check", "suggestions": [str], "plan" (null unless the question asked for actions; shape owned by rag), "web_pages": [{"library_id", "name", "fetched_at"}] (the web sources the answer used and when each was read; shape owned by rag)}
Citation: {"n", "source_id", "source_name", "source_kind", "chunk_id", "page", "quote" (verbatim chunk text), "similarity", "chunk_index", "chunk_count"}
Conversation: {"id", "notebook_id", "title", "created_at"}
Message: {"id", "conversation_id", "role", "content", "citations", "units", "check", "suggestions": [str], "feedback": MessageFeedback | null, "plan" (as on Answer), "web_pages" (as on Answer), "created_at"}
MessageFeedback: {"rating": "up"|"down", "comment": str | null, "at"}
Output: {"id", "notebook_id" (null once the origin notebook is deleted), "origin_title", "kind", "title", "content", "status", "error", "share_token", "source_id" (the generated library row), "options", "deleted_at", "purge_at", "provenance", "phase" (while waiting or processing, else null; shape owned by rag), "attempt_log": [AttemptRecord], "created_at"}
AttemptRecord: {"at", "attempt", "model", "error_kind": "provider_refused"|"provider"|"configuration"|"timeout"|"internal", "provider_status", "message" (core's own text, never a prompt or source text), "service"}
ChunkView: {"chunk_id", "source_id", "source_name", "source_kind", "page", "text", "chunk_index", "chunk_count"}
RagStats: {"notebooks": {"total"}, "messages": {"total", "last_24h"}, "outputs": {"by_kind": {<kind>: n}, "by_status": {<status>: n}},
"jobs": {"pending", "running", "failed"}, "shared": {"outputs"}}
scope.output_id restricts retrieval to the chunks of one generated
document ("ask about this"); an output that is not ready answers 409
{"error": "output is not ready"} so the frontend can tell "wait" from
"gone". source_kind lets the frontend render a
citation into a PDF differently from one into a generated report.
Citations are built by rag, not by the provider: retrieved chunks go
numbered into the prompt, the model answers with [n] markers, rag maps
every marker back to its chunk and drops markers that point at nothing.
Retrieval: top-k (8) by cosine similarity over chunks, filtered by
notebook_id and sources with status ready; a similarity floor from
config. If nothing passes the floor, the answer says that the sources do
not cover the question and citations is empty. The service never
answers from model knowledge alone.
Workers: PostgreSQL as the job queue
Background work never runs inside an API process. ingest and rag each
ship a second entrypoint, worker, deployed from the same image as a
separate Deployment with its own replica count. A worker loop claims
one job at a time:
update sources set claimed_at = now(), claimed_by = $worker, attempts = attempts + 1
where id = (select id from sources
where status = 'processing' and (claimed_at is null or claimed_at < now() - interval '10 minutes')
order by created_at for update skip locked limit 1)
returning *;
The same shape on outputs with status waiting/processing. A claim
older than ten minutes is considered abandoned and may be re-claimed;
after three attempts the row becomes failed with the last error. No
broker, no extra component: PostgreSQL's row locks make any number of
workers safe, and a dead pod costs at most one retry delay. The API
only inserts rows and reads status; the frontend polls.
The life of an output job (rag/context/outputs.md, infra/context/cluster.md)
A job heals itself when a worker dies or is replaced, quickly and without paying twice.
- Heartbeat. While a job runs, a background thread refreshes its
claim every minute, and every model call and phase change refreshes
it too, so a live job is never taken over. The thread stops after
JOB_MAX_SECONDS(2700 s) and logs it loudly; a job that hangs then goes stale and is taken over. - Fence. Only the worker that holds the claim may store the result
(
finish_output … where claimed_by = worker). A worker that lost its claim discards what it made, including its generated library row. - Graceful rollout. A rag worker gets a termination grace of 2760 s
(
WORKER_GRACE_SECONDS, one chart value for both), and the rollout starts new pods first (maxSurge 1,maxUnavailable 0). On SIGTERM a worker claims nothing new and finishes its job; only when the remaining grace drops below one worst-case model call (540 s) does it hand the job over: claim cleared, phase "handing over", the attempt not counted. Another worker picks it up within seconds. - Resume from a journal. Every billed model call of a job (the
draft, each judge verdict and revision, the decision model, the focus
embeddings) is stored in
outputs.checkpoint, keyed by a hash of its request and an occurrence number. A worker that takes over runs the job from the top and answers known calls from the journal: no provider call, no second usage row. A changed notebook changes the request, so that call is made fresh. Speech segments and the final embedding are not journaled. The journal is capped and cleared when the output is ready or failed. - Orphan sweep. A generated library row that no output references
and that is older than an hour is removed by a sweep; it ships
switched off (
ORPHAN_SWEEP), with a read-only count command (lasteverlm-rag-orphans), because switching it on writes to production. - Failed attempts are kept on the output (
attempt_log), so the history survives every deploy.
Outputs (generated documents)
An output is a document generated from a notebook's sources: a report,
a quiz, flashcards, a data table, a chart, a mind map, an infographic,
an audio version or a decision. The rag service owns them. Every output
has an ID (the UUID; short_id shows its first 8 hex characters) and an
attempt_log of failed attempts (core's AttemptRecord: time, model,
error kind, provider status, never a prompt), so support can trace it
after any deploy; admins find it by ID prefix. Its provenance carries
coverage: which selected sources its units cite and why the others
were not used. Charts, data tables and infographics first propose
candidates from the selected sources (/charts/propose,
/tables/propose, /infographics/propose); creation re-checks that
every candidate's passages lie in the selection (409
candidate_out_of_scope). Every unit is checked by the word check
(lexical-overlap-v2, with a spaCy negation check that marks
possible_contradiction) and, by the verification level (basic,
standard, thorough, bulletproof; rag/context/judge.md), by one or two
judges. One interface, OutputGenerator, one module
per kind, each with a prompt, an output schema and a renderer in web.
| Kind | content |
Rendered as |
|---|---|---|
report |
{"markdown", "units": [{"text", "refs", "support"}]} |
Markdown document with [n] markers; one unit per non-heading paragraph |
quiz |
{"questions": [{"question", "options", "answer", "explanation", "refs", "support"}]} |
interactive quiz with reveal |
table |
{"columns": [], "rows": [[]], "row_refs": [{"refs", "support"}]} |
sortable table, CSV export; row_refs[i] belongs to rows[i] |
mindmap |
{"root": {"label", "children": [], "refs", "support"}} |
markmap in the browser; every node carries its refs, the root is no claim unless it cites |
infographic |
{"headline", "facts": [{"label", "value", "refs", "support"}], "sections": [{"title", "text", "refs", "support"}]} |
card grid |
audio |
{"url", "transcript": [{"speaker": "narrator"|"interviewer"|"expert", "text", "refs", "support"}]} |
audio player with the transcript under it. Options: audio_style = overview (default) |
Provenance: every unit of every kind (a report paragraph, a quiz
question, a table row, a mind-map node, an infographic fact or section,
a transcript paragraph or turn) carries refs, the numbers of the
passages it cites, and support: supported when a deterministic
word-overlap check (lexical-overlap-v1, threshold 0.5, no model call)
finds at least half of the unit's content words in its best cited
passage, unsupported when it does not, unverified when it does not
and the output was written in another language than the material
(word overlap says nothing across languages); absent means the unit is
no claim. Failing units are kept and marked, never dropped, and the
output is never blocked. Output.provenance is the generation log:
{version, prompt_version, model, options, focus_query, retrieval: {k,
floor, candidates: [{chunk_id, source_id, source_name, page,
similarity, in_prompt}]}, passages: [{n, chunk_id, source_id,
source_name, source_kind, page, chunk_index, chunk_count, similarity,
quote}], usage: {llm, embedding, tts}, started_at, finished_at, check:
{method, threshold, supported, unsupported, unverified}}; the shared
view carries it without chunk_id, source_id and the source
selection, so a shared document is checkable without login
(rag/context/citations.md). Chat answers go through the same check
(Message.units, Message.check).
How a generator gets its input: every source carries a summary
written by ingest; the generator takes all summaries of the notebook
plus the top-k chunks for its own focus query. That keeps large
notebooks inside the context window.
Lifecycle: POST /outputs creates the row with status waiting when
any source of the notebook is still processing, otherwise processing;
the rag worker claims it (a waiting row is claimable once every
source of its notebook is ready or failed), generates, writes
ready or failed. Every generation writes a usage row. When an output is
ready, rag chunks and embeds its text as a source of kind generated
in the same notebook, so the chat can answer questions about generated
documents and cite them. Deleting an output deletes that source.
Ownership: an output belongs to the user (outputs.owner); the notebook it was made in is its origin (notebook_id, set to null when that notebook is deleted, origin_title kept for display). Deleting an output moves it to the trash for the user's trash_days; the rag worker purges trashed outputs at start and hourly: the stored file, then the generated library row, then the output row (rag/context/outputs.md).
Sharing: POST /outputs/{id}/share sets a random share_token; the
page /s/<token> in web renders GET /api/rag/shared/<token> without
login, read-only, and records a visit. DELETE .../share revokes it.
Printing is a print stylesheet on the viewer; the browser produces the
PDF. Audio, mind map and infographic are not printed as images; the
report and the table are the printable kinds.
Rate limiting, blocking, banning, access log
Two layers, both per client IP (X-Forwarded-For as set by the
ingress, never trusted from anywhere else):
- Ingress, coarse: ingress-nginx rate-limit annotations (requests per second with a burst) answer 503 before anything reaches a pod. Access log as JSON on the ingress.
- Core middleware, fine, loaded by every API service (the client address comes from the PROXY protocol between the load balancer and ingress-nginx, which rewrites
X-Forwarded-For; pods accept nothing but the ingress through NetworkPolicies, plus two internal paths: account to ingest and rag for health probes and user deletion, rag to ingest to read sources on use; IPv6 clients are keyed by their /64; a database failure in the limiter fails closed with 500): a sliding window per IP in PostgreSQL (ip_blocks). Over the limit means 429 andblocked_until = now() + 15 minutes,strikes + 1. The third strike within 24 hours writes anip_bansrow; a banned IP gets 403 on everything until an admin lifts it, at most 90 days. Every request writes oneaccess_logrow (sampled to 1 in N for/healthz).
The rule's five values (requests per minute, block minutes, strikes
to a ban, strike window, ban cap) have their defaults in configuration
(RATE_LIMIT_PER_MINUTE 120, BLOCK_MINUTES 15, BAN_AFTER_STRIKES 3,
STRIKE_WINDOW_HOURS, BAN_MAX_DAYS 90) and can be overridden at run time: the admin
panel writes them to the settings table (key ratelimit), and core's
middleware in every service re-reads that row at most every 30 seconds
(core/context/ratelimit.md). Blocks, bans, the access log and the
sign-in attempts (e-mail, IP, allowed or refused) are visible in the
admin panel; bans can be lifted there.
Raw IPs exist only in these tables and in login_attempts, for the security purpose.
The access log, expired blocks and lifted bans are purged after
RETENTION_DAYS (30); an active ban survives that purge and falls after
the ban cap (BAN_MAX_DAYS, 90, or the stored rule) at the latest. The account service runs the purge at
every start with core's statements. The visitor log keeps hashes.
Model prices
MODEL_PRICES is JSON: {"<model>": {"in": <cents per million tokens>, "out": <cents per million tokens>}};
cost_usd_cents = (tokens_in * in + tokens_out * out) / 1e6, exact, in US
cents. Core ships no default prices: the deployment's MODEL_PRICES (the
chart values, compose.yaml locally) is the one price table, and a
service refuses to start when its configured model has no price (fail
loud).
Configuration
| Variable | Used by |
|---|---|
DATABASE_URL |
account, ingest, rag |
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY |
ingest, rag (audio files) |
INFERENCE_BASE_URL, INFERENCE_API_KEY |
ingest, rag |
LLM_MODEL |
ingest (source summaries), rag |
STT_MODEL (default whisper-large-v3-turbo; MP3 and WAV only, 25 MiB, 4 h) |
ingest (audio sources) |
DECISION_MODEL (default clef; clef-flash costs half per token) |
rag (Decision outputs) |
JUDGE_MODELS (JSON level → model, levels low, medium, high; defaults in core; every model needs a price) |
rag (answer judge) |
SIMILARITY_FLOOR, TTS_MODEL, TTS_VOICE (default ryan; the platform's voices are not the OpenAI names) |
rag |
EMBEDDING_MODEL, EMBEDDING_DIMENSION |
ingest, rag (identical on both) |
MODEL_PRICES |
ingest, rag (JSON; set by the deployment, no defaults in core) |
AUTH_JWT_SECRET |
account (issues), ingest, rag (verify) |
DEMO_TOKEN |
all services; unset in production |
INGEST_URL, RAG_URL (defaults http://ingest, http://rag) |
account (System page probes, user deletion); INGEST_URL also rag (reading sources on use) |
JWT_TTL_DAYS (1), LLM_TIMEOUT_SECONDS (60; the rag worker uses 180) |
account, rag |
PUBLIC_BASE_URL, AUTH_ALLOWED_DOMAINS (seed only), ADMIN_EMAILS, SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_ADDRESS, SMTP_STARTTLS |
account |
VISIT_HASH_SECRET |
account (shared by its replicas: daily visitor-hash salt = HMAC(secret, date); without it each process salts on its own) |
API_BASE_URL |
web (build time) |
Runtime contract
- Images
ghcr.io/oliver-zehentleitner/lasteverlm-<service>:<git-sha>and:<tag>for account, ingest, rag, web, built from<service>/Dockerfilewith the repository root as build context so thatcore/can be copied in. - API services listen on 8000 and answer
GET /healthzwith{"status": "ok"}at the root (what Kubernetes probes) and under their prefix (what the ingress exposes); web serves on 8080,GET /is 200. Services receive the full request path including their prefix. - Containers run as non-root (
runAsNonRoot: true); every Dockerfile sets aUSER. - Configuration: ConfigMap
lasteverlm-config(PUBLIC_BASE_URL, S3_ENDPOINT, S3_BUCKET, INFERENCE_BASE_URL, LLM_MODEL, EMBEDDING_MODEL, EMBEDDING_DIMENSION, SIMILARITY_FLOOR, MODEL_PRICES). Secrets per concern:lasteverlm-db(DATABASE_URL, app role,sslmode=require),lasteverlm-s3(ingest),lasteverlm-inference(ingest, rag),lasteverlm-auth(AUTH_JWT_SECRET, DEMO_TOKEN),lasteverlm-account(AUTH_ALLOWED_DOMAINS, ADMIN_EMAILS), optionallasteverlm-smtp(the mail configuration, read at start). Account gets neither S3 nor inference. - Migrations: each service keeps
<service>/db/migrations/NNNN_<topic>.sql;make migrateapplies infra, core, account, ingest, rag in that order, one transaction per file, version key<service>/NNNN_<topic>inschema_migrations, never edit an applied file. The psql variable:EMBEDDING_DIMENSIONis passed on every run.CREATE EXTENSION vectoris infra's own first migration; services never create extensions. A migration runs as the application role (SET ROLE lasteverlm, the admin connection is a member of it); it must not rely on superuser rights or on owning objects the application role does not own.
Deployment
One Helm chart at infra/chart, chart version equal to the repository
tag, one release lasteverlm in namespace lasteverlm on a Vultr
Kubernetes cluster. The chart holds a Deployment, a Service and a
HorizontalPodAutoscaler per API service (minimum two replicas), a
Deployment per worker (ingest, rag), the web Deployment, the Ingress
with TLS from cert-manager and the rate-limit annotations, the
ConfigMap, and a migration Job that runs before the rollout. Secrets
are created from the environment by make secrets and never
committed. The cluster runs in Frankfurt; Object Storage has no
Frankfurt location and lives in Amsterdam.
Automation in .github/workflows/:
ci.yml: per directory, path-filtered,make check(andbuildfor web).docker.yml: on push tomainand on tagsv*, build the changed images and push them to ghcr.io tagged with the SHA (and the tag).deploy.yml: on tagsv*, after the images exist,helm upgrade --installwith that tag; also runnable by hand with a tag as input. A merge tomainnever deploys.ktw-lint.yml,pages.yml(docs site and the Keep the Why dashboard of the whole family).
Versioning: SemVer tags v* on the repository. One tag releases
everything at once; services that did not change get the same image
tag from an unchanged SHA layer.
Extension points, by design
| Later feature | Where it goes | What stays untouched |
|---|---|---|
| URL and image sources | one more SourceLoader in ingest |
API, schema, rag, web beyond a kind label |
| another LLM or embedding provider | one more provider module in core, selected by config | every service |
| hybrid retrieval (full text + vector) | one more Retriever in rag |
ingest, web |
| another output kind | one more OutputGenerator in rag plus its renderer in web |
ingest, account, the API |
| more throughput | replicas on the HPA or the worker Deployments | code |
| password or OAuth login | one more auth scheme in account and core's current_user |
ingest, rag, web beyond the login page |
| web search as a way to add sources (not built, see below) | a search provider in core, one ingest endpoint returning hits, one wizard step; the user picks hits, each becomes a url source through the existing loader |
the URL loader, dedupe, billing, rag |
Web search was considered and left out on 2026-10-14: the platform has no search API, so it would add a second vendor with its own key and send queries to another third party, and the quality of what a search returns (paywalls, cookie walls, pages that are only JavaScript) is outside this system's control. The row above is how it would plug in.