Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -0,0 +1,415 @@
|
||||
# API protocol
|
||||
|
||||
Status: rough contract — Claude refines this doc as endpoints are
|
||||
implemented; it must always match the actual FastAPI routes.
|
||||
|
||||
## General
|
||||
|
||||
- All API routes live under `/api/*`, served by FastAPI. The reverse proxy
|
||||
(prod) / Vite dev proxy (dev) makes frontend and API same-origin.
|
||||
- Types flow one way: FastAPI OpenAPI → `make types` → `openapi-typescript`
|
||||
→ typed `openapi-fetch` client. Never hand-write response types.
|
||||
- Errors: consistent JSON problem shape `{ "detail": string, "code": string }`.
|
||||
`detail` is a developer-facing English sentence; the frontend renders the
|
||||
message from `code` (`lib/api/errors.ts`), never from `detail`.
|
||||
- **LLM endpoint failures share one vocabulary.** `LLMError.code`
|
||||
(`llm/client.py`) classifies every endpoint failure once, and every surface
|
||||
that talks to a model reports it unchanged, over REST (503) or as an SSE
|
||||
`error` frame:
|
||||
|
||||
| code | when | what the user is told |
|
||||
|---|---|---|
|
||||
| `llm_unreachable` | connection refused / DNS / reset | the endpoint is not running |
|
||||
| `llm_busy` | 408, 429, 503, 504, a timeout, or Pablan's own endpoint gate refusing to queue further (`llm/gate.py`) | it is loaded, retry in a moment |
|
||||
| `llm_misconfigured` | 401, 403, 404 | wrong key, wrong model, wrong URL |
|
||||
| `llm_failed` | anything else | it did not answer |
|
||||
|
||||
A busy endpoint queues rather than refuses, so a turn can simply stay
|
||||
silent: the chat says so after 12s (`slow` in `chat/state.svelte.ts`)
|
||||
instead of leaving the user guessing. Waiting for a free slot is bounded
|
||||
by Pablan itself (`llm/gate.py`), so a turn that cannot be served soon
|
||||
ends in `llm_busy` in seconds rather than at the HTTP timeout.
|
||||
- `GET /api/health` — unauthenticated liveness probe, returns
|
||||
`{"status": "ok"}`. Used by reverse proxies / monitoring.
|
||||
|
||||
## Auth flow
|
||||
|
||||
Sequence incl. the 401 → redirect branch:
|
||||
[`diagrams/auth-sequence.svg`](diagrams/auth-sequence.svg)
|
||||
|
||||
- `POST /api/auth/login` `{email, password}` → verifies argon2 hash, creates
|
||||
`auth_sessions` row, sets httpOnly + Secure + SameSite=Lax cookie
|
||||
`pablan_session`. Returns the user object.
|
||||
- `POST /api/auth/logout` → deletes the session row, clears the cookie.
|
||||
- `GET /api/auth/me` → current user or 401.
|
||||
- SvelteKit `hooks.server.ts` calls `/api/auth/me` server-side (forwarding
|
||||
the cookie header) and fills `locals.user`; the `(app)` layout group
|
||||
redirects to `/login` without it.
|
||||
- `POST /api/account/password` `{current_password, new_password}` → 204.
|
||||
Self-service: the current password must verify (403
|
||||
`invalid_current_password`), the new one is at least 8 characters. Every
|
||||
OTHER session of that user is revoked; the session making the change
|
||||
survives, so the user is not thrown out of the app they are standing in.
|
||||
- `PUT /api/account/locale` `{locale: "de" | "en" | null}` → 204. Pins the
|
||||
interface language on `users.locale`, so it follows the person across
|
||||
devices; `null` goes back to following the browser. The backend only
|
||||
stores the choice — it never renders UI-language strings (see
|
||||
`architecture.md`). Anything else → 422.
|
||||
- `GET /api/account/document` → `{document_id, title, status, template_id}`,
|
||||
the caller's own document about themselves: the one they authored from the
|
||||
person blueprint (`person`), or nulls plus the `template_id` to
|
||||
start it from. Self-scoped, and AUTHORSHIP is the whole rule — a document
|
||||
someone else wrote about you is not this one. The blueprint id lives here, so
|
||||
the frontend needs to know no ids; `template_id` is null too when an admin
|
||||
removed the blueprint, and the ordinary template picker takes over.
|
||||
- Later: OIDC (Entra ID) via authlib ends in the same `auth_sessions`
|
||||
mechanism — no parallel auth system.
|
||||
|
||||
## People
|
||||
|
||||
- `GET /api/people` → `[{id, name, role, department}]`, the member-visible
|
||||
colleague directory, ordered by name. Any authenticated user; **no email or
|
||||
password hash** ever leaves it — the same permission-safe, non-admin shape as
|
||||
`ReviewerCandidate`, deliberately separate from the admin-only
|
||||
`/api/admin/users`.
|
||||
- `GET /api/people/{id}` → one colleague's `{id, name, role, department}`;
|
||||
unknown id → 404.
|
||||
|
||||
## Conversations & streaming
|
||||
|
||||
Conversations are chat threads. The only core mode is `query` (RAG Q&A); EE
|
||||
registers `insight`. **Capture is no longer a conversation** — it writes a
|
||||
Document directly (see the Documents section and `authoring-templates.md`), so
|
||||
there are no interview/draft/checklist endpoints. All conversation endpoints
|
||||
are owner-scoped; another user's conversation returns 404. The `mode` must be
|
||||
registered (`modes/registry.py`) or creation returns 400 `unknown_mode`.
|
||||
|
||||
- `POST /api/conversations` `{mode}` → creates a conversation.
|
||||
- `GET /api/conversations` / `GET /api/conversations/{id}` → list / detail
|
||||
incl. messages; list carries a `title` derived from the first user message.
|
||||
- `DELETE /api/conversations/{id}` → user deletes own conversation (GDPR);
|
||||
messages cascade.
|
||||
- `POST /api/conversations/{id}/messages` `{content}` → **SSE stream**
|
||||
response (`text/event-stream`). The user message is persisted before
|
||||
streaming; the assistant message is persisted when the stream ends —
|
||||
complete on normal end, as a partial if the client aborts (stop button).
|
||||
|
||||
### SSE events (mirror of `ModeEvent`)
|
||||
|
||||
| event | data | meaning |
|
||||
|---|---|---|
|
||||
| `token` | `{"text": "..."}` | next fragment of the assistant reply |
|
||||
| `sources` | `{"chunks": [{document_id, title, heading_path, excerpt, used, review_pending}]}` | every retrieved passage; `used` marks the ones that grounded the answer (the "?" inspector shows all, the badges only `used`), `review_pending` that the cited document has an unanswered question about it |
|
||||
| `state` | `{"phase": "...", "count": 3\|null}` | progress updates |
|
||||
| `error` | `{"code"}` | why the turn failed, e.g. `llm_unreachable`; the frontend phrases it |
|
||||
| `fallback` | `{"code"}` | no model was reachable: the preceding `sources` are a plain full-text result list to open, and no answer follows |
|
||||
| `done` | `{"message_id": "..."}` | reply persisted, stream ends |
|
||||
|
||||
`state` is **metadata only, never content** (rule 12): the query text and
|
||||
retrieved passages never appear in a `state` payload. Query mode's phases are
|
||||
`searching` → `results` \| `no_answer` → `queued`? → `answering`; `queued`
|
||||
appears only when every slot on the chat endpoint is taken as the turn is about
|
||||
to stream, and is followed by `answering` when the first token arrives. `count`
|
||||
is the number of
|
||||
passages (0 for `no_answer`). The two-field frame is structurally incapable of
|
||||
carrying document text, and one shape serves every mode — an EE mode adding a
|
||||
phase does not change it.
|
||||
|
||||
`fallback` is the no-model path, and deliberately not an `error`: the turn
|
||||
still has a reply, just not a generated one. Retrieval drops to the German
|
||||
full-text index alone (`rag/retrieval.text_search`, no embedding call — and
|
||||
with the query's terms ORed rather than ANDed, so a whole typed question still
|
||||
finds something), the
|
||||
`sources` frame is re-sent with every passage `used: false` (nothing reached a
|
||||
prompt), and the frontend renders the hits as a list the reader opens
|
||||
themselves. It is persisted like any other reply — `messages.meta.fallback`
|
||||
holds the code, and `GET /api/conversations/{id}` returns it as
|
||||
`messages[].fallback` — so a reload replays the turn instead of showing an
|
||||
empty assistant bubble. A dead EMBEDDING endpoint alone does not trigger it:
|
||||
the roles are configured separately, so retrieval falls back to full text and
|
||||
the chat model still answers.
|
||||
|
||||
`no_answer` is the low-confidence path: retrieval found nothing solid, the
|
||||
model answers without sources, and the frontend offers to capture the missing
|
||||
knowledge — which opens the template picker and starts a new draft
|
||||
(`POST /api/documents`), not a conversation.
|
||||
|
||||
`review_pending` travels with every hit, from the retrieval SQL through the
|
||||
mode to the citation: a document can be published and still carry an open
|
||||
question about it, and the answer that leans on it says so (a warning mark on
|
||||
the source badge, in the "?" inspector, and in the fallback list). It is
|
||||
snapshotted with the citation like the rest, so reopening a conversation shows
|
||||
what was true when the answer was given.
|
||||
|
||||
The `excerpt` on a citation is a short (≤280 char) preview of the cited
|
||||
chunk — the requesting user already passed the permission filter for it.
|
||||
Assistant messages snapshot their citations into `messages.meta`, so
|
||||
`GET /api/conversations/{id}` returns them under `messages[].sources`
|
||||
(empty for user turns) and citations survive reload and re-indexing.
|
||||
|
||||
Frontend consumes this via `fetch` + `ReadableStream` (NOT `EventSource` —
|
||||
it can't POST) with an `AbortController` wired to the stop button; see
|
||||
`lib/api/stream.ts`.
|
||||
|
||||
## Documents
|
||||
|
||||
Listing and detail are permission-scoped (same filter as retrieval, plus the
|
||||
unpublished documents this user owns **or was asked to check**); unreadable
|
||||
documents return 404 — their existence must not leak. **Editing** requires the
|
||||
author, an admin, or a colleague with an open review request on the document —
|
||||
being asked to check something is what grants the right to fix it. The
|
||||
owner-only decisions (delete, sharing, handing out a review request) stay with
|
||||
the author or an admin. Every `DocumentSummary`/`DocumentDetail` carries the
|
||||
per-request `can_edit`, `open_reviews` (the number of unanswered questions) and
|
||||
`access_reason` (`author | public | department | granted | review`), so the UI
|
||||
predicts the gate rather than guessing it, and can mark a document that is
|
||||
readable but not settled. `review` is the reason that ENDS: it means an open
|
||||
request is the only thing letting this caller in, so answering it takes the
|
||||
access away — a draft goes back to being its author's alone.
|
||||
|
||||
This is where knowledge is captured: the user writes Markdown into a document
|
||||
that starts as a `draft`, matures it with AI section refinement, and publishes
|
||||
it themselves — one action, no approval queue. A `draft` is author-only
|
||||
(`readable_documents_filter` shows it to no one else, bar a colleague asked to
|
||||
check it) and never indexed (only `published` documents are searched), so it
|
||||
never reaches another user or an LLM prompt (rule 2).
|
||||
|
||||
- `POST /api/documents` `{template_id?, title?, visibility?, conversation_id?}`
|
||||
→ 201 with the new `draft`'s `DocumentDetail`. With a `template_id` the draft
|
||||
opens on that template's Markdown skeleton and rendered title (see
|
||||
`authoring-templates.md`); a bad template is 404 `not_found` / 422
|
||||
`invalid_template`. Without one it starts blank and `title` is required
|
||||
(422 `title_required`). When
|
||||
the capture started from a chat, an optional `conversation_id` (owner-scoped;
|
||||
a foreign or unknown id is ignored) has that conversation's subject summarized
|
||||
once by the LLM and stored on the draft as `meta.context`, background the
|
||||
refine prompt then uses to stay on topic.
|
||||
- `POST /api/documents/suggest-similar` `{conversation_id}` → existing documents
|
||||
that match the conversation a capture is starting from, as
|
||||
`[{document_id, title}]` (best first). The match runs over an **LLM
|
||||
topic summary** of the chat (not the raw last message), then hybrid retrieval
|
||||
(`rag/similarity.similar_documents`, `exclude_builtin`), so it is filtered by
|
||||
construction (rule 2) and never offers a help page. Owner-scoped; an empty
|
||||
list when the conversation is unknown/foreign/empty or nothing is close
|
||||
enough. Feeds the "matches your conversation" block in the picker so the user
|
||||
can extend an existing document instead of starting a new one.
|
||||
- `POST /api/documents/{id}/refine` `{content_md, cursor_line}` → **SSE
|
||||
stream** that matures the section the cursor sits in. Owner-scoped (author
|
||||
or admin over a readable document). The server computes the active section
|
||||
(`app/authoring/sections.py`), streams a refined version of **only** that
|
||||
section (FIM-style: the rest of the document is prefix/suffix context the
|
||||
model must not re-emit), and disables the reasoning model's thinking for
|
||||
~1s latency. The refinement is **retrieval-aware**: the server first searches
|
||||
the permission-filtered knowledge base for related, already-published
|
||||
material the author may read (the current document and help pages excluded,
|
||||
and only once the section carries enough of its own text) and passes it to
|
||||
the prompt as grounding, so a suggestion stays consistent with what the
|
||||
company already documented. Frames:
|
||||
- `section` `{start_line, end_line}` — 1-based inclusive range the
|
||||
suggestion will replace, sent first so the client can bind "Accept" to an
|
||||
exact range before any token arrives.
|
||||
- `grounding` `{references: [{title, heading_path}]}` — what the suggestion
|
||||
drew from (the author's own readable material), for the "?" inspector; only
|
||||
sent when the section matched something.
|
||||
- `token` `{text}` — next fragment of the refined section.
|
||||
- `done` `{}` — stream complete.
|
||||
- `error` `{code}` — an `llm_*` code (see General).
|
||||
|
||||
The request body and the streamed response carry document text; that is fine
|
||||
on this owner-scoped endpoint (same trust boundary as `GET /api/documents/{id}`)
|
||||
but nothing here logs content — metadata only (rule 12).
|
||||
- `POST /api/documents/{id}/suggest-title` → `{title}`, a concise title
|
||||
suggested from the document's content via `chat_json` (utility). Used at the
|
||||
review step for a new document that still carries its generic template title;
|
||||
owner-scoped (author or admin), content in / title out, nothing logged. Falls
|
||||
back to the current title for an empty document; 503 with the `llm_*` code
|
||||
when the endpoint fails.
|
||||
- `POST /api/documents/{id}/publish` → draft → published + `index_document`
|
||||
job. Author or admin, deliberately NOT every editor: a
|
||||
colleague asked to check a draft may fix what is wrong in it, but whether the
|
||||
company gets to read it at all is not their call. An open question about the
|
||||
content does not block it, it travels with the document instead. A dedicated
|
||||
transition because PATCH deliberately refuses to publish. Any other status →
|
||||
409 `invalid_status`.
|
||||
- `GET /api/documents/{id}/reviewers` → `[{id, name}]`, the colleagues who can
|
||||
be asked to check this document: everyone who could read it once published
|
||||
(author excluded). Editor-only, non-admin, and permission-safe — a dedicated
|
||||
query, not `/admin/users` — so only id + name leave the server.
|
||||
- `POST /api/documents/{id}/reviews` `{reviewer_id, question?}` → the document
|
||||
detail. Asks one colleague to check it, optionally about something specific
|
||||
("do the 14 holiday days still hold?", ≤2000 chars). Author/admin only; the
|
||||
reviewer must be in that read set (422 `invalid_reviewer`), cannot be the
|
||||
caller (422 `invalid_reviewer`), and cannot already have an open request on
|
||||
the document (409 `review_already_open`). Until it is answered, the request
|
||||
lets that colleague read AND edit the document (never search it), and marks
|
||||
it wherever it appears.
|
||||
- `POST /api/documents/{id}/reviews/{review_id}/resolve` → the document detail.
|
||||
The answer: the content was checked. The reviewer answers their own request;
|
||||
the author or an admin can close one that has become moot, so a question
|
||||
nobody will answer does not mark a document forever. Unknown request → 404,
|
||||
already answered → 409 `already_resolved`.
|
||||
- `PUT /api/documents/{id}/departments` `{department_ids, confirm_lockout?}` →
|
||||
the document detail. **Multi-department sharing**: replaces the full set of
|
||||
ADDITIONAL departments a document is shared with (its `doc_permissions`
|
||||
grants), on top of the owning department. Author/admin; unknown department →
|
||||
404; no reindex (grants are evaluated live). Unsharing can drop the editing
|
||||
admin's own access, so it runs the same self-lockout guard as PATCH (409
|
||||
`self_lockout_warning` unless `confirm_lockout`). The owning department is
|
||||
never part of the set. `GET /api/documents/{id}` returns the current set as
|
||||
`shared_departments: [{id, name}]`; the `?department=` list filter counts a
|
||||
shared document under each department it reaches.
|
||||
|
||||
- `GET /api/documents` (filters: department, status, assigned_to_me,
|
||||
search — the `search` param here is a plain title match, used for cheap
|
||||
filtering)
|
||||
- `GET /api/documents/search?q=` → ranked hits through the SAME hybrid
|
||||
retrieval the chat uses (`rag/retrieval.search`), so the permission
|
||||
filter is identical by construction (rule 2). Each hit carries the
|
||||
document plus `heading_path` — the best-matching section, empty when the
|
||||
match was on the title. Drafts are readable but never chunked, so a title
|
||||
fallback covers them — the one asymmetry between this endpoint and chat
|
||||
retrieval.
|
||||
- `GET /api/documents/{id}` — includes Markdown content, plus `reviews`:
|
||||
every request on this document oldest-first, open and answered, as
|
||||
`{id, question, requester_name, reviewer_id, reviewer_name, created_at,
|
||||
resolved_at, resolved_by_name, is_mine}`. The answered ones are the record of
|
||||
what was already checked; `is_mine` tells the UI to offer the answer rather
|
||||
than just show the question. Built-in help documents (`is_builtin`, sourced
|
||||
from `help/*.md`) are read-only: PATCH and DELETE return 409
|
||||
`builtin_readonly`, and their `can_edit` is always false.
|
||||
- `GET /api/documents` — browse. Returns an envelope
|
||||
`{items, total, per_page}`, not a bare list: the document list is
|
||||
the one screen that grows without bound. Query params `department`,
|
||||
`status`, `search` (title substring), `assigned_to_me` (bool — only
|
||||
documents with an open review request addressed to this user, the "waiting
|
||||
for my check" queue), `sort` (`updated` | `created`), `page` (≥1), `per_page`
|
||||
(1–100, default 30). `total` is
|
||||
computed over the same permission filter as the page, so it never
|
||||
reveals how much exists beyond what the caller may read. Built-in help
|
||||
sorts last in SQL, so it stays last across page boundaries.
|
||||
- `PATCH /api/documents/{id}` — title, content_md, visibility, and
|
||||
the archive transition (`status`: only published ↔ archived; publishing has
|
||||
its own endpoint, because it indexes the document). `visibility` needs the
|
||||
author or an admin (like sharing and deleting) while title/content only need
|
||||
an editor — who may READ a document is the owner's decision, a reviewer
|
||||
corrects the text. Content-affecting edits to
|
||||
published documents and status transitions enqueue `index_document`. A
|
||||
visibility change that would remove the editing user's own access runs the
|
||||
self-lockout guard (409 `self_lockout_warning` for an admin unless
|
||||
`confirm_lockout` is set; an author always keeps access).
|
||||
- `GET /api/documents/{id}/history` → `[{id, action, actor_id, actor_name,
|
||||
visibility, created_at, has_snapshot}]`, the audit trail newest-first: who
|
||||
changed or checked the document, when. `action` ∈ `created | edited |
|
||||
published | archived | visibility_changed | review_requested |
|
||||
review_resolved`;
|
||||
`has_snapshot` marks the content-bearing events whose frozen version can be
|
||||
fetched. Same read gate as `GET /api/documents/{id}` — history never leaks to a
|
||||
user who cannot read the document.
|
||||
- `GET /api/documents/{id}/versions/{event_id}` → `{id, action, actor_id,
|
||||
actor_name, created_at, title, content_md, previous_content_md, visibility}`,
|
||||
one past version's frozen Markdown plus the content it replaced, so the caller
|
||||
can show what THIS event changed (the detail page reuses the
|
||||
`unifiedMergeView` line diff). A snapshot is written after its event, so
|
||||
`previous_content_md` is the closest earlier snapshot, and null for the first
|
||||
one. Unknown/foreign event → 404. Same read gate.
|
||||
- `GET /api/documents/export` — a streamed ZIP of the readable knowledge base
|
||||
as Markdown files with YAML frontmatter (title, status, visibility,
|
||||
departments (owning + shared)). Permission-filtered by construction
|
||||
(`readable_documents_filter`), built-in help pages excluded; stdlib
|
||||
`zipfile`/`io` only, no temp files. Declared before `/{document_id}`.
|
||||
- `DELETE /api/documents/{id}` — chunks go with it (cascade)
|
||||
- `GET /api/documents/stats` → `{documents_total, departments_total}`, read by
|
||||
the landing page's first-run guide to tell a fresh install from a filled one.
|
||||
`documents_total` counts published documents only. Aggregates carry no titles
|
||||
and no per-user data (D15), so they are deliberately not permission-filtered.
|
||||
|
||||
## Templates & admin
|
||||
|
||||
- `GET /api/templates`, `GET /api/templates/{id}` — any authenticated user
|
||||
(the template picker needs them) → `{id, config_id, name, version,
|
||||
description}`. `id` is the row id used to start a document; `config_id` is
|
||||
the blueprint id from the YAML (e.g. `prozess`), stable across
|
||||
installs, so a surface that wants to offer ONE known blueprint can find it
|
||||
(the profile page's "write about yourself"). The detail carries `yaml`, the
|
||||
editable source rendered server-side, since the frontend has no YAML
|
||||
library. Admin-only from here on:
|
||||
- `POST /api/templates/build` `{template_id, config}` saves a template
|
||||
from the **form builder**: `config` is the structured
|
||||
`AuthoringTemplate` (the same schema pasted YAML would parse into, so
|
||||
the form and the YAML editor share one validation — the frontend has no
|
||||
YAML library and sends the config instead of serializing it).
|
||||
`template_id` null creates a row (the derived config id is uniquified
|
||||
server-side, so a second template with the same name never overwrites
|
||||
the first); a UUID updates that row in place (409 `id_taken` only if the
|
||||
config id collides with a *different* row). 422 on schema violations.
|
||||
- `PUT /api/templates/{id}` `{yaml}` replaces a template, validated on
|
||||
save; 409 `id_taken` if another template already uses that config id.
|
||||
- `POST /api/templates/{id}/duplicate` forks one — the copy gets a fresh
|
||||
config id (`<id>-kopie`), so the two never collide.
|
||||
- `DELETE /api/templates/{id}` removes a template; documents created from
|
||||
it are independent and survive (a template is only a starting point).
|
||||
- **Every template is editable.** There is no `builtin_readonly` here —
|
||||
that applies only to the built-in help *documents*
|
||||
(`/api/documents/*`). See `authoring-templates.md` for the reasoning.
|
||||
- The shipped **catalog** of blueprints, admin-only. A blueprint lives in
|
||||
`templates/` and has no row until it is added, so `catalog_id` is the
|
||||
config id, not a UUID:
|
||||
- `GET /api/templates/catalog` → `{id, name, description, sections, added}`
|
||||
per blueprint (`sections` = how many skeleton headings carry hints);
|
||||
`added` is true when a template with that config id already exists.
|
||||
- `GET /api/templates/catalog/{catalog_id}` → the same plus `yaml`, for
|
||||
reading the skeleton before adding. 404 `not_found` if unknown.
|
||||
- `POST /api/templates/catalog/{catalog_id}` copies it into this
|
||||
instance and returns the new `TemplateDetail`. 409 `already_added`
|
||||
rather than overwriting an admin's edits.
|
||||
- `GET /api/departments` — names + ids for filters and pickers, any
|
||||
authenticated user.
|
||||
- Admin (requires admin role):
|
||||
- Users CRUD under `/api/admin/users`: create (409 `email_taken` on
|
||||
duplicates), update (setting `password` IS the reset and **revokes all
|
||||
sessions of that user** — including the current one when an admin
|
||||
changes their own password; admins cannot change their own role or
|
||||
delete themselves — 409 `self_modification`), delete
|
||||
(sessions/conversations cascade, documents survive authorless).
|
||||
- Departments CRUD under `/api/admin/departments` (409 `name_taken`;
|
||||
deletion leaves members and documents without a department).
|
||||
- `POST /api/admin/llm/test` — pings all three model roles and reports
|
||||
which endpoint is broken (first-line support tool). Per role:
|
||||
`{role, ok, base_url, model, latency_ms, code, error}` — `code` is the
|
||||
shared `llm_*` code the frontend phrases, `error` the sanitized technical
|
||||
detail (exception class + role) an admin needs to act. With a body
|
||||
`{role, base_url?, model?, api_key?}` it probes ONE candidate
|
||||
configuration without persisting it, so an endpoint can be validated
|
||||
before saving.
|
||||
- `GET /api/admin/llm/settings` → stored config per role plus
|
||||
`*_from_env` / `api_key_set` flags. The key itself is never returned
|
||||
(rule 12). Values are seeded from `.env` at first start; after that
|
||||
the DB is authoritative (see `architecture.md`).
|
||||
- `PUT /api/admin/llm/settings/{role}` `{base_url?, model?, api_key?,
|
||||
reset_*?}` → stores the value and marks that field as changed here;
|
||||
`reset_<field>: true` writes back what `.env` currently says and marks
|
||||
it as coming from the environment again. Applies without a restart.
|
||||
An omitted `api_key` keeps the stored one.
|
||||
- `POST /api/admin/llm/models/{role}` `{base_url?, api_key?}` →
|
||||
`{models, supported, error}`. Calls the endpoint's `GET /v1/models`
|
||||
server-side so the credentials never reach the browser, letting the UI
|
||||
offer a dropdown. `supported: false` means the endpoint has no such
|
||||
route (common, not an error) and the UI keeps free-text entry.
|
||||
- `GET /api/admin/prompts` → `[{key, content, is_default}]`, every editable
|
||||
system prompt with its effective text (an override, or the code default)
|
||||
and whether it is still the default. The UI labels each key from i18n.
|
||||
- `PUT /api/admin/prompts/{key}` `{content?}` or `{reset: true}` → overrides a
|
||||
system prompt (applied without a restart) or drops the override back to the
|
||||
code default. Empty content → 422 `empty_prompt`; unknown key → 404.
|
||||
- `GET /api/admin/metrics` — in-process metrics snapshot as JSON (LLM calls
|
||||
per role, job queue depth/duration/retries). Admin only; a Prometheus
|
||||
text-format exporter is planned for M10.
|
||||
|
||||
## Security notes
|
||||
|
||||
- All LLM output and document content rendered in the frontend passes
|
||||
through the sanitizing Markdown renderer (DOMPurify). Model output is
|
||||
untrusted input (stored-XSS vector via document content).
|
||||
- Retrieval endpoints never accept a user id from the client; the user comes
|
||||
from the session, and permission filtering happens inside
|
||||
`rag/retrieval.search` (see `data-model.md`).
|
||||
Reference in New Issue
Block a user