Compare commits
1
Commits
main
...
784b76baf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
784b76baf7 |
@@ -0,0 +1,72 @@
|
|||||||
|
# Pablan configuration — copy to .env and adjust.
|
||||||
|
# Read by docker compose (variable interpolation) and by the backend settings.
|
||||||
|
|
||||||
|
# --- Environment ---
|
||||||
|
# "development" or "production". The customer stack (docker-compose.yml)
|
||||||
|
# defaults to production regardless of this file; native dev reads it as-is.
|
||||||
|
# Seeding dev data is refused when set to production.
|
||||||
|
PABLAN_ENV=development
|
||||||
|
# Secure cookies require https. Dev access happens over plain http via
|
||||||
|
# WireGuard IPs (not localhost), where browsers silently drop Secure cookies —
|
||||||
|
# so dev sets false. NEVER set false in production (Caddy terminates https).
|
||||||
|
PABLAN_COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
POSTGRES_USER=pablan
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=pablan
|
||||||
|
# Dev (make dev): Postgres runs in Docker on localhost:5432.
|
||||||
|
# In the customer stack (docker-compose.yml) the backend gets this URL with
|
||||||
|
# host "postgres" instead — set automatically, no need to change it there.
|
||||||
|
PABLAN_DATABASE_URL=postgresql+asyncpg://pablan:change-me@localhost:5432/pablan
|
||||||
|
|
||||||
|
# --- LLM load ---
|
||||||
|
# How many requests Pablan lets ONE endpoint see at once. Match it to the
|
||||||
|
# server's own parallelism (llama.cpp: --parallel); beyond it, requests wait
|
||||||
|
# here rather than piling up where Pablan cannot bound them.
|
||||||
|
PABLAN_LLM_MAX_PARALLEL=4
|
||||||
|
# How long a request waits for a free slot before the user is told the model
|
||||||
|
# is busy, and how many may be waiting at all.
|
||||||
|
PABLAN_LLM_QUEUE_WAIT_SECONDS=20
|
||||||
|
PABLAN_LLM_MAX_QUEUED=24
|
||||||
|
|
||||||
|
# --- LLM endpoints (OpenAI-compatible; three independent model roles) ---
|
||||||
|
# chat: conversation turns — quality matters (Gemma-class 12B+ locally, cloud in prod)
|
||||||
|
PABLAN_CHAT_BASE_URL=http://localhost:8001/v1
|
||||||
|
PABLAN_CHAT_API_KEY=none
|
||||||
|
PABLAN_CHAT_MODEL=unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL
|
||||||
|
|
||||||
|
# utility: bookkeeping, summarization, entity extraction — cheap + fast
|
||||||
|
# (same local server as chat by default)
|
||||||
|
PABLAN_UTILITY_BASE_URL=http://localhost:8001/v1
|
||||||
|
PABLAN_UTILITY_API_KEY=none
|
||||||
|
PABLAN_UTILITY_MODEL=unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL
|
||||||
|
|
||||||
|
# embedding: multilingual embeddings — German retrieval quality is first-class.
|
||||||
|
# Expects an OpenAI-compatible server (e.g. llama.cpp with bge-m3) serving
|
||||||
|
# /v1/embeddings at this base URL.
|
||||||
|
PABLAN_EMBEDDING_BASE_URL=http://localhost:8002/v1
|
||||||
|
PABLAN_EMBEDDING_API_KEY=none
|
||||||
|
PABLAN_EMBEDDING_MODEL=gpustack/bge-m3-GGUF:Q8_0
|
||||||
|
|
||||||
|
# --- Privacy / retention ---
|
||||||
|
# Query-mode conversations are auto-deleted after this many days
|
||||||
|
# (retention_cleanup job).
|
||||||
|
PABLAN_QUERY_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
# PABLAN_LOG_LEVEL=INFO
|
||||||
|
# Content debug logging (prompts, LLM responses). NEVER enable in production —
|
||||||
|
# logs must stay free of prompts, user messages and document text.
|
||||||
|
# PABLAN_DEBUG_LOG_PROMPTS=false
|
||||||
|
|
||||||
|
# --- Tuning (defaults are fine) ---
|
||||||
|
# PABLAN_LLM_TIMEOUT_SECONDS=120
|
||||||
|
# PABLAN_JOB_POLL_SECONDS=1.0
|
||||||
|
|
||||||
|
# --- Enterprise (ee/) ---
|
||||||
|
# PABLAN_EE_LICENSE_KEY=
|
||||||
|
|
||||||
|
# --- Customer deployment (docker-compose.yml) ---
|
||||||
|
# Domain Caddy serves with automatic TLS. Use "localhost" for a local smoke test.
|
||||||
|
PABLAN_DOMAIN=pablan.example.com
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp/
|
||||||
|
/.vscode/
|
||||||
|
|
||||||
|
# Screenshots taken during visual checks. They belong in a message, not
|
||||||
|
# in the repository.
|
||||||
|
*.png
|
||||||
|
!frontend/static/**/*.png
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# CLAUDE.md — Pablan
|
||||||
|
|
||||||
|
Self-hosted knowledge management for SMEs. Employees write knowledge documents
|
||||||
|
in a split-screen Markdown editor while an LLM refines the section they are
|
||||||
|
working on; the same LLM answers questions over the resulting documents via
|
||||||
|
RAG. Everything is stored as Markdown and runs on the customer's
|
||||||
|
infrastructure. Team of 2 developers — bias toward simplicity, no speculative
|
||||||
|
abstractions.
|
||||||
|
|
||||||
|
Design docs live in `docs/` (English). Read the relevant doc before larger changes.
|
||||||
|
**Claude maintains the documentation**: whenever a decision, schema, API, or
|
||||||
|
workflow changes, update the affected file in `docs/` in the same change — stale
|
||||||
|
docs are treated as bugs. This includes the diagram sources in
|
||||||
|
`docs/diagrams/` (hand-authored SVG, theme-aware; the SVG is the artifact,
|
||||||
|
no diagram toolchain).
|
||||||
|
`docs/roadmap.md` is the feature-based backlog: every feature area as an epic with
|
||||||
|
done (`[x]`) and open (`[ ]`) stories, ordered so it reads as a rebuild manual. It
|
||||||
|
is the only forward-looking doc — its open epics may describe not-yet-built work;
|
||||||
|
everything else in `docs/` describes the as-is state only. `docs/notes.md` holds the
|
||||||
|
durable implementation learnings (calibrations, gotchas, why-it-is-this-way). When a
|
||||||
|
feature ships, tick its story in the roadmap and fold any lasting learning into
|
||||||
|
`docs/notes.md`, in the same change. The root `README.md` gives a short high-level overview
|
||||||
|
(what Pablan is, stack, quickstart) and links into `docs/`; it is also
|
||||||
|
Claude-maintained and must stay short and current.
|
||||||
|
|
||||||
|
**`help/` is in-product documentation and follows the same rule**: those
|
||||||
|
Markdown files are the built-in help pages users read *inside* Pablan
|
||||||
|
(imported into `documents` on every start, `is_builtin`, not editable in
|
||||||
|
the UI). Any change to a user-facing workflow, screen or concept updates
|
||||||
|
the affected help page in the same change — an outdated help page is a bug
|
||||||
|
like a stale `docs/` page, except users see this one. `docs/` explains the
|
||||||
|
system to developers; `help/` explains the product to its users, in German
|
||||||
|
(product content, like templates).
|
||||||
|
|
||||||
|
## Stack (fixed decisions — do not substitute)
|
||||||
|
|
||||||
|
- **Backend**: Python 3.12+, FastAPI (async), managed with `uv`. Lint+format: `ruff`.
|
||||||
|
- **DB**: PostgreSQL + pgvector. ORM: SQLAlchemy 2.0 (async, typed). Migrations: Alembic.
|
||||||
|
No dedicated vector DB, no Prisma, no Redis.
|
||||||
|
- **Frontend**: SvelteKit (Svelte 5 runes) + TypeScript + Tailwind. Prettier + ESLint.
|
||||||
|
Headless behavior via **Bits UI**; we write our own styled components on top.
|
||||||
|
No shadcn imports, no component library dependencies beyond Bits UI.
|
||||||
|
- **LLM**: OpenAI-compatible endpoints only (llama.cpp server locally, cloud APIs in prod).
|
||||||
|
No LangChain / LlamaIndex — thin custom client in `backend/app/llm/client.py`.
|
||||||
|
- **Deployment**: Docker Compose (postgres, backend, frontend, reverse proxy).
|
||||||
|
Reverse proxy config in `deploy/` (Caddy, the only supported proxy for now).
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
- `backend/` — FastAPI app (`app/models`, `app/auth`, `app/api`, `app/modes`,
|
||||||
|
`app/authoring`, `app/llm`, `app/rag`, `app/ingestion`)
|
||||||
|
- `frontend/` — SvelteKit, pure UI / API client. No DB access, no auth logic beyond
|
||||||
|
cookie passthrough in `hooks.server.ts`.
|
||||||
|
- `templates/` — built-in authoring templates: Markdown skeletons (YAML).
|
||||||
|
Product content, not code.
|
||||||
|
- `ee/` — proprietary Enterprise modules. **Core code must NEVER import from ee/.**
|
||||||
|
ee registers itself via `app/ee_hooks.py` and the mode/frontend registries.
|
||||||
|
- `docs/` — English design documentation, kept in sync with code by Claude.
|
||||||
|
- License: FSL-1.1 for core, separate proprietary license in `ee/LICENSE`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `make dev` — full dev stack with hot reload (postgres in docker, backend +
|
||||||
|
frontend native)
|
||||||
|
- `make down` — stop the dev stack
|
||||||
|
- `make migrate` — apply Alembic migrations
|
||||||
|
- `make seed` — seed dev data
|
||||||
|
- `make types` — regenerate `frontend/src/lib/api/schema.d.ts` from OpenAPI.
|
||||||
|
Run after ANY backend API change.
|
||||||
|
- `make lint` — ruff + prettier + eslint + design-token contrast check
|
||||||
|
(CI runs the same)
|
||||||
|
- `make eval` — LLM eval suite in `backend/tests/evals` against the configured endpoint
|
||||||
|
- `make e2e` — Playwright end-to-end tests against the dev stack
|
||||||
|
|
||||||
|
## Architecture rules (invariants — enforce in every change)
|
||||||
|
|
||||||
|
1. **Markdown is the source of truth.** Documents live as Markdown in Postgres.
|
||||||
|
Chunks/embeddings are disposable derivatives; any pipeline change must allow
|
||||||
|
full re-indexing from documents.
|
||||||
|
2. **Permissions filter BEFORE the LLM.** All retrieval goes through
|
||||||
|
`rag/retrieval.search(query, user=...)` — there is no search without a user.
|
||||||
|
Never pass chunks to a prompt that the requesting user could not read.
|
||||||
|
3. **All LLM traffic goes through `llm/client.py`** (`chat_stream`, `chat_json`,
|
||||||
|
`embed`) with model roles `chat` / `utility` / `embedding`, each independently
|
||||||
|
configurable (base_url, api_key, model). Never call an LLM HTTP API elsewhere.
|
||||||
|
Its failure vocabulary lives in `llm/errors.py`: `LLMError.code` classifies
|
||||||
|
every endpoint failure once, and the frontend phrases it.
|
||||||
|
4. **Structured outputs use `chat_json`** with a Pydantic schema passed as
|
||||||
|
`response_format` (JSON schema). Never parse free-form LLM text into data.
|
||||||
|
5. **Modes implement the `Mode` protocol** (`modes/base.py`) yielding `ModeEvent`s;
|
||||||
|
the conversations router converts events to SSE. Modes know no HTTP; routers
|
||||||
|
know no mode logic. New modes register in `modes/registry.py`. Query (RAG Q&A)
|
||||||
|
is the only core mode; EE adds insight. Capture is NOT a mode — see rule 6.
|
||||||
|
6. **Capture is writing-first, not a conversation.** The user authors a
|
||||||
|
`Document` directly (Markdown is the source of truth, rule 1); it starts in
|
||||||
|
`draft` status — author-only (`rag/permissions.readable_documents_filter`) and
|
||||||
|
never indexed until published (searchable requires `published`), so a draft
|
||||||
|
never reaches another user or an LLM prompt (rule 2). Publishing is the
|
||||||
|
author's own one-click action; the three statuses (draft, published,
|
||||||
|
archived) say where a document stands, never whether its CONTENT is
|
||||||
|
trusted. That is a `ReviewRequest` — "please check this", which can hang on
|
||||||
|
a draft or on a document published months ago, grants the person asked the
|
||||||
|
right to edit until they answer, and marks the document everywhere it
|
||||||
|
appears including chat sources. Section refinement
|
||||||
|
(`POST /api/documents/{id}/refine`, package `app/authoring/`) regenerates ONLY
|
||||||
|
the section at the cursor (FIM-style: the rest of the document is prefix/suffix
|
||||||
|
context), so large documents stay cheap and small local models (Gemma-class)
|
||||||
|
stay reliable. The active-section boundary is computed server-side, shared with
|
||||||
|
`rag/chunking`. No hidden engine state — the document is the state.
|
||||||
|
7. **Prompts are rendered natural language**, never raw YAML/JSON dumps.
|
||||||
|
8. **Auth = server-side sessions** (argon2 password hashes, `auth_sessions` table,
|
||||||
|
httpOnly cookie). No JWT. Naming: chat threads are `conversations`,
|
||||||
|
login sessions are `auth_sessions` — never mix these up.
|
||||||
|
9. **Background work goes through the `jobs` table** (`ingestion/queue.py`,
|
||||||
|
`FOR UPDATE SKIP LOCKED` loop). No new queue infrastructure.
|
||||||
|
10. **Frontend renders LLM/document Markdown only through the sanitizing
|
||||||
|
renderer** (DOMPurify). Treat all model output and document content as untrusted.
|
||||||
|
11. **API contract flows one way**: FastAPI OpenAPI → `openapi-typescript` →
|
||||||
|
typed `openapi-fetch` client. Never hand-write API response types.
|
||||||
|
12. **NEVER log content** — no prompts, no LLM responses, no user messages,
|
||||||
|
no document text. Log metadata only (model role, duration, token counts,
|
||||||
|
error codes, entity IDs, correlation id). This applies to every log line,
|
||||||
|
including exceptions (no content in error messages). Content debug
|
||||||
|
logging only behind `PABLAN_DEBUG_LOG_PROMPTS=true`, documented as
|
||||||
|
never-in-production.
|
||||||
|
13. **Auth boundaries (login/logout) are full document navigations, never
|
||||||
|
client-side.** Module-level client state (the conversation list, chat
|
||||||
|
state, the resolved locale — all runes singletons) is guaranteed dead at
|
||||||
|
the session boundary because the page is reloaded. Do NOT convert these
|
||||||
|
to client navigation (`goto`/`invalidateAll`): a client nav keeps the
|
||||||
|
previous user's singletons alive and leaks their data (conversation
|
||||||
|
titles are an information disclosure). This is immune to stores added
|
||||||
|
later; a per-store reset is not.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Code, comments, identifiers, `docs/`, README, seed data, test strings:
|
||||||
|
English. Template content and the test fixture corpus in
|
||||||
|
`tests/fixtures/`: German — product content for the German market.
|
||||||
|
- **UI copy goes through Paraglide messages** (`frontend/messages/`),
|
||||||
|
source language **de** (informal "du"), **en** written in the same
|
||||||
|
change. A hardcoded UI string is a bug; a missing `en` message and an
|
||||||
|
em/en dash in any message both fail `make lint`
|
||||||
|
(`frontend/scripts/check-messages.py`). See `docs/i18n.md` for the key
|
||||||
|
naming convention and how the locale is resolved.
|
||||||
|
- **The backend never renders UI-language strings.** API errors are
|
||||||
|
`{detail, code}` and the frontend translates by `code`; SSE `state`
|
||||||
|
events carry counts and markers, and the frontend phrases them.
|
||||||
|
- Full type hints in Python; `ruff` rules include `I` (isort) and `B` (bugbear).
|
||||||
|
- Svelte 5 runes only (`$state`, `$derived`, `$props`) — no legacy stores for new code.
|
||||||
|
- Keep the base component set small; reuse `lib/components/` primitives.
|
||||||
|
No one-off colors or spacing — see Design tokens below.
|
||||||
|
- DB primary keys: UUID. Timestamps via the shared mixin in `models/base.py`.
|
||||||
|
- `docs/diagrams/data-model.svg` is the ER diagram; any model or migration
|
||||||
|
change updates the diagram and the prose in `docs/data-model.md` in the
|
||||||
|
same change.
|
||||||
|
- Retention/privacy defaults matter (GDPR): users can delete their own
|
||||||
|
conversations; retention cleanup runs as a scheduled job; insights features
|
||||||
|
must only ever see aggregated data, never per-user raw messages.
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
- All colors are defined ONCE as semantic CSS variables in `frontend/src/app.css`
|
||||||
|
and mapped into the Tailwind theme: `--color-primary`, `--color-secondary`,
|
||||||
|
`--color-accent`, plus role tokens for surfaces, text, borders, and states
|
||||||
|
(success/warning/danger), each with the shades needed for hover/muted variants.
|
||||||
|
- Current palette values live ONLY in `frontend/src/app.css` — CLAUDE.md
|
||||||
|
never names colors. The palette must be swappable by editing only the
|
||||||
|
token definitions — components reference tokens exclusively (`bg-primary`,
|
||||||
|
`text-accent`), NEVER raw hex values or Tailwind default colors.
|
||||||
|
- The accent token is for highlights, active states, and CTAs — never for
|
||||||
|
body text or large surfaces. Check WCAG AA contrast for any token pairing
|
||||||
|
(`frontend/scripts/contrast-check.py`, enforced by `make lint`); every
|
||||||
|
token needs a working value in both light and dark mode.
|
||||||
|
- Spacing, radii, and typography sizes also come from the theme scale — no
|
||||||
|
arbitrary values (`p-[13px]`) in components.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Fast unit tests colocated in `backend/tests/`; retrieval logic gets SQL-level
|
||||||
|
tests against a real Postgres (docker).
|
||||||
|
- `tests/evals/` holds the refinement/query eval set — extend it whenever prompt
|
||||||
|
or engine behavior changes, and run `make eval` with both a local model and
|
||||||
|
a cloud model before merging prompt changes.
|
||||||
|
- E2E: Playwright in `frontend/e2e/` against the dev stack (`make e2e`).
|
||||||
|
Cover the critical paths: login, chat streaming, the writing editor (section
|
||||||
|
refinement + accept), publishing, review requests, permission boundaries
|
||||||
|
(user A must not see user B's restricted docs).
|
||||||
|
- **Visual verification**: when changing UI, use the Playwright MCP server to
|
||||||
|
open the affected pages, take screenshots (light AND dark mode), and inspect
|
||||||
|
the result before considering the work done. Frontend changes are not
|
||||||
|
finished on "it compiles".
|
||||||
|
|
||||||
|
## Dev environment notes
|
||||||
|
|
||||||
|
- Local LLM: llama.cpp server (Gemma-class 12B/26B) via OpenAI-compatible API;
|
||||||
|
quality reference: Claude Sonnet via API. Both configured purely through `.env`
|
||||||
|
(`PABLAN_CHAT_*`, `PABLAN_UTILITY_*`, `PABLAN_EMBEDDING_*`).
|
||||||
|
- Embeddings: multilingual model (e.g. bge-m3) — German retrieval quality is a
|
||||||
|
first-class requirement; hybrid search (pgvector + Postgres `german` full-text
|
||||||
|
with RRF) is the default, not an option.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Functional Source License, Version 1.1, ALv2 Future License
|
||||||
|
|
||||||
|
## Abbreviation
|
||||||
|
|
||||||
|
FSL-1.1-ALv2
|
||||||
|
|
||||||
|
## Notice
|
||||||
|
|
||||||
|
Copyright 2026 Pablan
|
||||||
|
|
||||||
|
## Terms and Conditions
|
||||||
|
|
||||||
|
### Licensor ("We")
|
||||||
|
|
||||||
|
The party offering the Software under these Terms and Conditions.
|
||||||
|
|
||||||
|
### The Software
|
||||||
|
|
||||||
|
The "Software" is each version of the software that we make available under
|
||||||
|
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||||
|
Conditions with the Software.
|
||||||
|
|
||||||
|
### License Grant
|
||||||
|
|
||||||
|
Subject to your compliance with this License Grant and the Patents,
|
||||||
|
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||||
|
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||||
|
and redistribute the Software for any Permitted Purpose identified below.
|
||||||
|
|
||||||
|
### Permitted Purpose
|
||||||
|
|
||||||
|
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||||
|
means making the Software available to others in a commercial product or
|
||||||
|
service that:
|
||||||
|
|
||||||
|
1. substitutes for the Software;
|
||||||
|
|
||||||
|
2. substitutes for any other product or service we offer using the Software
|
||||||
|
that exists as of the date we make the Software available; or
|
||||||
|
|
||||||
|
3. offers the same or substantially similar functionality as the Software.
|
||||||
|
|
||||||
|
Permitted Purposes specifically include using the Software:
|
||||||
|
|
||||||
|
1. for your internal use and access;
|
||||||
|
|
||||||
|
2. for non-commercial education;
|
||||||
|
|
||||||
|
3. for non-commercial research; and
|
||||||
|
|
||||||
|
4. in connection with professional services that you provide to a licensee
|
||||||
|
using the Software in accordance with these Terms and Conditions.
|
||||||
|
|
||||||
|
### Patents
|
||||||
|
|
||||||
|
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||||
|
patents, the license grant above includes a license under our patents. If you
|
||||||
|
make a claim against any party that the Software infringes or contributes to
|
||||||
|
the infringement of any patent, then your patent license to the Software ends
|
||||||
|
immediately.
|
||||||
|
|
||||||
|
### Redistribution
|
||||||
|
|
||||||
|
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||||
|
the Software.
|
||||||
|
|
||||||
|
If you redistribute any copies, modifications or derivatives of the Software,
|
||||||
|
you must include a copy of or a link to these Terms and Conditions and not
|
||||||
|
remove any copyright notices provided in or with the Software.
|
||||||
|
|
||||||
|
### Disclaimer
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||||
|
|
||||||
|
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||||
|
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||||
|
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
Except for displaying the License Details and identifying us as the origin of
|
||||||
|
the Software, you have no right under these Terms and Conditions to use our
|
||||||
|
trademarks, trade names, service marks or product names.
|
||||||
|
|
||||||
|
## Grant of Future License
|
||||||
|
|
||||||
|
We hereby irrevocably grant you an additional license to use the Software under
|
||||||
|
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||||
|
the date we make the Software available. On or after that date, you may use the
|
||||||
|
Software under the Apache License, Version 2.0, in which case the following
|
||||||
|
will apply:
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||||
|
this file except in compliance with the License.
|
||||||
|
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software distributed
|
||||||
|
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
|
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations under the License.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
COMPOSE_DEV := docker compose -f docker-compose.dev.yml
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help dev dev-backend dev-frontend down migrate seed types lint eval e2e
|
||||||
|
|
||||||
|
help: ## list available targets
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-10s %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
dev: ## full dev stack with hot reload (postgres in docker, backend + frontend native)
|
||||||
|
@for port in 8000 5173; do \
|
||||||
|
if ss -ltn "sport = :$$port" 2>/dev/null | tail -n +2 | grep -q .; then \
|
||||||
|
echo "ERROR: port $$port is already in use — stale 'uvicorn app.main:app' or 'vite dev'?"; \
|
||||||
|
ss -ltnp "sport = :$$port" 2>/dev/null | tail -n +2; \
|
||||||
|
echo "Kill the shown process and retry."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
$(COMPOSE_DEV) up -d --wait
|
||||||
|
$(MAKE) -j2 dev-backend dev-frontend
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && uv run uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
down: ## stop the dev stack
|
||||||
|
$(COMPOSE_DEV) down
|
||||||
|
|
||||||
|
migrate: ## apply alembic migrations
|
||||||
|
cd backend && uv run alembic upgrade head
|
||||||
|
|
||||||
|
seed: ## seed dev data
|
||||||
|
cd backend && uv run python -m app.seed
|
||||||
|
|
||||||
|
types: ## regenerate frontend/src/lib/api/schema.d.ts from the FastAPI OpenAPI schema
|
||||||
|
cd backend && uv run python -c "import json, sys; from app.main import app; json.dump(app.openapi(), sys.stdout)" > ../frontend/openapi.json
|
||||||
|
cd frontend && npx openapi-typescript openapi.json -o src/lib/api/schema.d.ts
|
||||||
|
rm frontend/openapi.json
|
||||||
|
|
||||||
|
lint: ## ruff + prettier + eslint + design-token contrast check (CI runs the same)
|
||||||
|
cd backend && uv run ruff check . && uv run ruff format --check .
|
||||||
|
@# Recompiling Paraglide rewrites every module under src/lib/paraglide/. A
|
||||||
|
@# running `make dev` is watching exactly those files and ends up serving a
|
||||||
|
@# stale graph (500s until vite restarts) -- and it compiles them through
|
||||||
|
@# its own plugin anyway, so skipping is not a gap.
|
||||||
|
@if ss -ltn 'sport = :5173' 2>/dev/null | tail -n +2 | grep -q .; then \
|
||||||
|
echo "dev server on :5173 -- keeping its compiled messages, checking files only"; \
|
||||||
|
cd frontend && npm run lint:files; \
|
||||||
|
else \
|
||||||
|
cd frontend && npm run lint; \
|
||||||
|
fi
|
||||||
|
python3 frontend/scripts/contrast-check.py
|
||||||
|
python3 frontend/scripts/check-messages.py
|
||||||
|
python3 backend/scripts/check-no-ui-strings.py
|
||||||
|
|
||||||
|
eval: ## LLM eval suite against the configured endpoints
|
||||||
|
cd backend && uv run pytest tests/evals -m eval -s
|
||||||
|
|
||||||
|
e2e: ## playwright end-to-end tests against the dev stack
|
||||||
|
cd frontend && npm run test:e2e
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Pablan
|
||||||
|
|
||||||
|
Self-hosted knowledge management for SMEs. Employees capture knowledge by
|
||||||
|
writing Markdown directly into a document, with an LLM editor that matures the
|
||||||
|
section at the cursor as they write (onboarding, offboarding, project debriefs
|
||||||
|
and more, started from templates); the results are stored as Markdown documents
|
||||||
|
and questions about the knowledge base are answered via RAG. Everything runs on
|
||||||
|
your own infrastructure. The interface ships in German and English.
|
||||||
|
|
||||||
|
The problem it solves: knowledge leaves the company when employees do, and
|
||||||
|
departments solve problems in isolation. Pablan makes writing tacit knowledge
|
||||||
|
down quick and makes it searchable, with permission filtering **before** the
|
||||||
|
LLM sees anything, drafts that stay private until their author publishes them,
|
||||||
|
and GDPR-friendly retention defaults.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend** — Python 3.12, FastAPI (async), SQLAlchemy 2.0 + Alembic, managed with [uv](https://docs.astral.sh/uv/)
|
||||||
|
- **Database** — PostgreSQL + pgvector: relational data, documents, embeddings, full-text (hybrid search), jobs and sessions in one DB
|
||||||
|
- **Frontend** — SvelteKit (Svelte 5) + TypeScript + Tailwind, own components on Bits UI
|
||||||
|
- **LLM** — any OpenAI-compatible endpoint (llama.cpp locally, cloud APIs in prod); three independently configured model roles: chat / utility / embedding
|
||||||
|
- **Deployment** — Docker Compose: postgres, backend, frontend, Caddy
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Prerequisites: Docker, uv, Node 22+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # set DB password and LLM endpoints
|
||||||
|
make dev # postgres in docker, backend + frontend native with hot reload
|
||||||
|
make migrate # apply DB migrations
|
||||||
|
make seed # seed dev data
|
||||||
|
```
|
||||||
|
|
||||||
|
App: <http://localhost:5173> · API: <http://localhost:8000/api>
|
||||||
|
|
||||||
|
| Command | |
|
||||||
|
|---|---|
|
||||||
|
| `make dev` / `make down` | start / stop the dev stack |
|
||||||
|
| `make migrate` | apply Alembic migrations |
|
||||||
|
| `make seed` | seed dev data |
|
||||||
|
| `make types` | regenerate the typed API client from OpenAPI |
|
||||||
|
| `make lint` | ruff + prettier + eslint + token contrast check (same as CI) |
|
||||||
|
| `make eval` | LLM eval suite against the configured endpoints |
|
||||||
|
| `make e2e` | Playwright end-to-end tests |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The customer stack is plain Docker Compose:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # set POSTGRES_PASSWORD, PABLAN_DOMAIN, LLM endpoints
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy terminates TLS for `PABLAN_DOMAIN` automatically and serves frontend and
|
||||||
|
API on one origin — config in [`deploy/`](deploy/README.md).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [roadmap.md](docs/roadmap.md) | feature backlog: what's built and what's next, as a rebuild manual |
|
||||||
|
| [architecture.md](docs/architecture.md) | deployment model, backend/frontend structure, mode engine |
|
||||||
|
| [data-model.md](docs/data-model.md) | tables, permission model, hybrid retrieval, GDPR defaults |
|
||||||
|
| [api-protocol.md](docs/api-protocol.md) | REST + SSE contract |
|
||||||
|
| [authoring-templates.md](docs/authoring-templates.md) | declarative Markdown-skeleton template format for capture |
|
||||||
|
| [i18n.md](docs/i18n.md) | message conventions, locale resolution, what stays untranslated |
|
||||||
|
| [decisions.md](docs/decisions.md) | ADR-style decision log |
|
||||||
|
| [notes.md](docs/notes.md) | durable implementation learnings — calibrations, gotchas, rationale |
|
||||||
|
| [licensing.md](docs/licensing.md) | Fair Source licensing model |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Pablan is **Fair Source**: the core is licensed under the
|
||||||
|
[Functional Source License 1.1 with Apache 2.0 future grant](LICENSE)
|
||||||
|
(FSL-1.1-ALv2) — free to read, audit, self-host and use internally; each
|
||||||
|
release automatically becomes Apache 2.0 two years after publication. The
|
||||||
|
`ee/` directory contains proprietary Enterprise modules under a
|
||||||
|
[separate license](ee/LICENSE).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
tests
|
||||||
|
Dockerfile
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
# Install dependencies first so source changes don't bust this layer.
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||||
|
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
# sqlalchemy.url is provided by alembic/env.py (app settings / test override)
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration with an async dbapi.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
# Never disable already-created application loggers (e.g. when tests or
|
||||||
|
# tooling run migrations programmatically after app modules are imported).
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# URL priority: alembic.ini / programmatic override (tests) → app settings.
|
||||||
|
if not config.get_main_option("sqlalchemy.url"):
|
||||||
|
config.set_main_option(
|
||||||
|
"sqlalchemy.url", get_settings().database_url.replace("%", "%%")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode: emit SQL without a DB connection."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""message meta
|
||||||
|
|
||||||
|
Revision ID: 15bc390bcabb
|
||||||
|
Revises: 563ae5ac1d0d
|
||||||
|
Create Date: 2026-07-20 09:05:10.023514
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "15bc390bcabb"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "563ae5ac1d0d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.add_column(
|
||||||
|
"messages",
|
||||||
|
sa.Column(
|
||||||
|
"meta",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default="{}",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_column("messages", "meta")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""dismissed hints
|
||||||
|
|
||||||
|
Revision ID: 50f054decf55
|
||||||
|
Revises: 72ef34f36387
|
||||||
|
Create Date: 2026-07-20 13:14:22.851339
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "50f054decf55"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "72ef34f36387"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column(
|
||||||
|
"dismissed_hints",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default="[]",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_column("users", "dismissed_hints")
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 563ae5ac1d0d
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-18 14:41:47.620604
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import pgvector.sqlalchemy
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "563ae5ac1d0d"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"departments",
|
||||||
|
sa.Column("name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"jobs",
|
||||||
|
sa.Column("type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum(
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"done",
|
||||||
|
"failed",
|
||||||
|
name="jobstatus",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"run_after",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_jobs_status_run_after", "jobs", ["status", "run_after"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"templates",
|
||||||
|
sa.Column("name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("version", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("email", sa.String(length=320), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.Enum("member", "admin", name="userrole", native_enum=False, length=32),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("department_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["department_id"], ["departments.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||||
|
op.create_table(
|
||||||
|
"auth_sessions",
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"documents",
|
||||||
|
sa.Column("title", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum(
|
||||||
|
"draft",
|
||||||
|
"published",
|
||||||
|
"archived",
|
||||||
|
name="documentstatus",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"visibility",
|
||||||
|
sa.Enum(
|
||||||
|
"public",
|
||||||
|
"department",
|
||||||
|
"restricted",
|
||||||
|
name="documentvisibility",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("content_md", sa.Text(), nullable=False),
|
||||||
|
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("author_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("department_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["author_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["department_id"], ["departments.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"chunks",
|
||||||
|
sa.Column("document_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("chunk_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"embedding", pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"tsv",
|
||||||
|
postgresql.TSVECTOR(),
|
||||||
|
sa.Computed(
|
||||||
|
"to_tsvector('german'::regconfig, "
|
||||||
|
"content || ' ' || coalesce(meta->>'heading_path', ''))",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("document_id", "chunk_index"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_chunks_document_id"), "chunks", ["document_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_chunks_embedding_hnsw",
|
||||||
|
"chunks",
|
||||||
|
["embedding"],
|
||||||
|
unique=False,
|
||||||
|
postgresql_using="hnsw",
|
||||||
|
postgresql_ops={"embedding": "vector_cosine_ops"},
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_chunks_tsv", "chunks", ["tsv"], unique=False, postgresql_using="gin"
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"conversations",
|
||||||
|
sa.Column(
|
||||||
|
"mode",
|
||||||
|
sa.Enum(
|
||||||
|
"query",
|
||||||
|
"insight",
|
||||||
|
name="conversationmode",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum(
|
||||||
|
"active",
|
||||||
|
"completed",
|
||||||
|
"abandoned",
|
||||||
|
name="conversationstatus",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_conversations_user_id"), "conversations", ["user_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"doc_permissions",
|
||||||
|
sa.Column("document_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("department_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"level",
|
||||||
|
sa.Enum("read", name="permissionlevel", native_enum=False, length=32),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["department_id"], ["departments.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("document_id", "department_id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"messages",
|
||||||
|
sa.Column("conversation_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.Enum(
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"system",
|
||||||
|
name="messagerole",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["conversation_id"], ["conversations.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_messages_conversation_id"),
|
||||||
|
"messages",
|
||||||
|
["conversation_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f("ix_messages_conversation_id"), table_name="messages")
|
||||||
|
op.drop_table("messages")
|
||||||
|
op.drop_table("doc_permissions")
|
||||||
|
op.drop_index(op.f("ix_conversations_user_id"), table_name="conversations")
|
||||||
|
op.drop_table("conversations")
|
||||||
|
op.drop_index("ix_chunks_tsv", table_name="chunks", postgresql_using="gin")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_chunks_embedding_hnsw",
|
||||||
|
table_name="chunks",
|
||||||
|
postgresql_using="hnsw",
|
||||||
|
postgresql_ops={"embedding": "vector_cosine_ops"},
|
||||||
|
)
|
||||||
|
op.drop_index(op.f("ix_chunks_document_id"), table_name="chunks")
|
||||||
|
op.drop_table("chunks")
|
||||||
|
op.drop_table("documents")
|
||||||
|
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
|
||||||
|
op.drop_table("auth_sessions")
|
||||||
|
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.drop_table("templates")
|
||||||
|
op.drop_index("ix_jobs_status_run_after", table_name="jobs")
|
||||||
|
op.drop_table("jobs")
|
||||||
|
op.drop_table("departments")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""builtin help documents
|
||||||
|
|
||||||
|
Revision ID: 717591478a2a
|
||||||
|
Revises: 15bc390bcabb
|
||||||
|
Create Date: 2026-07-20 11:28:26.085209
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "717591478a2a"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "15bc390bcabb"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.add_column(
|
||||||
|
"documents",
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_column("documents", "is_builtin")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""llm settings
|
||||||
|
|
||||||
|
Revision ID: 72ef34f36387
|
||||||
|
Revises: 717591478a2a
|
||||||
|
Create Date: 2026-07-20 12:58:00.212399
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "72ef34f36387"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "717591478a2a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.create_table(
|
||||||
|
"llm_settings",
|
||||||
|
sa.Column("role", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("base_url", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("model", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("api_key", sa.Text(), nullable=True),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("role"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_table("llm_settings")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""user locale, drop dismissed hints
|
||||||
|
|
||||||
|
Revision ID: 8c31d0a4e7b2
|
||||||
|
Revises: 50f054decf55
|
||||||
|
Create Date: 2026-07-20 16:02:10.114872
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8c31d0a4e7b2"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "50f054decf55"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# The first-contact hints were removed: a dismissable callout that has to
|
||||||
|
# be explained ("what hints?") is not guidance. Guidance that matters is
|
||||||
|
# now static text at the place it belongs.
|
||||||
|
op.drop_column("users", "dismissed_hints")
|
||||||
|
# NULL = follow the browser's Accept-Language; a value pins the interface
|
||||||
|
# language for this person on every device.
|
||||||
|
op.add_column("users", sa.Column("locale", sa.String(length=5), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_column("users", "locale")
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column(
|
||||||
|
"dismissed_hints",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default="[]",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""templates are never builtin
|
||||||
|
|
||||||
|
Revision ID: 9f4a71c60d38
|
||||||
|
Revises: 8c31d0a4e7b2
|
||||||
|
Create Date: 2026-07-20 16:41:03.529117
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9f4a71c60d38"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "8c31d0a4e7b2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# templates/ became a catalog of blueprints that an admin adds from,
|
||||||
|
# rather than content re-imported over the customer's rows on every
|
||||||
|
# start. Nothing in this table is read-only any more, so the flag that
|
||||||
|
# marked it has no meaning left. (documents.is_builtin is untouched —
|
||||||
|
# the help pages really are the product's own.)
|
||||||
|
op.drop_column("templates", "is_builtin")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.add_column(
|
||||||
|
"templates",
|
||||||
|
sa.Column(
|
||||||
|
"is_builtin",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.false(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""llm settings field provenance
|
||||||
|
|
||||||
|
Revision ID: a71e3c92fd45
|
||||||
|
Revises: 9f4a71c60d38
|
||||||
|
Create Date: 2026-07-20 18:07:44.902113
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a71e3c92fd45"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "9f4a71c60d38"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_FIELDS = ("base_url", "model", "api_key")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# The table changed meaning: it used to hold sparse per-field overrides
|
||||||
|
# on top of the environment, and now holds the full configuration,
|
||||||
|
# seeded from the environment once at first start.
|
||||||
|
for field in _FIELDS:
|
||||||
|
op.add_column(
|
||||||
|
"llm_settings",
|
||||||
|
sa.Column(
|
||||||
|
f"{field}_from_env",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.false(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The table changed meaning: it used to hold sparse per-field overrides
|
||||||
|
# on top of the environment, and now holds the full configuration,
|
||||||
|
# seeded from the environment at startup. Pre-existing rows are dev
|
||||||
|
# leftovers — nothing is in production yet, so they are dropped rather
|
||||||
|
# than translated, and `bootstrap_llm_settings()` recreates them from
|
||||||
|
# `.env` on the next start.
|
||||||
|
op.execute("DELETE FROM llm_settings")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
for field in _FIELDS:
|
||||||
|
op.drop_column("llm_settings", f"{field}_from_env")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Review requests: an open question about a document, not a status
|
||||||
|
|
||||||
|
Publishing becomes the author's own action, so `pending_approval` disappears
|
||||||
|
and the delegated-approver column with it. What replaces both is a request
|
||||||
|
that can sit on a draft OR on a published document: "please check this", with
|
||||||
|
the question attached.
|
||||||
|
|
||||||
|
Revision ID: b8e14d7c05a3
|
||||||
|
Revises: f3c8d5a92b47
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "b8e14d7c05a3"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f3c8d5a92b47"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"review_requests",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("document_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("requester_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("reviewer_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("question", sa.Text(), nullable=True),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("resolved_by_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["requester_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["resolved_by_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_review_requests_document_id", "review_requests", ["document_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_review_requests_reviewer_id", "review_requests", ["reviewer_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# A document waiting for approval is simply an unpublished draft now; the
|
||||||
|
# ask that used to be implied by the status is an explicit request.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE documents SET status = 'draft' WHERE status = 'pending_approval'"
|
||||||
|
)
|
||||||
|
op.drop_column("documents", "reviewer_id")
|
||||||
|
|
||||||
|
# The audit trail keeps its shape: approving WAS publishing, and
|
||||||
|
# submitting has no counterpart in a world where the author publishes.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE document_events SET action = 'published' WHERE action = 'approved'"
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM document_events WHERE action = 'submitted'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("documents", sa.Column("reviewer_id", sa.Uuid(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"documents_reviewer_id_fkey",
|
||||||
|
"documents",
|
||||||
|
"users",
|
||||||
|
["reviewer_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.drop_index("ix_review_requests_reviewer_id", table_name="review_requests")
|
||||||
|
op.drop_index("ix_review_requests_document_id", table_name="review_requests")
|
||||||
|
op.drop_table("review_requests")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""document reviewer
|
||||||
|
|
||||||
|
An author can delegate approval: `documents.reviewer_id` names the user asked
|
||||||
|
to review a pending document. Nullable (self-approval leaves it null), SET NULL
|
||||||
|
so a document survives the reviewer leaving.
|
||||||
|
|
||||||
|
Revision ID: c4f7a1b2e9d3
|
||||||
|
Revises: a71e3c92fd45
|
||||||
|
Create Date: 2026-07-22 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c4f7a1b2e9d3"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "a71e3c92fd45"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.add_column("documents", sa.Column("reviewer_id", sa.Uuid(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"documents_reviewer_id_fkey",
|
||||||
|
"documents",
|
||||||
|
"users",
|
||||||
|
["reviewer_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_constraint("documents_reviewer_id_fkey", "documents", type_="foreignkey")
|
||||||
|
op.drop_column("documents", "reviewer_id")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""document events audit trail
|
||||||
|
|
||||||
|
An append-only history of who changed or reviewed a document, and when.
|
||||||
|
Content-bearing events (created / edited) snapshot the Markdown source of truth
|
||||||
|
so a past version can be viewed or diffed; the disposable chunks are never
|
||||||
|
snapshotted. `actor_id` is SET NULL so the trail outlives its actor's account;
|
||||||
|
events cascade with their document.
|
||||||
|
|
||||||
|
Revision ID: d5a9c1e3b7f2
|
||||||
|
Revises: c4f7a1b2e9d3
|
||||||
|
Create Date: 2026-07-22 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "d5a9c1e3b7f2"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "c4f7a1b2e9d3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.create_table(
|
||||||
|
"document_events",
|
||||||
|
sa.Column("document_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"action",
|
||||||
|
sa.Enum(
|
||||||
|
"created",
|
||||||
|
"edited",
|
||||||
|
"published",
|
||||||
|
"archived",
|
||||||
|
"visibility_changed",
|
||||||
|
"review_requested",
|
||||||
|
"review_resolved",
|
||||||
|
name="documenteventaction",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("content_md", sa.Text(), nullable=True),
|
||||||
|
sa.Column("title", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"visibility",
|
||||||
|
sa.Enum(
|
||||||
|
"public",
|
||||||
|
"department",
|
||||||
|
"restricted",
|
||||||
|
name="documentvisibility",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["actor_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_document_events_document_id"),
|
||||||
|
"document_events",
|
||||||
|
["document_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_index(op.f("ix_document_events_document_id"), table_name="document_events")
|
||||||
|
op.drop_table("document_events")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""prompt settings
|
||||||
|
|
||||||
|
Admin overrides for shipped system prompts. A row exists only when an admin has
|
||||||
|
changed a prompt from its code default; `content` is the full replacement text.
|
||||||
|
|
||||||
|
Revision ID: f3c8d5a92b47
|
||||||
|
Revises: e7b2c4a1f6d9
|
||||||
|
Create Date: 2026-07-22 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f3c8d5a92b47"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "d5a9c1e3b7f2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.create_table(
|
||||||
|
"prompt_settings",
|
||||||
|
sa.Column("key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_table("prompt_settings")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api import (
|
||||||
|
account,
|
||||||
|
admin,
|
||||||
|
auth,
|
||||||
|
authoring,
|
||||||
|
conversations,
|
||||||
|
departments,
|
||||||
|
documents,
|
||||||
|
people,
|
||||||
|
templates,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api")
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(account.router)
|
||||||
|
api_router.include_router(admin.router)
|
||||||
|
api_router.include_router(conversations.router)
|
||||||
|
api_router.include_router(departments.router)
|
||||||
|
api_router.include_router(documents.router)
|
||||||
|
api_router.include_router(authoring.router)
|
||||||
|
api_router.include_router(people.router)
|
||||||
|
api_router.include_router(templates.router)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Self-service account actions.
|
||||||
|
|
||||||
|
Separate from `auth.py` (login/logout/me) and from `admin.py`: this is what
|
||||||
|
a user may change about themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_auth_session, get_current_user
|
||||||
|
from app.auth.passwords import hash_password, verify_password
|
||||||
|
from app.auth.sessions import revoke_user_sessions
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import AuthSession, Document, DocumentStatus, Template, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/account", tags=["account"])
|
||||||
|
logger = logging.getLogger("pablan.account")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalePreference(BaseModel):
|
||||||
|
"""The languages the interface ships in — the frontend bundles must
|
||||||
|
cover exactly these."""
|
||||||
|
|
||||||
|
# null = follow the browser's Accept-Language again.
|
||||||
|
locale: Literal["de", "en"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=200)
|
||||||
|
new_password: str = Field(min_length=8, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password", status_code=204)
|
||||||
|
async def change_password(
|
||||||
|
body: PasswordChange,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AuthSession, Depends(get_current_auth_session)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
"""Change your own password, proving the current one first.
|
||||||
|
|
||||||
|
Every other session of this user is revoked — a
|
||||||
|
password change is how someone reacts to a suspected compromise, so
|
||||||
|
other devices must lose access. The session doing the change survives,
|
||||||
|
otherwise the user is thrown out of the app they are standing in.
|
||||||
|
"""
|
||||||
|
if not verify_password(user.password_hash, body.current_password):
|
||||||
|
raise ApiError(
|
||||||
|
403, "Current password is incorrect.", "invalid_current_password"
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = hash_password(body.new_password)
|
||||||
|
await revoke_user_sessions(db, user.id, keep_session_id=session.id)
|
||||||
|
await db.commit()
|
||||||
|
# Metadata only — never the password, not even its length.
|
||||||
|
logger.info("password changed", extra={"event": "password_change"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/locale", status_code=204)
|
||||||
|
async def set_locale(
|
||||||
|
body: LocalePreference,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
"""Pin the interface language, or clear it to follow the browser again.
|
||||||
|
|
||||||
|
The backend only stores the choice — it never renders UI-language
|
||||||
|
strings (see docs/architecture.md); the frontend does the translating.
|
||||||
|
"""
|
||||||
|
user.locale = body.locale
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# The starter catalog's blueprint about a PERSON (role, specialities, who to
|
||||||
|
# ask) rather than a topic. What "the document about you" is made from, named
|
||||||
|
# once here so the frontend does not have to know a blueprint id.
|
||||||
|
PERSONAL_BLUEPRINT = "person"
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalDocument(BaseModel):
|
||||||
|
"""The caller's own document about themselves.
|
||||||
|
|
||||||
|
Either they wrote one — then it is opened and edited like any other
|
||||||
|
document — or they have not, and `template_id` says what to start it from.
|
||||||
|
Both are null when the blueprint is not in this instance and nothing was
|
||||||
|
written yet; the frontend falls back to the ordinary template picker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document_id: uuid.UUID | None = None
|
||||||
|
title: str | None = None
|
||||||
|
status: DocumentStatus | None = None
|
||||||
|
template_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/document")
|
||||||
|
async def personal_document(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> PersonalDocument:
|
||||||
|
"""What the profile page needs to show: your document about yourself, or
|
||||||
|
the way to start it. Self-scoped, and authorship is the whole rule — a
|
||||||
|
document someone else wrote about you is not this."""
|
||||||
|
document = (
|
||||||
|
await db.execute(
|
||||||
|
select(Document)
|
||||||
|
.where(
|
||||||
|
Document.author_id == user.id,
|
||||||
|
Document.meta["template"].astext == PERSONAL_BLUEPRINT,
|
||||||
|
)
|
||||||
|
# The newest, if a second one was ever started.
|
||||||
|
.order_by(Document.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
template_id = (
|
||||||
|
await db.execute(
|
||||||
|
select(Template.id).where(
|
||||||
|
Template.config["id"].astext == PERSONAL_BLUEPRINT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if document is None:
|
||||||
|
return PersonalDocument(template_id=template_id)
|
||||||
|
return PersonalDocument(
|
||||||
|
document_id=document.id,
|
||||||
|
title=document.title,
|
||||||
|
status=document.status,
|
||||||
|
template_id=template_id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""The admin API: everything only an administrator may do.
|
||||||
|
|
||||||
|
Split by what is being administered — model endpoints, prompts, users,
|
||||||
|
departments, and the metrics snapshot. The admin gate is not repeated per
|
||||||
|
endpoint: it sits on the shared router in `routing.py`, so a new route in any
|
||||||
|
of these modules is admin-only whether or not its author thought about it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.admin import departments, llm, observability, prompts, users
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(llm.router)
|
||||||
|
router.include_router(prompts.router)
|
||||||
|
router.include_router(users.router)
|
||||||
|
router.include_router(departments.router)
|
||||||
|
router.include_router(observability.router)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Departments.
|
||||||
|
|
||||||
|
Deliberately unpaged: an SME has a handful of them. The interesting rule is
|
||||||
|
deletion — a department's read grants CASCADE away with it, and that access
|
||||||
|
loss is invisible, so it has to be confirmed rather than discovered later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.admin.routing import admin_router
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Department, DocPermission, Document, User
|
||||||
|
|
||||||
|
router = admin_router()
|
||||||
|
|
||||||
|
NAME_TAKEN = ("Department name already exists.", "name_taken")
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDepartmentOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/departments")
|
||||||
|
async def create_department(
|
||||||
|
body: DepartmentCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AdminDepartmentOut:
|
||||||
|
department = Department(name=body.name.strip())
|
||||||
|
db.add(department)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise ApiError(409, *NAME_TAKEN) from None
|
||||||
|
return AdminDepartmentOut.model_validate(department)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/departments/{department_id}")
|
||||||
|
async def rename_department(
|
||||||
|
department_id: uuid.UUID,
|
||||||
|
body: DepartmentCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AdminDepartmentOut:
|
||||||
|
department = await db.get(Department, department_id)
|
||||||
|
if department is None:
|
||||||
|
raise ApiError(404, "Department not found.", "not_found")
|
||||||
|
department.name = body.name.strip()
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise ApiError(409, *NAME_TAKEN) from None
|
||||||
|
return AdminDepartmentOut.model_validate(department)
|
||||||
|
|
||||||
|
|
||||||
|
async def _still_in_use(db: AsyncSession, department_id: uuid.UUID) -> bool:
|
||||||
|
"""Members, owned documents or read grants — anything whose access changes
|
||||||
|
when this department disappears."""
|
||||||
|
for count in (
|
||||||
|
select(func.count(User.id)).where(User.department_id == department_id),
|
||||||
|
select(func.count(Document.id)).where(Document.department_id == department_id),
|
||||||
|
select(func.count())
|
||||||
|
.select_from(DocPermission)
|
||||||
|
.where(DocPermission.department_id == department_id),
|
||||||
|
):
|
||||||
|
if (await db.execute(count)).scalar_one():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/departments/{department_id}", status_code=204)
|
||||||
|
async def delete_department(
|
||||||
|
department_id: uuid.UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
confirm: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a department. Members and owned documents survive with their
|
||||||
|
`department_id` set to NULL, but this department's `doc_permissions` grants
|
||||||
|
CASCADE away — silently dropping the shared read access they gave. Because
|
||||||
|
that access loss is invisible, deleting a department that still has members,
|
||||||
|
owned documents or grants requires `?confirm=true` (409 `department_in_use`
|
||||||
|
otherwise)."""
|
||||||
|
department = await db.get(Department, department_id)
|
||||||
|
if department is None:
|
||||||
|
raise ApiError(404, "Department not found.", "not_found")
|
||||||
|
if not confirm and await _still_in_use(db, department_id):
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"This department is still in use; deleting it drops that access.",
|
||||||
|
"department_in_use",
|
||||||
|
)
|
||||||
|
await db.delete(department)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Model endpoints: test them, configure them, ask what they serve.
|
||||||
|
|
||||||
|
Configuration is bootstrapped from `.env` at first start and lives in the DB
|
||||||
|
afterwards, so this is where an admin changes an endpoint without a restart.
|
||||||
|
Every call runs server-side: the API key must never reach the browser, and the
|
||||||
|
browser must never reach the model endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.admin.routing import admin_router
|
||||||
|
from app.db import get_db
|
||||||
|
from app.llm.client import (
|
||||||
|
Role,
|
||||||
|
chat_stream,
|
||||||
|
embed,
|
||||||
|
list_models,
|
||||||
|
probe,
|
||||||
|
rebuild_clients,
|
||||||
|
role_config,
|
||||||
|
)
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.llm.overrides import env_defaults, load_config
|
||||||
|
from app.models import LLMSetting
|
||||||
|
|
||||||
|
router = admin_router()
|
||||||
|
|
||||||
|
ROLES: tuple[Role, ...] = ("chat", "utility", "embedding")
|
||||||
|
|
||||||
|
|
||||||
|
class LLMSettingUpdate(BaseModel):
|
||||||
|
# None means "leave as is"; the reset_* flags restore the value the
|
||||||
|
# environment currently states (same sentinel pattern as
|
||||||
|
# UserUpdate.clear_department).
|
||||||
|
base_url: str | None = Field(default=None, max_length=500)
|
||||||
|
model: str | None = Field(default=None, max_length=200)
|
||||||
|
api_key: str | None = Field(default=None, max_length=500)
|
||||||
|
reset_base_url: bool | None = None
|
||||||
|
reset_model: bool | None = None
|
||||||
|
reset_api_key: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LLMRoleStatus(BaseModel):
|
||||||
|
role: Role
|
||||||
|
ok: bool
|
||||||
|
base_url: str
|
||||||
|
model: str
|
||||||
|
latency_ms: int | None
|
||||||
|
# Why it failed: `code` is phrased by the frontend, `error` is the
|
||||||
|
# sanitized technical detail (exception class + role) for the admin.
|
||||||
|
code: str | None
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTestResponse(BaseModel):
|
||||||
|
roles: list[LLMRoleStatus]
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTestRequest(BaseModel):
|
||||||
|
"""Optional candidate config: test an endpoint BEFORE saving it."""
|
||||||
|
|
||||||
|
role: Role | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _ping_role(
|
||||||
|
role: Role, candidate: LLMSettingUpdate | None = None
|
||||||
|
) -> LLMRoleStatus:
|
||||||
|
"""Ping a role — either its effective config, or a candidate an admin is
|
||||||
|
about to save."""
|
||||||
|
base_url, _, model = role_config(role)
|
||||||
|
if candidate is not None:
|
||||||
|
base_url = candidate.base_url or base_url
|
||||||
|
model = candidate.model or model
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
if candidate is None:
|
||||||
|
if role == "embedding":
|
||||||
|
await embed(["ping"])
|
||||||
|
else:
|
||||||
|
# Drain the (max_tokens=1) stream so the call records as
|
||||||
|
# "ok", not "aborted".
|
||||||
|
async for _ in chat_stream(
|
||||||
|
[{"role": "user", "content": "ping"}], role=role, max_tokens=1
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await probe(
|
||||||
|
role,
|
||||||
|
base_url=candidate.base_url,
|
||||||
|
api_key=candidate.api_key,
|
||||||
|
model=candidate.model,
|
||||||
|
)
|
||||||
|
return LLMRoleStatus(
|
||||||
|
role=role,
|
||||||
|
ok=True,
|
||||||
|
base_url=base_url,
|
||||||
|
model=model,
|
||||||
|
latency_ms=round((time.monotonic() - started) * 1000),
|
||||||
|
code=None,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
except LLMError as exc:
|
||||||
|
return LLMRoleStatus(
|
||||||
|
role=role,
|
||||||
|
ok=False,
|
||||||
|
base_url=base_url,
|
||||||
|
model=model,
|
||||||
|
latency_ms=None,
|
||||||
|
code=exc.code,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/llm/test")
|
||||||
|
async def llm_test(body: LLMTestRequest | None = None) -> LLMTestResponse:
|
||||||
|
"""First-line support tool: pings all three roles, or one candidate
|
||||||
|
configuration without persisting anything."""
|
||||||
|
if body is not None and body.role is not None:
|
||||||
|
candidate = LLMSettingUpdate(
|
||||||
|
base_url=body.base_url, model=body.model, api_key=body.api_key
|
||||||
|
)
|
||||||
|
return LLMTestResponse(roles=[await _ping_role(body.role, candidate)])
|
||||||
|
results = await asyncio.gather(*(_ping_role(role) for role in ROLES))
|
||||||
|
return LLMTestResponse(roles=list(results))
|
||||||
|
|
||||||
|
|
||||||
|
class LLMSettingOut(BaseModel):
|
||||||
|
"""Stored config for one role.
|
||||||
|
|
||||||
|
The api_key is NEVER returned — only whether one is set, and where each
|
||||||
|
field's value came from. `*_from_env` drives the per-field
|
||||||
|
"taken from .env" / "changed here" label and the reset action; it is
|
||||||
|
provenance, not a fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
role: Role
|
||||||
|
base_url: str
|
||||||
|
model: str
|
||||||
|
base_url_from_env: bool
|
||||||
|
model_from_env: bool
|
||||||
|
api_key_set: bool
|
||||||
|
api_key_from_env: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def _row_for(db: AsyncSession, role: Role) -> LLMSetting:
|
||||||
|
row = (
|
||||||
|
await db.execute(select(LLMSetting).where(LLMSetting.role == role))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
# Only reachable if bootstrap never ran (a test, or a role added
|
||||||
|
# after install). Seed it from the environment, same as bootstrap.
|
||||||
|
defaults = env_defaults(role)
|
||||||
|
row = LLMSetting(
|
||||||
|
role=role,
|
||||||
|
base_url=defaults.base_url or None,
|
||||||
|
model=defaults.model or None,
|
||||||
|
api_key=defaults.api_key or None,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _setting_out(row: LLMSetting) -> LLMSettingOut:
|
||||||
|
return LLMSettingOut(
|
||||||
|
role=row.role, # type: ignore[arg-type]
|
||||||
|
base_url=row.base_url or "",
|
||||||
|
model=row.model or "",
|
||||||
|
base_url_from_env=row.base_url_from_env,
|
||||||
|
model_from_env=row.model_from_env,
|
||||||
|
api_key_set=bool(row.api_key),
|
||||||
|
api_key_from_env=row.api_key_from_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm/settings")
|
||||||
|
async def llm_settings(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[LLMSettingOut]:
|
||||||
|
return [_setting_out(await _row_for(db, role)) for role in ROLES]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/llm/settings/{role}")
|
||||||
|
async def update_llm_setting(
|
||||||
|
role: Role,
|
||||||
|
body: LLMSettingUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> LLMSettingOut:
|
||||||
|
"""Change a role's stored config and apply it without a restart.
|
||||||
|
|
||||||
|
Writing a field marks it as changed here; resetting it writes back what
|
||||||
|
`.env` currently says and marks it as coming from the environment
|
||||||
|
again.
|
||||||
|
"""
|
||||||
|
row = await _row_for(db, role)
|
||||||
|
defaults = env_defaults(role)
|
||||||
|
|
||||||
|
if body.reset_base_url:
|
||||||
|
row.base_url, row.base_url_from_env = defaults.base_url or None, True
|
||||||
|
elif body.base_url is not None:
|
||||||
|
row.base_url, row.base_url_from_env = body.base_url or None, False
|
||||||
|
if body.reset_model:
|
||||||
|
row.model, row.model_from_env = defaults.model or None, True
|
||||||
|
elif body.model is not None:
|
||||||
|
row.model, row.model_from_env = body.model or None, False
|
||||||
|
if body.reset_api_key:
|
||||||
|
row.api_key, row.api_key_from_env = defaults.api_key or None, True
|
||||||
|
elif body.api_key:
|
||||||
|
row.api_key, row.api_key_from_env = body.api_key, False
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await load_config(db)
|
||||||
|
# The cached OpenAI clients hold the old base_url and key.
|
||||||
|
rebuild_clients()
|
||||||
|
return _setting_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMModelsRequest(BaseModel):
|
||||||
|
"""Optional candidate endpoint, so an admin can list the models of a
|
||||||
|
URL they have typed but not saved."""
|
||||||
|
|
||||||
|
base_url: str | None = Field(default=None, max_length=500)
|
||||||
|
api_key: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMModelsResponse(BaseModel):
|
||||||
|
models: list[str]
|
||||||
|
# False when the endpoint does not implement GET /v1/models — the UI
|
||||||
|
# keeps its free-text field instead of showing an error.
|
||||||
|
supported: bool
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/llm/models/{role}")
|
||||||
|
async def llm_models(
|
||||||
|
role: Role, body: LLMModelsRequest | None = None
|
||||||
|
) -> LLMModelsResponse:
|
||||||
|
"""List what the endpoint serves, so the model field can be a dropdown."""
|
||||||
|
try:
|
||||||
|
models = await list_models(
|
||||||
|
role,
|
||||||
|
base_url=(body.base_url if body else None) or None,
|
||||||
|
api_key=(body.api_key if body else None) or None,
|
||||||
|
)
|
||||||
|
except LLMError as exc:
|
||||||
|
# A 404 means "this server has no /v1/models", which is common
|
||||||
|
# enough to be a normal outcome rather than a failure to report.
|
||||||
|
supported = exc.status_code != 404
|
||||||
|
return LLMModelsResponse(
|
||||||
|
models=[],
|
||||||
|
supported=supported,
|
||||||
|
error=exc.cause_type if supported else None,
|
||||||
|
)
|
||||||
|
return LLMModelsResponse(models=models, supported=True)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""What the running process has counted so far."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.admin.routing import admin_router
|
||||||
|
from app.metrics import metrics
|
||||||
|
|
||||||
|
router = admin_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
async def metrics_snapshot() -> dict[str, Any]:
|
||||||
|
"""The in-process metrics registry as JSON.
|
||||||
|
|
||||||
|
Per process by design (the app runs one worker), and admin-only: the
|
||||||
|
counters name models and durations, which is operational detail rather
|
||||||
|
than something to expose publicly.
|
||||||
|
"""
|
||||||
|
return metrics.snapshot()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Editing the shipped system prompts.
|
||||||
|
|
||||||
|
Every prompt has a code default; a row exists only where an admin changed one,
|
||||||
|
and resetting deletes that row rather than storing a copy of the default. The
|
||||||
|
UI labels the keys from its own messages, so nothing user-facing is worded here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.admin.routing import admin_router
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import PromptSetting
|
||||||
|
from app.prompts.defaults import DEFAULTS, PROMPT_KEYS
|
||||||
|
from app.prompts.overrides import get_prompt, is_overridden
|
||||||
|
from app.prompts.overrides import load_config as load_prompt_config
|
||||||
|
|
||||||
|
router = admin_router()
|
||||||
|
|
||||||
|
|
||||||
|
class PromptSettingOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
# The effective text: an admin override if present, else the code default.
|
||||||
|
content: str
|
||||||
|
# True when no override exists, i.e. the shipped default is in force.
|
||||||
|
is_default: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PromptSettingUpdate(BaseModel):
|
||||||
|
# New override text, or `reset` to drop the override back to the default.
|
||||||
|
content: str | None = None
|
||||||
|
reset: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompts")
|
||||||
|
async def prompt_settings(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[PromptSettingOut]:
|
||||||
|
"""Every editable system prompt with its effective text and whether it is
|
||||||
|
still the shipped default."""
|
||||||
|
rows = {row.key: row for row in (await db.execute(select(PromptSetting))).scalars()}
|
||||||
|
return [
|
||||||
|
PromptSettingOut(
|
||||||
|
key=key,
|
||||||
|
content=rows[key].content if key in rows else DEFAULTS[key],
|
||||||
|
is_default=key not in rows,
|
||||||
|
)
|
||||||
|
for key in PROMPT_KEYS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/prompts/{key}")
|
||||||
|
async def update_prompt_setting(
|
||||||
|
key: str,
|
||||||
|
body: PromptSettingUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> PromptSettingOut:
|
||||||
|
"""Override a system prompt (applied without a restart) or reset it to the
|
||||||
|
shipped default. Resetting deletes the override row."""
|
||||||
|
if key not in DEFAULTS:
|
||||||
|
raise ApiError(404, "Unknown prompt.", "not_found")
|
||||||
|
row = (
|
||||||
|
await db.execute(select(PromptSetting).where(PromptSetting.key == key))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if body.reset:
|
||||||
|
if row is not None:
|
||||||
|
await db.delete(row)
|
||||||
|
elif body.content is not None:
|
||||||
|
content = body.content.strip()
|
||||||
|
if not content:
|
||||||
|
raise ApiError(422, "A prompt cannot be empty.", "empty_prompt")
|
||||||
|
if row is None:
|
||||||
|
db.add(PromptSetting(key=key, content=content))
|
||||||
|
else:
|
||||||
|
row.content = content
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await load_prompt_config(db)
|
||||||
|
return PromptSettingOut(
|
||||||
|
key=key, content=get_prompt(key), is_default=not is_overridden(key)
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""The one router constructor the admin modules share.
|
||||||
|
|
||||||
|
The admin gate lives here rather than on each endpoint: a route added to any
|
||||||
|
of these modules is admin-only by construction, and there is no way to forget
|
||||||
|
the dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth.deps import require_admin
|
||||||
|
|
||||||
|
|
||||||
|
def admin_router() -> APIRouter:
|
||||||
|
return APIRouter(
|
||||||
|
prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]
|
||||||
|
)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""User accounts.
|
||||||
|
|
||||||
|
An admin creates people, corrects their details, and offboards them. The two
|
||||||
|
guardrails here exist because an admin who locks themselves out has no second
|
||||||
|
admin to call: you cannot change your own role, and you cannot delete yourself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Query
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.admin.routing import admin_router
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.passwords import hash_password
|
||||||
|
from app.auth.sessions import revoke_user_sessions
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Department, User, UserRole
|
||||||
|
|
||||||
|
router = admin_router()
|
||||||
|
|
||||||
|
EMAIL_TAKEN = ("Email address already in use.", "email_taken")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
role: UserRole
|
||||||
|
department_id: uuid.UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserPage(BaseModel):
|
||||||
|
items: list[AdminUserOut]
|
||||||
|
total: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
role: UserRole = UserRole.member
|
||||||
|
department_id: uuid.UUID | None = None
|
||||||
|
password: str = Field(min_length=8, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
# Correcting a typo in an address should not mean deleting the person
|
||||||
|
# and losing everything hanging off their id. Plain `str` like
|
||||||
|
# UserCreate: `EmailStr` would pull in email-validator, a dependency we
|
||||||
|
# deliberately avoid, and the format is already guarded by the browser's
|
||||||
|
# type="email" and the column's unique constraint.
|
||||||
|
email: str | None = Field(default=None, min_length=3, max_length=320)
|
||||||
|
role: UserRole | None = None
|
||||||
|
department_id: uuid.UUID | None = None
|
||||||
|
clear_department: bool | None = None
|
||||||
|
# Setting a password IS the reset mechanism.
|
||||||
|
password: str | None = Field(default=None, min_length=8, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email(email: str) -> str:
|
||||||
|
"""The same normalisation on create and update, or an edited address would
|
||||||
|
stop matching what login looks up."""
|
||||||
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def _department_must_exist(
|
||||||
|
db: AsyncSession, department_id: uuid.UUID | None
|
||||||
|
) -> None:
|
||||||
|
if department_id is not None and await db.get(Department, department_id) is None:
|
||||||
|
raise ApiError(404, "Department not found.", "not_found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
search: str | None = Query(None, max_length=200),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25, ge=1, le=100),
|
||||||
|
) -> AdminUserPage:
|
||||||
|
"""Paged, because the admin screen is the one place that scales with
|
||||||
|
headcount: a company with two hundred employees would otherwise get two
|
||||||
|
hundred rows and no way to find anyone.
|
||||||
|
|
||||||
|
Departments deliberately stay unpaged: an SME has a handful, and a
|
||||||
|
pager over five rows is furniture.
|
||||||
|
"""
|
||||||
|
filters = []
|
||||||
|
if search:
|
||||||
|
needle = f"%{search.strip()}%"
|
||||||
|
filters.append(or_(User.email.ilike(needle), User.name.ilike(needle)))
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count(User.id)).where(*filters))).scalar_one()
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(User.email)
|
||||||
|
.offset((page - 1) * per_page)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return AdminUserPage(
|
||||||
|
items=[AdminUserOut.model_validate(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
per_page=per_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AdminUserOut:
|
||||||
|
await _department_must_exist(db, body.department_id)
|
||||||
|
user = User(
|
||||||
|
email=_normalize_email(body.email),
|
||||||
|
name=body.name,
|
||||||
|
role=body.role,
|
||||||
|
department_id=body.department_id,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise ApiError(409, *EMAIL_TAKEN) from None
|
||||||
|
return AdminUserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
body: UserUpdate,
|
||||||
|
admin: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AdminUserOut:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise ApiError(404, "User not found.", "not_found")
|
||||||
|
if user.id == admin.id and body.role is not None and body.role != user.role:
|
||||||
|
raise ApiError(409, "You cannot change your own role.", "self_modification")
|
||||||
|
|
||||||
|
if body.name is not None:
|
||||||
|
user.name = body.name
|
||||||
|
if body.email is not None:
|
||||||
|
user.email = _normalize_email(body.email)
|
||||||
|
if body.role is not None:
|
||||||
|
user.role = body.role
|
||||||
|
if body.clear_department:
|
||||||
|
user.department_id = None
|
||||||
|
elif body.department_id is not None:
|
||||||
|
await _department_must_exist(db, body.department_id)
|
||||||
|
user.department_id = body.department_id
|
||||||
|
if body.password is not None:
|
||||||
|
user.password_hash = hash_password(body.password)
|
||||||
|
# A password change revokes every session of that user — including
|
||||||
|
# the current one if an admin changes their own password.
|
||||||
|
await revoke_user_sessions(db, user.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise ApiError(409, *EMAIL_TAKEN) from None
|
||||||
|
return AdminUserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=204)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
admin: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
"""Offboarding: sessions and conversations cascade, documents survive
|
||||||
|
with author set to NULL."""
|
||||||
|
if user_id == admin.id:
|
||||||
|
raise ApiError(409, "You cannot delete your own account.", "self_modification")
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise ApiError(404, "User not found.", "not_found")
|
||||||
|
await db.delete(user)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.passwords import burn_verification_time, verify_password
|
||||||
|
from app.auth.sessions import (
|
||||||
|
COOKIE_NAME,
|
||||||
|
clear_session_cookie,
|
||||||
|
create_auth_session,
|
||||||
|
set_session_cookie,
|
||||||
|
)
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import AuthSession, User, UserRole
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
role: UserRole
|
||||||
|
department_id: uuid.UUID | None
|
||||||
|
# Pinned interface language, or null to follow the browser. Flows to the
|
||||||
|
# UI via /me, so no page needs its own preference fetch. Typed as the
|
||||||
|
# closed set the API accepts, so the generated client is precise too.
|
||||||
|
locale: Literal["de", "en"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(
|
||||||
|
body: LoginRequest,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> UserOut:
|
||||||
|
email = body.email.strip().lower()
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
burn_verification_time()
|
||||||
|
raise ApiError(401, "Invalid email or password.", "invalid_credentials")
|
||||||
|
if not verify_password(user.password_hash, body.password):
|
||||||
|
raise ApiError(401, "Invalid email or password.", "invalid_credentials")
|
||||||
|
|
||||||
|
session = await create_auth_session(db, user)
|
||||||
|
await db.commit()
|
||||||
|
set_session_cookie(response, session)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=204)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
raw = request.cookies.get(COOKIE_NAME)
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
session_id = None
|
||||||
|
if session_id is not None:
|
||||||
|
session = await db.get(AuthSession, session_id)
|
||||||
|
if session is not None:
|
||||||
|
await db.delete(session)
|
||||||
|
await db.commit()
|
||||||
|
clear_session_cookie(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
|
||||||
|
return UserOut.model_validate(user)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""The authoring API: the model's help while someone writes.
|
||||||
|
|
||||||
|
Three endpoints under `/documents`, all owner-scoped: refine the section at the
|
||||||
|
cursor (streamed), suggest a title for a finished draft, and suggest which
|
||||||
|
existing document a capture should extend. What they share is `grounding` — the
|
||||||
|
permission-filtered look at what the company already wrote.
|
||||||
|
|
||||||
|
`suggest-similar` has a static path and is registered before the refine module
|
||||||
|
so it cannot be read as a document id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.authoring import refine, suggest
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(suggest.router)
|
||||||
|
router.include_router(refine.router)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""What the company already wrote about this.
|
||||||
|
|
||||||
|
Before refining a section, the server looks for related published material the
|
||||||
|
author may read and hands it to the prompt as a reference — so a suggestion
|
||||||
|
stays consistent with the rest of the knowledge base instead of inventing a
|
||||||
|
parallel version of it. The same chunks are surfaced to the editor's "?"
|
||||||
|
inspector, so the author can see where a suggestion drew from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.models import User
|
||||||
|
from app.rag.chunking import HEADING_RE
|
||||||
|
from app.rag.similarity import (
|
||||||
|
CAPTURE_CONTEXT_MAX_DISTANCE,
|
||||||
|
SimilarChunk,
|
||||||
|
similar_chunks,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A section needs at least this much of its own text (beyond the heading)
|
||||||
|
# before it is worth searching the knowledge base to ground the refinement:
|
||||||
|
# a bare heading matches nothing useful and only adds noise.
|
||||||
|
MIN_CHARS = 40
|
||||||
|
TOP_K = 3
|
||||||
|
# Cap each grounding excerpt so a few long ones cannot crowd out the section.
|
||||||
|
EXCERPT_CHARS = 600
|
||||||
|
|
||||||
|
|
||||||
|
def leading_heading(section_text: str) -> str | None:
|
||||||
|
"""The heading text a section starts with, to match a template hint."""
|
||||||
|
first = section_text.lstrip().splitlines()[0] if section_text.strip() else ""
|
||||||
|
match = HEADING_RE.match(first)
|
||||||
|
return match.group(2).strip() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def reference(title: str, heading_path: str, content: str) -> str:
|
||||||
|
"""One retrieved chunk, rendered for the prompt: where it comes from, then
|
||||||
|
a bounded excerpt."""
|
||||||
|
excerpt = content.strip()
|
||||||
|
if len(excerpt) > EXCERPT_CHARS:
|
||||||
|
excerpt = excerpt[:EXCERPT_CHARS].rstrip() + " ..."
|
||||||
|
where = f'"{title}" ({heading_path})' if heading_path else f'"{title}"'
|
||||||
|
return f"From {where}:\n{excerpt}"
|
||||||
|
|
||||||
|
|
||||||
|
async def for_section(
|
||||||
|
db: AsyncSession, section_text: str, user: User, document_id: uuid.UUID
|
||||||
|
) -> list[SimilarChunk]:
|
||||||
|
"""Related knowledge for one section, permission-filtered by construction.
|
||||||
|
|
||||||
|
Returns nothing when the section is still too thin to match on, when the
|
||||||
|
current document is the only match, or when the embedding endpoint is
|
||||||
|
unavailable — the refinement then simply proceeds without grounding.
|
||||||
|
"""
|
||||||
|
query = section_text.strip()
|
||||||
|
_, _, after_heading = query.partition("\n")
|
||||||
|
body = after_heading.strip() if leading_heading(query) is not None else query
|
||||||
|
if len(body) < MIN_CHARS:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return await similar_chunks(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
user=user,
|
||||||
|
top_k=TOP_K,
|
||||||
|
max_distance=CAPTURE_CONTEXT_MAX_DISTANCE,
|
||||||
|
exclude_builtin=True,
|
||||||
|
exclude_document_id=document_id,
|
||||||
|
)
|
||||||
|
except LLMError:
|
||||||
|
return []
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Section refinement for the writing editor.
|
||||||
|
|
||||||
|
The user writes Markdown; after a pause the client asks the model to refine the
|
||||||
|
section the cursor is in. The whole document is context, but the model
|
||||||
|
regenerates ONLY that section (FIM-style), streamed back as SSE so the
|
||||||
|
suggestion appears progressively and can be aborted the moment the user resumes
|
||||||
|
typing.
|
||||||
|
|
||||||
|
The request body and the streamed response carry document text. That is fine on
|
||||||
|
this owner-scoped endpoint — the same trust boundary as
|
||||||
|
`GET /api/documents/{id}` — but nothing here logs content: metadata only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.authoring import grounding
|
||||||
|
from app.api.authoring.routing import authoring_router
|
||||||
|
from app.api.documents import readable_document, require_editor
|
||||||
|
from app.api.sse import sse
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.authoring.prompts import render_refine_prompt
|
||||||
|
from app.authoring.schema import AuthoringTemplate
|
||||||
|
from app.authoring.sections import ActiveSection, active_section, slice_lines
|
||||||
|
from app.db import get_db
|
||||||
|
from app.llm.client import NO_THINKING, chat_stream
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.models import Template, User
|
||||||
|
|
||||||
|
router = authoring_router()
|
||||||
|
logger = logging.getLogger("pablan.authoring")
|
||||||
|
|
||||||
|
|
||||||
|
class RefineRequest(BaseModel):
|
||||||
|
content_md: str = Field(max_length=100_000)
|
||||||
|
cursor_line: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _template_for(
|
||||||
|
db: AsyncSession, template_config_id: str | None
|
||||||
|
) -> AuthoringTemplate | None:
|
||||||
|
"""The blueprint a document was started from, if it still exists and still
|
||||||
|
parses — it carries the persona, the temperature and the per-section hints
|
||||||
|
that shape a refinement."""
|
||||||
|
if not template_config_id:
|
||||||
|
return None
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Template).where(Template.config["id"].astext == template_config_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return AuthoringTemplate.model_validate(row.config)
|
||||||
|
except ValidationError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/refine")
|
||||||
|
async def refine_section(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
body: RefineRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream a matured version of the section at the cursor.
|
||||||
|
|
||||||
|
SSE frames: one `section` frame with the exact line range the suggestion
|
||||||
|
replaces, then `token` frames, then `done` (or `error`)."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_editor(document, user)
|
||||||
|
|
||||||
|
section = active_section(body.content_md, body.cursor_line)
|
||||||
|
prefix, section_text, suffix = slice_lines(
|
||||||
|
body.content_md, section.start_line, section.end_line
|
||||||
|
)
|
||||||
|
meta = document.meta or {}
|
||||||
|
|
||||||
|
persona: str | None = None
|
||||||
|
hint: str | None = None
|
||||||
|
temperature = 0.4
|
||||||
|
template = await _template_for(db, meta.get("template"))
|
||||||
|
if template is not None:
|
||||||
|
persona = template.persona
|
||||||
|
temperature = template.model.temperature
|
||||||
|
heading = grounding.leading_heading(section_text)
|
||||||
|
if heading:
|
||||||
|
hint = template.hint_for(heading)
|
||||||
|
|
||||||
|
# Related, already-published knowledge the author may read. Rendered into
|
||||||
|
# the prompt as grounding, AND surfaced to the editor's "?" inspector so the
|
||||||
|
# author can see where a suggestion drew from.
|
||||||
|
chunks = await grounding.for_section(db, section_text, user, document.id)
|
||||||
|
messages = render_refine_prompt(
|
||||||
|
section_text,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
persona=persona,
|
||||||
|
hint=hint,
|
||||||
|
# Background from the chat this capture came from, if any. Like the
|
||||||
|
# document text, it travels only on this owner-scoped call and is
|
||||||
|
# never logged.
|
||||||
|
context=meta.get("context"),
|
||||||
|
knowledge=[
|
||||||
|
grounding.reference(chunk.title, chunk.heading_path, chunk.content)
|
||||||
|
for chunk in chunks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
references = [
|
||||||
|
{"title": chunk.title, "heading_path": chunk.heading_path} for chunk in chunks
|
||||||
|
]
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream_refine(messages, section, temperature, str(document.id), references),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_refine(
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
section: ActiveSection,
|
||||||
|
temperature: float,
|
||||||
|
document_id: str,
|
||||||
|
references: list[dict[str, str]],
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
started = time.monotonic()
|
||||||
|
outcome = "ok"
|
||||||
|
token_events = 0
|
||||||
|
# First: which lines "Accept" will overwrite, so the client can bind the
|
||||||
|
# suggestion to an exact range even as the model streams.
|
||||||
|
yield sse(
|
||||||
|
"section", {"start_line": section.start_line, "end_line": section.end_line}
|
||||||
|
)
|
||||||
|
# What the suggestion is grounding on (the author's own readable material) —
|
||||||
|
# titles + heading paths only, for the "?" inspector. Content-safe.
|
||||||
|
if references:
|
||||||
|
yield sse("grounding", {"references": references})
|
||||||
|
try:
|
||||||
|
async for token in chat_stream(
|
||||||
|
messages, role="chat", temperature=temperature, extra_body=NO_THINKING
|
||||||
|
):
|
||||||
|
token_events += 1
|
||||||
|
yield sse("token", {"text": token})
|
||||||
|
yield sse("done", {})
|
||||||
|
except LLMError as exc:
|
||||||
|
outcome = "error"
|
||||||
|
yield sse("error", {"code": exc.code})
|
||||||
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
|
outcome = "aborted"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
logger.info(
|
||||||
|
"refine finished",
|
||||||
|
extra={
|
||||||
|
"event": "refine",
|
||||||
|
"outcome": outcome,
|
||||||
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||||
|
"token_events": token_events,
|
||||||
|
"document_id": document_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""The one router constructor the authoring modules share.
|
||||||
|
|
||||||
|
The prefix is `/documents`: authoring acts ON a document the caller owns, so
|
||||||
|
its endpoints live under the document they belong to rather than in a
|
||||||
|
namespace of their own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def authoring_router() -> APIRouter:
|
||||||
|
return APIRouter(prefix="/documents", tags=["authoring"])
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Two small suggestions the editor asks for: a title, and what to extend.
|
||||||
|
|
||||||
|
Both are one-shot calls rather than streams, and both are advisory: the author
|
||||||
|
keeps the generic title or starts a new document if the suggestion does not fit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.authoring.routing import authoring_router
|
||||||
|
from app.api.documents import readable_document, require_editor
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.authoring.context import summarize_conversation
|
||||||
|
from app.authoring.prompts import render_title_prompt
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.llm.client import NO_THINKING, chat_json
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.models import Conversation, User
|
||||||
|
from app.rag.similarity import CAPTURE_CONTEXT_MAX_DISTANCE, similar_documents
|
||||||
|
|
||||||
|
router = authoring_router()
|
||||||
|
|
||||||
|
|
||||||
|
class TitleSuggestion(BaseModel):
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/suggest-title")
|
||||||
|
async def suggest_title(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TitleSuggestion:
|
||||||
|
"""Suggest a concise title from the document's content (review step for a
|
||||||
|
new document). Owner-scoped; content in, title out, nothing logged."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_editor(document, user)
|
||||||
|
if not document.content_md.strip():
|
||||||
|
return TitleSuggestion(title=document.title)
|
||||||
|
try:
|
||||||
|
return await chat_json(
|
||||||
|
render_title_prompt(document.content_md),
|
||||||
|
TitleSuggestion,
|
||||||
|
extra_body=NO_THINKING,
|
||||||
|
)
|
||||||
|
except LLMError as exc:
|
||||||
|
raise ApiError(503, "The model endpoint did not answer.", exc.code) from None
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestSimilarRequest(BaseModel):
|
||||||
|
conversation_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarDocumentOut(BaseModel):
|
||||||
|
document_id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/suggest-similar")
|
||||||
|
async def suggest_similar(
|
||||||
|
body: SuggestSimilarRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[SimilarDocumentOut]:
|
||||||
|
"""Existing documents that match the conversation a capture is starting
|
||||||
|
from — found over an LLM TOPIC SUMMARY of the chat (not the raw last
|
||||||
|
message), permission-filtered, help pages excluded. Empty list when the
|
||||||
|
conversation is unknown, empty, or nothing is close enough."""
|
||||||
|
conversation = (
|
||||||
|
await db.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.where(
|
||||||
|
Conversation.id == body.conversation_id,
|
||||||
|
Conversation.user_id == user.id,
|
||||||
|
)
|
||||||
|
.options(selectinload(Conversation.messages))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
return []
|
||||||
|
topic = await summarize_conversation(conversation)
|
||||||
|
if not topic:
|
||||||
|
return []
|
||||||
|
matches = await similar_documents(
|
||||||
|
db,
|
||||||
|
topic,
|
||||||
|
user=user,
|
||||||
|
max_distance=CAPTURE_CONTEXT_MAX_DISTANCE,
|
||||||
|
exclude_builtin=True,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
SimilarDocumentOut(document_id=match.document_id, title=match.title)
|
||||||
|
for match in matches
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""The conversations API: the chat itself.
|
||||||
|
|
||||||
|
Two halves. `crud` manages conversations as objects a user owns; `turns` is the
|
||||||
|
one place that speaks SSE, turning a mode's events into frames and persisting
|
||||||
|
what was streamed. The ownership gate sits in `access`, the wire shapes in
|
||||||
|
`schemas`, and the row-to-shape mapping in `view`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.conversations import crud, turns
|
||||||
|
from app.api.conversations.turns import stream_turn
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(crud.router)
|
||||||
|
router.include_router(turns.router)
|
||||||
|
|
||||||
|
# Exported for the tests that drive a turn without going through HTTP.
|
||||||
|
__all__ = ["router", "stream_turn"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Whose conversation this is.
|
||||||
|
|
||||||
|
A conversation is private to the user who started it — there is no sharing and
|
||||||
|
no admin view. One gate, used by every endpoint in the package, so the rule
|
||||||
|
cannot quietly differ between reading and writing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Conversation, User
|
||||||
|
|
||||||
|
|
||||||
|
async def own_conversation(
|
||||||
|
db: AsyncSession,
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
with_messages: bool = False,
|
||||||
|
) -> Conversation:
|
||||||
|
stmt = select(Conversation).where(
|
||||||
|
Conversation.id == conversation_id, Conversation.user_id == user.id
|
||||||
|
)
|
||||||
|
if with_messages:
|
||||||
|
stmt = stmt.options(selectinload(Conversation.messages))
|
||||||
|
conversation = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
# 404 rather than 403: someone else's conversation must not be
|
||||||
|
# confirmed to exist.
|
||||||
|
raise ApiError(404, "Conversation not found.", "not_found")
|
||||||
|
return conversation
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Starting, listing, reading and deleting conversations.
|
||||||
|
|
||||||
|
Everything here is owner-scoped. Deleting is a GDPR surface, not a convenience:
|
||||||
|
a user must be able to remove their own transcripts, and the messages go with
|
||||||
|
them by cascade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.conversations.access import own_conversation
|
||||||
|
from app.api.conversations.routing import conversations_router
|
||||||
|
from app.api.conversations.schemas import (
|
||||||
|
ConversationCreate,
|
||||||
|
ConversationDetail,
|
||||||
|
ConversationSummary,
|
||||||
|
)
|
||||||
|
from app.api.conversations.view import message_out, title
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Conversation, Message, User
|
||||||
|
from app.modes import get_mode
|
||||||
|
|
||||||
|
router = conversations_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_conversation(
|
||||||
|
body: ConversationCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> ConversationSummary:
|
||||||
|
if get_mode(body.mode.value) is None:
|
||||||
|
raise ApiError(
|
||||||
|
400, f"Mode '{body.mode.value}' is not available.", "unknown_mode"
|
||||||
|
)
|
||||||
|
conversation = Conversation(mode=body.mode, user_id=user.id)
|
||||||
|
db.add(conversation)
|
||||||
|
await db.commit()
|
||||||
|
return ConversationSummary.model_validate(conversation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_conversations(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[ConversationSummary]:
|
||||||
|
"""The sidebar list, newest activity first. The title is the first message,
|
||||||
|
fetched as a correlated subquery so one statement answers the whole list."""
|
||||||
|
first_message = (
|
||||||
|
select(Message.content)
|
||||||
|
.where(Message.conversation_id == Conversation.id)
|
||||||
|
.order_by(Message.created_at)
|
||||||
|
.limit(1)
|
||||||
|
.correlate(Conversation)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(Conversation, first_message)
|
||||||
|
.where(Conversation.user_id == user.id)
|
||||||
|
.order_by(Conversation.updated_at.desc())
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ConversationSummary.model_validate(conversation).model_copy(
|
||||||
|
update={"title": title(first)}
|
||||||
|
)
|
||||||
|
for conversation, first in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conversation_id}")
|
||||||
|
async def get_conversation(
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> ConversationDetail:
|
||||||
|
conversation = await own_conversation(db, conversation_id, user, with_messages=True)
|
||||||
|
first = conversation.messages[0].content if conversation.messages else None
|
||||||
|
return ConversationDetail.model_validate(conversation).model_copy(
|
||||||
|
update={
|
||||||
|
"title": title(first),
|
||||||
|
"messages": [message_out(message) for message in conversation.messages],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{conversation_id}", status_code=204)
|
||||||
|
async def delete_conversation(
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
"""GDPR: users delete their own conversations; messages cascade."""
|
||||||
|
conversation = await own_conversation(db, conversation_id, user)
|
||||||
|
await db.delete(conversation)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""The one router constructor the conversations modules share."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def conversations_router() -> APIRouter:
|
||||||
|
return APIRouter(prefix="/conversations", tags=["conversations"])
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Request and response shapes for conversations and their turns."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models import ConversationMode, MessageRole
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCreate(BaseModel):
|
||||||
|
mode: ConversationMode
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSource(BaseModel):
|
||||||
|
document_id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
heading_path: str
|
||||||
|
excerpt: str = ""
|
||||||
|
# True when the passage was passed to the model; False for passages that
|
||||||
|
# were retrieved but dropped as too weak (a no-answer turn). Old messages
|
||||||
|
# predate the flag, so it defaults to True (they were all cited).
|
||||||
|
used: bool = True
|
||||||
|
# The cited document has an unanswered request to check it. Snapshotted
|
||||||
|
# with the citation, so a reload shows what was true when it was answered.
|
||||||
|
review_pending: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
role: MessageRole
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
sources: list[MessageSource] = []
|
||||||
|
# Set when no model answered this turn and `sources` is a plain full-text
|
||||||
|
# result list instead: the `llm_*` code that caused it, which the frontend
|
||||||
|
# phrases. Null on every normal turn.
|
||||||
|
fallback: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationDetail(ConversationSummary):
|
||||||
|
messages: list[MessageOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessage(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=8000)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""One turn: a question in, an answer streamed out, both persisted.
|
||||||
|
|
||||||
|
This is the only place that knows about SSE. A mode yields `ModeEvent`s and
|
||||||
|
knows nothing about HTTP; here they become frames on the wire. Persistence is
|
||||||
|
deliberately asymmetric: the user message is committed BEFORE streaming starts
|
||||||
|
so it survives anything the endpoint does, while the assistant message is
|
||||||
|
written at the end — complete, partial after an abort, or source-list-only when
|
||||||
|
no model could answer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from app.api.conversations.access import own_conversation
|
||||||
|
from app.api.conversations.routing import conversations_router
|
||||||
|
from app.api.conversations.schemas import SendMessage
|
||||||
|
from app.api.sse import sse
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.log import conversation_id as conversation_id_var
|
||||||
|
from app.models import Conversation, Message, MessageRole, User
|
||||||
|
from app.modes import get_mode
|
||||||
|
from app.modes.base import Degraded, Done, Error, Mode, Sources, StateChanged, Token
|
||||||
|
|
||||||
|
router = conversations_router()
|
||||||
|
logger = logging.getLogger("pablan.conversations")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{conversation_id}/messages")
|
||||||
|
async def send_message(
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
body: SendMessage,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
conversation = await own_conversation(db, conversation_id, user, with_messages=True)
|
||||||
|
mode = get_mode(conversation.mode.value)
|
||||||
|
if mode is None:
|
||||||
|
raise ApiError(
|
||||||
|
400, f"Mode '{conversation.mode.value}' is not available.", "unknown_mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The user message is committed before streaming starts — it survives
|
||||||
|
# whatever happens to the LLM call.
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role=MessageRole.user,
|
||||||
|
content=body.content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation.updated_at = datetime.now(UTC)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_turn(conversation, body.content, mode, db),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_payload(chunks: Any) -> list[dict[str, Any]]:
|
||||||
|
"""The wire shape of a citation — the same dicts are snapshotted onto the
|
||||||
|
message, so a reload shows exactly what was streamed."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"document_id": str(chunk.document_id),
|
||||||
|
"title": chunk.title,
|
||||||
|
"heading_path": chunk.heading_path,
|
||||||
|
"excerpt": chunk.excerpt,
|
||||||
|
"used": chunk.used,
|
||||||
|
"review_pending": chunk.review_pending,
|
||||||
|
}
|
||||||
|
for chunk in chunks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_turn(
|
||||||
|
conversation: Conversation, content: str, mode: Mode, db: AsyncSession
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Convert ModeEvents to SSE frames; persist the assistant reply —
|
||||||
|
complete on normal end, partial on client abort or endpoint failure."""
|
||||||
|
context_token = conversation_id_var.set(str(conversation.id))
|
||||||
|
started = time.monotonic()
|
||||||
|
parts: list[str] = []
|
||||||
|
sources: list[dict[str, Any]] = []
|
||||||
|
# Set when the mode gave up on the model: the turn still has a reply (the
|
||||||
|
# retrieved documents), so it is persisted and replayed like any other.
|
||||||
|
fallback: str | None = None
|
||||||
|
outcome = "ok"
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
async for event in mode.handle_turn(conversation, content, db):
|
||||||
|
match event:
|
||||||
|
case Token(text=text):
|
||||||
|
parts.append(text)
|
||||||
|
yield sse("token", {"text": text})
|
||||||
|
case Sources(chunks=chunks):
|
||||||
|
sources = _source_payload(chunks)
|
||||||
|
yield sse("sources", {"chunks": sources})
|
||||||
|
case StateChanged(phase=phase, count=count):
|
||||||
|
yield sse("state", {"phase": phase, "count": count})
|
||||||
|
case Error(code=code):
|
||||||
|
outcome = "error"
|
||||||
|
yield sse("error", {"code": code})
|
||||||
|
case Degraded(code=code):
|
||||||
|
outcome = "degraded"
|
||||||
|
fallback = code
|
||||||
|
yield sse("fallback", {"code": code})
|
||||||
|
case Done():
|
||||||
|
pass # the router emits the final done after persisting
|
||||||
|
except LLMError as exc:
|
||||||
|
# Every endpoint failure inside a mode ends the turn the same way,
|
||||||
|
# wherever it happened. Retrieval embeds before the model is ever
|
||||||
|
# called, so an escaping error would reach the browser as a
|
||||||
|
# truncated stream ("connection lost") instead of the reason.
|
||||||
|
outcome = "error"
|
||||||
|
logger.warning(
|
||||||
|
"turn failed",
|
||||||
|
extra={
|
||||||
|
"event": "turn_error",
|
||||||
|
"mode": mode.name,
|
||||||
|
"code": exc.code,
|
||||||
|
"cause_type": exc.cause_type,
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield sse("error", {"code": exc.code})
|
||||||
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
|
# Client aborted (stop button): keep what was already streamed.
|
||||||
|
outcome = "aborted"
|
||||||
|
if parts:
|
||||||
|
await asyncio.shield(
|
||||||
|
_persist_partial(db.bind, conversation.id, "".join(parts), sources)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# Whatever arrived before the end is the reply, complete or not — for a
|
||||||
|
# fallback turn that is the source list alone.
|
||||||
|
if parts or fallback:
|
||||||
|
message_id = await _persist_assistant(
|
||||||
|
db, conversation, "".join(parts), sources, fallback=fallback
|
||||||
|
)
|
||||||
|
yield sse("done", {"message_id": str(message_id)})
|
||||||
|
finally:
|
||||||
|
conversation_id_var.reset(context_token)
|
||||||
|
logger.info(
|
||||||
|
"turn finished",
|
||||||
|
extra={
|
||||||
|
"event": "turn",
|
||||||
|
"mode": mode.name,
|
||||||
|
"outcome": outcome,
|
||||||
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||||
|
"token_events": len(parts),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assistant_meta(
|
||||||
|
sources: list[dict[str, Any]], fallback: str | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
meta: dict[str, Any] = {}
|
||||||
|
if sources:
|
||||||
|
meta["sources"] = sources
|
||||||
|
if fallback:
|
||||||
|
# Why there is no generated text, kept so a reload replays the turn as
|
||||||
|
# what it was rather than as an empty reply.
|
||||||
|
meta["fallback"] = fallback
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_assistant(
|
||||||
|
db: AsyncSession,
|
||||||
|
conversation: Conversation,
|
||||||
|
content: str,
|
||||||
|
sources: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
fallback: str | None = None,
|
||||||
|
) -> uuid.UUID:
|
||||||
|
message = Message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role=MessageRole.assistant,
|
||||||
|
content=content,
|
||||||
|
meta=_assistant_meta(sources, fallback),
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
conversation.updated_at = datetime.now(UTC)
|
||||||
|
await db.commit()
|
||||||
|
return message.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_partial(
|
||||||
|
bind: Any,
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
content: str,
|
||||||
|
sources: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Write what was streamed before the client hung up.
|
||||||
|
|
||||||
|
On a FRESH session on the same engine as the request session: the request
|
||||||
|
session is being torn down mid-cancel, so it cannot be used to commit, and
|
||||||
|
binding to the same engine keeps this working under the test overrides.
|
||||||
|
"""
|
||||||
|
async with async_sessionmaker(bind, expire_on_commit=False)() as db:
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=MessageRole.assistant,
|
||||||
|
content=content,
|
||||||
|
meta=_assistant_meta(sources, None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = await db.get(Conversation, conversation_id)
|
||||||
|
if conversation is not None:
|
||||||
|
conversation.updated_at = datetime.now(UTC)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Rows to API shapes.
|
||||||
|
|
||||||
|
A conversation has no title column: the first message is the title, derived
|
||||||
|
here so the list and the detail can never disagree about what a conversation
|
||||||
|
is called.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.api.conversations.schemas import MessageOut, MessageSource
|
||||||
|
from app.models import Message
|
||||||
|
from app.modes.query import excerpt as clean_excerpt
|
||||||
|
|
||||||
|
TITLE_LENGTH = 80
|
||||||
|
|
||||||
|
|
||||||
|
def title(first_message: str | None) -> str | None:
|
||||||
|
if not first_message:
|
||||||
|
return None
|
||||||
|
flattened = " ".join(first_message.split())
|
||||||
|
if len(flattened) <= TITLE_LENGTH:
|
||||||
|
return flattened
|
||||||
|
return flattened[: TITLE_LENGTH - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def message_out(message: Message) -> MessageOut:
|
||||||
|
"""Message + its citation snapshot from `meta` (assistant turns only).
|
||||||
|
|
||||||
|
The excerpt is re-cleaned on the way out, not just on the way in. It is
|
||||||
|
a presentation detail frozen at answer time, so an improvement to the
|
||||||
|
cleaning would otherwise only reach conversations created afterwards,
|
||||||
|
and every existing citation would keep showing raw Markdown forever.
|
||||||
|
Cleaning is idempotent, so text stored by a newer backend passes
|
||||||
|
through untouched.
|
||||||
|
"""
|
||||||
|
meta = message.meta or {}
|
||||||
|
sources = [
|
||||||
|
MessageSource.model_validate(item).model_copy(
|
||||||
|
update={"excerpt": clean_excerpt(item.get("excerpt", ""))}
|
||||||
|
)
|
||||||
|
for item in meta.get("sources", [])
|
||||||
|
]
|
||||||
|
return MessageOut.model_validate(message).model_copy(
|
||||||
|
update={"sources": sources, "fallback": meta.get("fallback")}
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import Department, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/departments", tags=["departments"])
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_departments(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[DepartmentOut]:
|
||||||
|
"""Department names for filters and pickers — not secret, any user."""
|
||||||
|
rows = (
|
||||||
|
(await db.execute(select(Department).order_by(Department.name))).scalars().all()
|
||||||
|
)
|
||||||
|
return [DepartmentOut.model_validate(row) for row in rows]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""The documents API.
|
||||||
|
|
||||||
|
Split by what a caller is doing, not by HTTP verb: browsing, the life of one
|
||||||
|
document, its audit trail, the approval workflow, and department sharing. The
|
||||||
|
shared gates live in `access.py` and the shared response shapes in `view.py`,
|
||||||
|
so a rule like "an author keeps access to their own document" exists once.
|
||||||
|
|
||||||
|
**Route order matters.** FastAPI matches in registration order, so `browse`
|
||||||
|
goes first: after `/{document_id}` exists, a request for `/search` would be
|
||||||
|
parsed as a document id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.documents import browse, crud, history, sharing, workflow
|
||||||
|
from app.api.documents.access import readable_document, require_editor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(browse.router)
|
||||||
|
router.include_router(crud.router)
|
||||||
|
router.include_router(history.router)
|
||||||
|
router.include_router(workflow.router)
|
||||||
|
router.include_router(sharing.router)
|
||||||
|
|
||||||
|
# The authoring API works on documents the caller may change, so it shares
|
||||||
|
# this package's gate rather than growing a second one.
|
||||||
|
__all__ = ["readable_document", "require_editor", "router"]
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Who may read, edit and publish a document.
|
||||||
|
|
||||||
|
The read gate itself lives in `rag/permissions` as SQL, because there is one
|
||||||
|
place where "which documents may this user see" is decided. What lives here is
|
||||||
|
everything the HTTP layer needs around it: loading one document through that
|
||||||
|
gate, the write gates on top of it, and the Python mirror that can judge a
|
||||||
|
change BEFORE it is committed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import (
|
||||||
|
AccessReason,
|
||||||
|
Document,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from app.rag.permissions import readable_documents_filter
|
||||||
|
|
||||||
|
BUILTIN_READONLY = (
|
||||||
|
"Built-in help documents are maintained with the product.",
|
||||||
|
"builtin_readonly",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def readable_document(
|
||||||
|
db: AsyncSession, document_id: uuid.UUID, user: User
|
||||||
|
) -> Document:
|
||||||
|
"""One document, through the same filter the search uses."""
|
||||||
|
document = (
|
||||||
|
await db.execute(
|
||||||
|
select(Document).where(
|
||||||
|
Document.id == document_id, readable_documents_filter(user)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if document is None:
|
||||||
|
# 404 for unreadable docs: existence must not leak.
|
||||||
|
raise ApiError(404, "Document not found.", "not_found")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def is_open_reviewer(document: Document, user: User) -> bool:
|
||||||
|
"""Someone asked this user to check the document and has not been answered.
|
||||||
|
|
||||||
|
Being asked is what grants the right to change it: a reviewer who spots a
|
||||||
|
wrong number should fix it, not file a second question about it.
|
||||||
|
"""
|
||||||
|
return any(review.reviewer_id == user.id for review in document.open_reviews)
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit(document: Document, user: User) -> bool:
|
||||||
|
"""Mirror of `require_editor` — the UI must predict the gate, never guess
|
||||||
|
it."""
|
||||||
|
if document.is_builtin:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
document.author_id == user.id
|
||||||
|
or user.role == UserRole.admin
|
||||||
|
or is_open_reviewer(document, user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_editor(document: Document, user: User) -> None:
|
||||||
|
if document.is_builtin:
|
||||||
|
# Help pages ship with the product and are re-imported on start;
|
||||||
|
# an edit here would silently vanish on the next deploy.
|
||||||
|
raise ApiError(409, *BUILTIN_READONLY)
|
||||||
|
if not can_edit(document, user):
|
||||||
|
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
def require_author_or_admin(document: Document, user: User) -> None:
|
||||||
|
"""Stricter than `require_editor`: for the decisions that belong to the
|
||||||
|
document's owner, like deleting it or handing out a review request."""
|
||||||
|
if document.is_builtin:
|
||||||
|
raise ApiError(409, *BUILTIN_READONLY)
|
||||||
|
if document.author_id != user.id and user.role != UserRole.admin:
|
||||||
|
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
def access_reason(document: Document, user: User) -> AccessReason:
|
||||||
|
"""Most specific reason first: being the author explains access better
|
||||||
|
than the visibility level does.
|
||||||
|
|
||||||
|
Mirrors `readable_documents_filter`, where the visibility rules only apply
|
||||||
|
to a PUBLISHED document — an unpublished one is visible to its author and
|
||||||
|
to whoever was asked to check it, and to nobody else. So `review` is the
|
||||||
|
reason whenever nothing more durable carries the access, which is exactly
|
||||||
|
the case where the access ends with the answer.
|
||||||
|
"""
|
||||||
|
if document.author_id == user.id:
|
||||||
|
return AccessReason.author
|
||||||
|
published = document.status == DocumentStatus.published
|
||||||
|
if published and document.visibility == DocumentVisibility.public:
|
||||||
|
return AccessReason.public
|
||||||
|
if (
|
||||||
|
published
|
||||||
|
and document.visibility == DocumentVisibility.department
|
||||||
|
and document.department_id is not None
|
||||||
|
and document.department_id == user.department_id
|
||||||
|
):
|
||||||
|
return AccessReason.department
|
||||||
|
if is_open_reviewer(document, user):
|
||||||
|
return AccessReason.review
|
||||||
|
# Everything else that survived the permission filter came via a grant.
|
||||||
|
return AccessReason.granted
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_read(
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
author_id: uuid.UUID | None,
|
||||||
|
visibility: DocumentVisibility,
|
||||||
|
department_id: uuid.UUID | None,
|
||||||
|
granted_department_ids: set[uuid.UUID],
|
||||||
|
) -> bool:
|
||||||
|
"""The Python mirror of `readable_documents_filter` for one document's
|
||||||
|
proposed state — so a change can be checked BEFORE it is committed. Admins
|
||||||
|
get no read-everything bypass (same as the filter).
|
||||||
|
|
||||||
|
Kept next to its only caller so the two cannot drift apart unnoticed; the
|
||||||
|
SQL it mirrors is one import away.
|
||||||
|
"""
|
||||||
|
if author_id is not None and author_id == user.id:
|
||||||
|
return True
|
||||||
|
if visibility == DocumentVisibility.public:
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
visibility == DocumentVisibility.department
|
||||||
|
and department_id is not None
|
||||||
|
and department_id == user.department_id
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
user.department_id is not None and user.department_id in granted_department_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def guard_self_lockout(
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
author_id: uuid.UUID | None,
|
||||||
|
visibility: DocumentVisibility,
|
||||||
|
department_id: uuid.UUID | None,
|
||||||
|
granted_department_ids: set[uuid.UUID],
|
||||||
|
confirm: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Refuse (or, for a confirming admin, allow) a change that would remove the
|
||||||
|
editing user's own read access. An author keeps access as author, so this
|
||||||
|
only ever bites an admin editing a document they do not own."""
|
||||||
|
if user_can_read(
|
||||||
|
user,
|
||||||
|
author_id=author_id,
|
||||||
|
visibility=visibility,
|
||||||
|
department_id=department_id,
|
||||||
|
granted_department_ids=granted_department_ids,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if user.role != UserRole.admin:
|
||||||
|
# A non-author non-admin cannot reach this state through the API; a
|
||||||
|
# defensive block rather than a silent lockout.
|
||||||
|
raise ApiError(409, "This change would remove your own access.", "self_lockout")
|
||||||
|
if not confirm:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"You will lose access to this document after this change.",
|
||||||
|
"self_lockout_warning",
|
||||||
|
)
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""Finding documents: the paged list, ranked search, the ZIP export, and the
|
||||||
|
company-wide counts.
|
||||||
|
|
||||||
|
Every route here has a static path, so this router is included FIRST: after
|
||||||
|
`/{document_id}` is registered, "search" would be parsed as a document id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import exists, func, or_, select, true
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.documents.routing import documents_router
|
||||||
|
from app.api.documents.schemas import (
|
||||||
|
DocumentPage,
|
||||||
|
DocumentSearchHit,
|
||||||
|
DocumentSort,
|
||||||
|
DocumentStats,
|
||||||
|
)
|
||||||
|
from app.api.documents.view import document_fields, summary
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import (
|
||||||
|
Department,
|
||||||
|
DocPermission,
|
||||||
|
Document,
|
||||||
|
DocumentStatus,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.rag.permissions import open_review_for, readable_documents_filter
|
||||||
|
|
||||||
|
# aliased: `search` is also a query parameter on the list endpoint
|
||||||
|
from app.rag.retrieval import search as hybrid_search
|
||||||
|
|
||||||
|
router = documents_router()
|
||||||
|
|
||||||
|
# Chunks retrieved before grouping, and the most documents a search returns.
|
||||||
|
SEARCH_CANDIDATES = 20
|
||||||
|
SEARCH_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_documents(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
department: uuid.UUID | None = None,
|
||||||
|
status: DocumentStatus | None = None,
|
||||||
|
assigned_to_me: bool = False,
|
||||||
|
search: str | None = Query(None, max_length=200),
|
||||||
|
sort: DocumentSort = DocumentSort.updated,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(30, ge=1, le=100),
|
||||||
|
) -> DocumentPage:
|
||||||
|
"""Browse readable documents.
|
||||||
|
|
||||||
|
Paginated server-side: the list is the one screen that grows without
|
||||||
|
bound as a knowledge base fills up. Search has its own endpoint and is
|
||||||
|
ranked rather than paged.
|
||||||
|
"""
|
||||||
|
filters = [readable_documents_filter(user)]
|
||||||
|
if department is not None:
|
||||||
|
# A department filter matches the owning department OR a shared grant,
|
||||||
|
# so a document shared with a department shows up under it too.
|
||||||
|
filters.append(
|
||||||
|
or_(
|
||||||
|
Document.department_id == department,
|
||||||
|
exists(
|
||||||
|
select(DocPermission.document_id).where(
|
||||||
|
DocPermission.document_id == Document.id,
|
||||||
|
DocPermission.department_id == department,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
filters.append(Document.status == status)
|
||||||
|
if assigned_to_me:
|
||||||
|
# "Waiting for me": documents someone asked THIS user to check.
|
||||||
|
filters.append(open_review_for(user))
|
||||||
|
if search:
|
||||||
|
filters.append(Document.title.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
total = (
|
||||||
|
await db.execute(select(func.count(Document.id)).where(*filters))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
order = (
|
||||||
|
Document.created_at.desc()
|
||||||
|
if sort is DocumentSort.created
|
||||||
|
else Document.updated_at.desc()
|
||||||
|
)
|
||||||
|
documents = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Document)
|
||||||
|
.where(*filters)
|
||||||
|
# Built-in help is reference material and belongs after the
|
||||||
|
# team's own documents — sorted in SQL so it holds across page
|
||||||
|
# boundaries, which a client-side sort could not manage.
|
||||||
|
# `Document.id` breaks ties. Without it the order is only
|
||||||
|
# partial: the corpus is seeded in one transaction, so many
|
||||||
|
# rows share a timestamp to the microsecond, and Postgres is
|
||||||
|
# free to return them in a different order per query. Two pages
|
||||||
|
# then overlap and a document is shown twice while another is
|
||||||
|
# never reachable.
|
||||||
|
.order_by(Document.is_builtin.asc(), order, Document.id)
|
||||||
|
.offset((page - 1) * per_page)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return DocumentPage(
|
||||||
|
items=[summary(document, user) for document in documents],
|
||||||
|
total=total,
|
||||||
|
per_page=per_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search_documents(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
q: Annotated[str, Query(min_length=1, max_length=200)],
|
||||||
|
) -> list[DocumentSearchHit]:
|
||||||
|
"""Find documents through the same hybrid retrieval the chat uses.
|
||||||
|
|
||||||
|
Permission-safe by construction: `search()` requires a user and applies
|
||||||
|
the shared filter. Drafts and pending documents are readable
|
||||||
|
but never indexed, so a title fallback covers them — the one asymmetry
|
||||||
|
between this endpoint and chat retrieval.
|
||||||
|
"""
|
||||||
|
results = await hybrid_search(db, q, user=user, top_k=SEARCH_CANDIDATES)
|
||||||
|
|
||||||
|
# Group chunks per document, keeping the best-scoring chunk's heading.
|
||||||
|
best_heading: dict[uuid.UUID, str] = {}
|
||||||
|
for result in results:
|
||||||
|
best_heading.setdefault(result.document_id, result.heading_path)
|
||||||
|
|
||||||
|
hits: list[DocumentSearchHit] = []
|
||||||
|
if best_heading:
|
||||||
|
documents = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Document).where(
|
||||||
|
Document.id.in_(best_heading),
|
||||||
|
readable_documents_filter(user),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_id = {document.id: document for document in documents}
|
||||||
|
# Preserve retrieval order — relevance, not insertion order.
|
||||||
|
for document_id, heading in best_heading.items():
|
||||||
|
document = by_id.get(document_id)
|
||||||
|
if document is not None:
|
||||||
|
hits.append(
|
||||||
|
DocumentSearchHit(
|
||||||
|
**document_fields(document, user),
|
||||||
|
heading_path=heading,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Title fallback for everything retrieval cannot see.
|
||||||
|
remaining = SEARCH_LIMIT - len(hits)
|
||||||
|
if remaining > 0:
|
||||||
|
by_title = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Document)
|
||||||
|
.where(
|
||||||
|
readable_documents_filter(user),
|
||||||
|
Document.title.ilike(f"%{q}%"),
|
||||||
|
Document.id.notin_(best_heading) if best_heading else true(),
|
||||||
|
)
|
||||||
|
.order_by(Document.updated_at.desc())
|
||||||
|
.limit(remaining)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
hits.extend(
|
||||||
|
DocumentSearchHit(**document_fields(document, user))
|
||||||
|
for document in by_title
|
||||||
|
)
|
||||||
|
return hits[:SEARCH_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def _export_name(document: Document, used: set[str]) -> str:
|
||||||
|
"""A stable, de-duplicated `.md` filename for a document in the export."""
|
||||||
|
slug = (document.meta or {}).get("slug")
|
||||||
|
base = slug or re.sub(r"[^a-z0-9]+", "-", document.title.lower()).strip("-")
|
||||||
|
base = base or str(document.id)
|
||||||
|
name = f"{base}.md"
|
||||||
|
counter = 2
|
||||||
|
while name in used:
|
||||||
|
name = f"{base}-{counter}.md"
|
||||||
|
counter += 1
|
||||||
|
used.add(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_documents(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""The readable knowledge base as a ZIP of Markdown files with YAML
|
||||||
|
frontmatter. Permission-filtered by construction (`readable_documents_filter`
|
||||||
|
— an admin exports what they can read, anyone else the same); built-in help
|
||||||
|
pages are excluded (product content, not the company's knowledge). stdlib
|
||||||
|
only, streamed, no temp files."""
|
||||||
|
documents = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Document)
|
||||||
|
.where(readable_documents_filter(user), Document.is_builtin.is_(False))
|
||||||
|
.order_by(Document.title)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve department names once for the frontmatter: the owning department
|
||||||
|
# plus any shared grants, so an export records the full reach of a document.
|
||||||
|
dept_names = dict((await db.execute(select(Department.id, Department.name))).all())
|
||||||
|
shared: dict[uuid.UUID, list[str]] = {}
|
||||||
|
for doc_id, dept_id in (
|
||||||
|
await db.execute(select(DocPermission.document_id, DocPermission.department_id))
|
||||||
|
).all():
|
||||||
|
shared.setdefault(doc_id, []).append(dept_names.get(dept_id, ""))
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
used: set[str] = set()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for document in documents:
|
||||||
|
departments = [
|
||||||
|
*(
|
||||||
|
[dept_names[document.department_id]]
|
||||||
|
if document.department_id in dept_names
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
*sorted(shared.get(document.id, [])),
|
||||||
|
]
|
||||||
|
frontmatter = yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"title": document.title,
|
||||||
|
"status": str(document.status),
|
||||||
|
"visibility": str(document.visibility),
|
||||||
|
"departments": departments,
|
||||||
|
},
|
||||||
|
allow_unicode=True,
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
|
body = f"---\n{frontmatter}---\n\n{document.content_md.rstrip()}\n"
|
||||||
|
archive.writestr(_export_name(document, used), body)
|
||||||
|
|
||||||
|
buffer.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([buffer.getvalue()]),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"content-disposition": 'attachment; filename="pablan-export.zip"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def document_stats(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentStats:
|
||||||
|
"""Is this a fresh install or a filled one? Read by the landing page's
|
||||||
|
first-run guide."""
|
||||||
|
published = Document.status == DocumentStatus.published
|
||||||
|
return DocumentStats(
|
||||||
|
documents_total=(
|
||||||
|
await db.execute(select(func.count(Document.id)).where(published))
|
||||||
|
).scalar_one(),
|
||||||
|
departments_total=(
|
||||||
|
await db.execute(select(func.count(Department.id)))
|
||||||
|
).scalar_one(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""The life of one document: open it, read it, change it, delete it.
|
||||||
|
|
||||||
|
Publishing does NOT live here — a draft becomes public through
|
||||||
|
`workflow.py`, so a content edit can never make a private draft readable by
|
||||||
|
accident. Archiving does, because it is the mirror of the `status` a PATCH
|
||||||
|
already carries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.documents.access import (
|
||||||
|
guard_self_lockout,
|
||||||
|
readable_document,
|
||||||
|
require_author_or_admin,
|
||||||
|
require_editor,
|
||||||
|
)
|
||||||
|
from app.api.documents.routing import documents_router
|
||||||
|
from app.api.documents.schemas import DocumentCreate, DocumentDetail, DocumentUpdate
|
||||||
|
from app.api.documents.view import detail, full_detail, granted_department_ids
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.authoring.context import summarize_conversation
|
||||||
|
from app.authoring.document import render_skeleton, render_title
|
||||||
|
from app.authoring.history import record_event
|
||||||
|
from app.authoring.schema import AuthoringTemplate
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.ingestion.handlers import INDEX_DOCUMENT
|
||||||
|
from app.ingestion.queue import enqueue
|
||||||
|
from app.models import (
|
||||||
|
Conversation,
|
||||||
|
Document,
|
||||||
|
DocumentEventAction,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
Template,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = documents_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_document(
|
||||||
|
body: DocumentCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
"""Open a new document to write in.
|
||||||
|
|
||||||
|
A `draft` is the author's private working copy: `readable_documents_filter`
|
||||||
|
shows it to no one else (bar a colleague asked to check it) and only
|
||||||
|
`published` documents are indexed, so a draft never reaches another user
|
||||||
|
or an LLM prompt."""
|
||||||
|
title = body.title
|
||||||
|
content_md = ""
|
||||||
|
visibility = body.visibility or DocumentVisibility.department
|
||||||
|
meta: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if body.template_id is not None:
|
||||||
|
row = await db.get(Template, body.template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "Template not found.", "not_found")
|
||||||
|
try:
|
||||||
|
template = AuthoringTemplate.model_validate(row.config)
|
||||||
|
except ValidationError:
|
||||||
|
raise ApiError(
|
||||||
|
422, "Template is not a valid authoring template.", "invalid_template"
|
||||||
|
) from None
|
||||||
|
content_md = render_skeleton(template)
|
||||||
|
meta = {"template": template.id}
|
||||||
|
if title is None:
|
||||||
|
title = render_title(template, user)
|
||||||
|
if body.visibility is None:
|
||||||
|
visibility = DocumentVisibility(template.metadata.visibility)
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
raise ApiError(422, "A title or a template is required.", "title_required")
|
||||||
|
|
||||||
|
if body.conversation_id is not None:
|
||||||
|
meta.update(await _conversation_context(db, body.conversation_id, user))
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
title=title,
|
||||||
|
status=DocumentStatus.draft,
|
||||||
|
visibility=visibility,
|
||||||
|
content_md=content_md,
|
||||||
|
meta=meta,
|
||||||
|
author_id=user.id,
|
||||||
|
department_id=user.department_id,
|
||||||
|
# Marks the collection loaded — a brand-new document has no requests,
|
||||||
|
# and the serializer reads them without a session to lazy-load in.
|
||||||
|
reviews=[],
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
# Flush so the event can reference document.id (the PK default is applied
|
||||||
|
# at flush, not at construction).
|
||||||
|
await db.flush()
|
||||||
|
record_event(db, document, user, DocumentEventAction.created, snapshot=True)
|
||||||
|
await db.commit()
|
||||||
|
return detail(document, user)
|
||||||
|
|
||||||
|
|
||||||
|
async def _conversation_context(
|
||||||
|
db: AsyncSession, conversation_id: uuid.UUID, user: User
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""What the chat this capture started from was about, as background for
|
||||||
|
section refinement. Owner-scoped; a foreign or unknown conversation simply
|
||||||
|
contributes nothing."""
|
||||||
|
conversation = (
|
||||||
|
await db.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.where(
|
||||||
|
Conversation.id == conversation_id,
|
||||||
|
Conversation.user_id == user.id,
|
||||||
|
)
|
||||||
|
.options(selectinload(Conversation.messages))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
return {}
|
||||||
|
topic = await summarize_conversation(conversation)
|
||||||
|
if not topic:
|
||||||
|
return {}
|
||||||
|
return {"context": topic, "conversation_id": str(conversation.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}")
|
||||||
|
async def get_document(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
return await full_detail(db, document, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{document_id}")
|
||||||
|
async def update_document(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
body: DocumentUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_editor(document, user)
|
||||||
|
|
||||||
|
# Track the axes of change separately so the audit trail can name what
|
||||||
|
# happened (an edit vs. a visibility change vs. an archive), even though
|
||||||
|
# all three equally invalidate the denormalized chunk copy.
|
||||||
|
body_changed = False
|
||||||
|
if body.title is not None and body.title != document.title:
|
||||||
|
document.title = body.title
|
||||||
|
body_changed = True
|
||||||
|
if body.content_md is not None and body.content_md != document.content_md:
|
||||||
|
document.content_md = body.content_md
|
||||||
|
body_changed = True
|
||||||
|
|
||||||
|
visibility_changed = False
|
||||||
|
if body.visibility is not None and body.visibility != document.visibility:
|
||||||
|
# Who may READ this is the owner's decision, like sharing and deleting:
|
||||||
|
# a colleague asked to check the text may correct it, not re-address it.
|
||||||
|
require_author_or_admin(document, user)
|
||||||
|
# A visibility change can remove the editing user's own access (only an
|
||||||
|
# admin editing a document they do not own — an author keeps access).
|
||||||
|
guard_self_lockout(
|
||||||
|
user,
|
||||||
|
author_id=document.author_id,
|
||||||
|
visibility=body.visibility,
|
||||||
|
department_id=document.department_id,
|
||||||
|
granted_department_ids=await granted_department_ids(db, document.id),
|
||||||
|
confirm=bool(body.confirm_lockout),
|
||||||
|
)
|
||||||
|
document.visibility = body.visibility
|
||||||
|
visibility_changed = True # chunk meta carries a denormalized copy
|
||||||
|
|
||||||
|
if body.conversation_id is not None:
|
||||||
|
# Extending a document out of a chat: same background as a fresh
|
||||||
|
# capture. Metadata only — no event, and no reindex, because nothing
|
||||||
|
# a chunk carries changed.
|
||||||
|
document.meta = {
|
||||||
|
**document.meta,
|
||||||
|
**await _conversation_context(db, body.conversation_id, user),
|
||||||
|
}
|
||||||
|
|
||||||
|
archived = False
|
||||||
|
status_changed = False
|
||||||
|
if body.status is not None and body.status != document.status:
|
||||||
|
archivable = {DocumentStatus.published, DocumentStatus.archived}
|
||||||
|
if body.status not in archivable or document.status not in archivable:
|
||||||
|
# Publishing is its own endpoint: it indexes the document and is
|
||||||
|
# the author's decision, not a field on a content edit.
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"Only published documents can be archived (and vice versa).",
|
||||||
|
"invalid_status",
|
||||||
|
)
|
||||||
|
document.status = body.status
|
||||||
|
status_changed = True
|
||||||
|
archived = body.status == DocumentStatus.archived
|
||||||
|
|
||||||
|
# Audit: an edit snapshots the new Markdown so the version can be diffed; a
|
||||||
|
# visibility change or archive is a pure transition (no content snapshot).
|
||||||
|
if body_changed:
|
||||||
|
record_event(db, document, user, DocumentEventAction.edited, snapshot=True)
|
||||||
|
if visibility_changed:
|
||||||
|
record_event(db, document, user, DocumentEventAction.visibility_changed)
|
||||||
|
if archived:
|
||||||
|
record_event(db, document, user, DocumentEventAction.archived)
|
||||||
|
|
||||||
|
# Chunks are derivatives of the Markdown: published edits reindex, and
|
||||||
|
# archive/publish transitions add or remove the chunks.
|
||||||
|
content_changed = body_changed or visibility_changed
|
||||||
|
published = document.status == DocumentStatus.published
|
||||||
|
if (content_changed and published) or status_changed:
|
||||||
|
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
|
||||||
|
await db.commit()
|
||||||
|
return detail(document, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{document_id}", status_code=204)
|
||||||
|
async def delete_document(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_author_or_admin(document, user)
|
||||||
|
await db.delete(document) # chunks cascade
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""The audit trail: who changed a document, when, and what that change was.
|
||||||
|
|
||||||
|
Snapshots are written AFTER their event, so an entry's content is the state it
|
||||||
|
produced. Showing "what did this one do" therefore needs the pair (this
|
||||||
|
snapshot and the one before it), which is why the version endpoint returns both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import select, tuple_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.documents.access import readable_document
|
||||||
|
from app.api.documents.routing import documents_router
|
||||||
|
from app.api.documents.schemas import DocumentEventOut, DocumentVersion
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import DocumentEvent, User
|
||||||
|
|
||||||
|
router = documents_router()
|
||||||
|
|
||||||
|
# Newest first, with the id as tiebreaker: events written in one transaction
|
||||||
|
# share a timestamp, and only a total order can be paged or walked backwards.
|
||||||
|
_NEWEST_FIRST = (DocumentEvent.created_at.desc(), DocumentEvent.id.desc())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/history")
|
||||||
|
async def document_history(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[DocumentEventOut]:
|
||||||
|
"""The document's audit trail, newest first: who changed or reviewed it,
|
||||||
|
when, and whether a content snapshot exists to diff against. Same read gate
|
||||||
|
as the document itself, so history never leaks to a user who cannot read the
|
||||||
|
document."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(DocumentEvent, User.name)
|
||||||
|
.join(User, DocumentEvent.actor_id == User.id, isouter=True)
|
||||||
|
.where(DocumentEvent.document_id == document.id)
|
||||||
|
.order_by(*_NEWEST_FIRST)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
DocumentEventOut(
|
||||||
|
id=event.id,
|
||||||
|
action=event.action,
|
||||||
|
actor_id=event.actor_id,
|
||||||
|
actor_name=name,
|
||||||
|
visibility=event.visibility,
|
||||||
|
created_at=event.created_at,
|
||||||
|
has_snapshot=event.content_md is not None,
|
||||||
|
)
|
||||||
|
for event, name in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/versions/{event_id}")
|
||||||
|
async def document_version(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
event_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentVersion:
|
||||||
|
"""A single past version's frozen content plus the content it replaced, so
|
||||||
|
the caller can show what this event changed. Same read gate as the
|
||||||
|
document."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(DocumentEvent, User.name)
|
||||||
|
.join(User, DocumentEvent.actor_id == User.id, isouter=True)
|
||||||
|
.where(
|
||||||
|
DocumentEvent.id == event_id,
|
||||||
|
DocumentEvent.document_id == document.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "Version not found.", "not_found")
|
||||||
|
event, name = row
|
||||||
|
# The state this event started from: the closest earlier snapshot, in the
|
||||||
|
# same order the history list uses.
|
||||||
|
previous = (
|
||||||
|
await db.execute(
|
||||||
|
select(DocumentEvent.content_md)
|
||||||
|
.where(
|
||||||
|
DocumentEvent.document_id == document.id,
|
||||||
|
DocumentEvent.content_md.is_not(None),
|
||||||
|
tuple_(DocumentEvent.created_at, DocumentEvent.id)
|
||||||
|
< tuple_(event.created_at, event.id),
|
||||||
|
)
|
||||||
|
.order_by(*_NEWEST_FIRST)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return DocumentVersion(
|
||||||
|
id=event.id,
|
||||||
|
action=event.action,
|
||||||
|
actor_id=event.actor_id,
|
||||||
|
actor_name=name,
|
||||||
|
created_at=event.created_at,
|
||||||
|
title=event.title,
|
||||||
|
content_md=event.content_md,
|
||||||
|
previous_content_md=previous,
|
||||||
|
visibility=event.visibility,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""The one router constructor the package's modules share.
|
||||||
|
|
||||||
|
Every module builds its own `APIRouter` and `__init__` mounts them in the
|
||||||
|
order that matters. They cannot be prefix-less sub-routers: FastAPI refuses a
|
||||||
|
route whose path and router prefix are BOTH empty, which the browse list ("")
|
||||||
|
would be, so the prefix lives here rather than five times over.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
def documents_router() -> APIRouter:
|
||||||
|
return APIRouter(prefix="/documents", tags=["documents"])
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Request and response shapes for the documents API.
|
||||||
|
|
||||||
|
Kept in one module because the whole package answers with the same handful of
|
||||||
|
document shapes: a summary in lists, a detail on a single document, and the
|
||||||
|
few command bodies that change one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
AccessReason,
|
||||||
|
DocumentEventAction,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
status: DocumentStatus
|
||||||
|
visibility: DocumentVisibility
|
||||||
|
department_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
# Why this user sees it and what they may do — so the UI can explain
|
||||||
|
# access instead of leaving the rules implicit.
|
||||||
|
access_reason: AccessReason
|
||||||
|
can_edit: bool
|
||||||
|
# Unanswered questions about this document. A published document with an
|
||||||
|
# open question is readable but not settled, and every surface that shows
|
||||||
|
# the document says so — including the sources under a chat answer.
|
||||||
|
open_reviews: int
|
||||||
|
# Shipped with the product: read-only, and never deletable.
|
||||||
|
is_builtin: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentRef(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewOut(BaseModel):
|
||||||
|
"""One request to check this document. Open while `resolved_at` is null."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
question: str | None
|
||||||
|
requester_name: str | None
|
||||||
|
reviewer_id: uuid.UUID | None
|
||||||
|
reviewer_name: str | None
|
||||||
|
created_at: datetime
|
||||||
|
resolved_at: datetime | None
|
||||||
|
resolved_by_name: str | None
|
||||||
|
# Whether the caller is the one being asked, so the UI can offer the
|
||||||
|
# answer rather than just showing the question.
|
||||||
|
is_mine: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDetail(DocumentSummary):
|
||||||
|
content_md: str
|
||||||
|
# Every request on this document, oldest first, open and answered — the
|
||||||
|
# answered ones are the record of what was already checked.
|
||||||
|
reviews: list[ReviewOut] = []
|
||||||
|
# Additional departments the document is shared with, on top of its owning
|
||||||
|
# `department_id` (the `doc_permissions` grants). Resolved where the endpoint
|
||||||
|
# looks it up (get_document, the departments endpoint).
|
||||||
|
shared_departments: list[DepartmentRef] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSort(StrEnum):
|
||||||
|
"""How the browse list is ordered. Deliberately two options: "what
|
||||||
|
changed" and "what is new" are the two questions people actually ask of
|
||||||
|
a document list."""
|
||||||
|
|
||||||
|
updated = "updated"
|
||||||
|
created = "created"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPage(BaseModel):
|
||||||
|
items: list[DocumentSummary]
|
||||||
|
# Total matching the filters, not the page — the UI needs it to know
|
||||||
|
# whether there is a next page at all.
|
||||||
|
total: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSearchHit(DocumentSummary):
|
||||||
|
"""A search result: the document plus the section that matched.
|
||||||
|
|
||||||
|
Empty `heading_path` means the match was on the title, not a section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
heading_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentStats(BaseModel):
|
||||||
|
"""Company-wide counts, read by the landing page's first-run guide.
|
||||||
|
|
||||||
|
Aggregates only — no titles, no per-user data. Deliberately not
|
||||||
|
permission-filtered: a bare count reveals nothing about content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
documents_total: int
|
||||||
|
departments_total: int
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCreate(BaseModel):
|
||||||
|
"""Start a new document the user will write in the editor.
|
||||||
|
|
||||||
|
With a `template_id` the draft opens on that template's Markdown skeleton
|
||||||
|
and title; without one it starts blank and `title` is required. The result
|
||||||
|
is a `draft` — author-only and never indexed until it is published."""
|
||||||
|
|
||||||
|
template_id: uuid.UUID | None = None
|
||||||
|
title: str | None = None
|
||||||
|
visibility: DocumentVisibility | None = None
|
||||||
|
# When the capture started from a chat: its subject is summarized and kept
|
||||||
|
# on the draft as background for section refinement.
|
||||||
|
conversation_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
content_md: str | None = None
|
||||||
|
visibility: DocumentVisibility | None = None
|
||||||
|
# Only the archive transition is settable here; publishing has its own
|
||||||
|
# endpoint, because it indexes the document.
|
||||||
|
status: DocumentStatus | None = None
|
||||||
|
# An admin may knowingly make a change that removes their own access; an
|
||||||
|
# author never can (they keep access as author). See access.guard_self_lockout.
|
||||||
|
# Nullable (not `bool = False`) so it stays optional in the generated client.
|
||||||
|
confirm_lockout: bool | None = None
|
||||||
|
# Continuing an EXISTING document out of a chat: the same background the
|
||||||
|
# create path attaches, for the document that already covers the topic.
|
||||||
|
conversation_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDepartments(BaseModel):
|
||||||
|
"""The full set of ADDITIONAL departments the document is shared with (on
|
||||||
|
top of the owning department) — replaces the existing grants."""
|
||||||
|
|
||||||
|
department_ids: list[uuid.UUID]
|
||||||
|
confirm_lockout: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewerCandidate(BaseModel):
|
||||||
|
"""A user the author may ask to check a document — id + name only."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRequestBody(BaseModel):
|
||||||
|
"""Ask someone to check this document, optionally about something specific
|
||||||
|
("do the holiday numbers still hold?")."""
|
||||||
|
|
||||||
|
reviewer_id: uuid.UUID
|
||||||
|
question: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentEventOut(BaseModel):
|
||||||
|
"""One entry in a document's history timeline — metadata only."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
action: DocumentEventAction
|
||||||
|
actor_id: uuid.UUID | None
|
||||||
|
# Null once the actor's account is deleted (SET NULL on the event).
|
||||||
|
actor_name: str | None
|
||||||
|
visibility: DocumentVisibility | None
|
||||||
|
created_at: datetime
|
||||||
|
# A content snapshot exists for this event and can be fetched for diffing.
|
||||||
|
has_snapshot: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentVersion(BaseModel):
|
||||||
|
"""A past version's frozen content, for viewing or diffing.
|
||||||
|
|
||||||
|
A snapshot is taken *after* its event, so `content_md` is the state this
|
||||||
|
event produced and `previous_content_md` the state it started from — the
|
||||||
|
pair is what "what did this change do?" needs. `previous_content_md` is
|
||||||
|
null for the first snapshot, where everything was added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
action: DocumentEventAction
|
||||||
|
actor_id: uuid.UUID | None
|
||||||
|
actor_name: str | None
|
||||||
|
created_at: datetime
|
||||||
|
title: str | None
|
||||||
|
content_md: str | None
|
||||||
|
previous_content_md: str | None
|
||||||
|
visibility: DocumentVisibility | None
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Sharing a document with departments beyond its own.
|
||||||
|
|
||||||
|
Management, not new permission logic: the read filter's EXISTS branch already
|
||||||
|
unions `doc_permissions` in, so this endpoint only maintains those rows. Grants
|
||||||
|
are evaluated live against the table, which is why nothing is reindexed here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.documents.access import (
|
||||||
|
guard_self_lockout,
|
||||||
|
readable_document,
|
||||||
|
require_author_or_admin,
|
||||||
|
)
|
||||||
|
from app.api.documents.routing import documents_router
|
||||||
|
from app.api.documents.schemas import DocumentDepartments, DocumentDetail
|
||||||
|
from app.api.documents.view import full_detail, granted_department_ids
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Department, DocPermission, PermissionLevel, User
|
||||||
|
|
||||||
|
router = documents_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{document_id}/departments")
|
||||||
|
async def set_shared_departments(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
body: DocumentDepartments,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
"""Replace the full set of ADDITIONAL departments this document is shared
|
||||||
|
with. Author or admin only."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_author_or_admin(document, user)
|
||||||
|
|
||||||
|
requested = set(body.department_ids)
|
||||||
|
# A document is never "shared with" its own owning department.
|
||||||
|
requested.discard(document.department_id)
|
||||||
|
if requested:
|
||||||
|
found = set(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Department.id).where(Department.id.in_(requested))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if requested - found:
|
||||||
|
raise ApiError(404, "One or more departments do not exist.", "not_found")
|
||||||
|
|
||||||
|
# Removing a grant can drop the editing admin's own department access.
|
||||||
|
guard_self_lockout(
|
||||||
|
user,
|
||||||
|
author_id=document.author_id,
|
||||||
|
visibility=document.visibility,
|
||||||
|
department_id=document.department_id,
|
||||||
|
granted_department_ids=requested,
|
||||||
|
confirm=bool(body.confirm_lockout),
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = await granted_department_ids(db, document.id)
|
||||||
|
for dept_id in existing - requested:
|
||||||
|
await db.execute(
|
||||||
|
delete(DocPermission).where(
|
||||||
|
DocPermission.document_id == document.id,
|
||||||
|
DocPermission.department_id == dept_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for dept_id in requested - existing:
|
||||||
|
db.add(
|
||||||
|
DocPermission(
|
||||||
|
document_id=document.id,
|
||||||
|
department_id=dept_id,
|
||||||
|
level=PermissionLevel.read,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await full_detail(db, document, user)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Document rows to API shapes.
|
||||||
|
|
||||||
|
Every endpoint in the package answers with `summary` or `detail`, so the
|
||||||
|
per-request fields (why this user sees it, what they may do, what is still
|
||||||
|
open) are computed in exactly one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.documents.access import access_reason, can_edit
|
||||||
|
from app.api.documents.schemas import (
|
||||||
|
DepartmentRef,
|
||||||
|
DocumentDetail,
|
||||||
|
DocumentSummary,
|
||||||
|
ReviewOut,
|
||||||
|
)
|
||||||
|
from app.models import Department, DocPermission, Document, ReviewRequest, User
|
||||||
|
|
||||||
|
|
||||||
|
def document_fields(document: Document, user: User) -> dict[str, Any]:
|
||||||
|
"""Built explicitly rather than via model_validate: access_reason and
|
||||||
|
can_edit are per-request, so there is nothing to read them from."""
|
||||||
|
return {
|
||||||
|
"id": document.id,
|
||||||
|
"title": document.title,
|
||||||
|
"status": document.status,
|
||||||
|
"visibility": document.visibility,
|
||||||
|
"department_id": document.department_id,
|
||||||
|
"created_at": document.created_at,
|
||||||
|
"updated_at": document.updated_at,
|
||||||
|
"access_reason": access_reason(document, user),
|
||||||
|
"can_edit": can_edit(document, user),
|
||||||
|
"open_reviews": len(document.open_reviews),
|
||||||
|
"is_builtin": document.is_builtin,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summary(document: Document, user: User) -> DocumentSummary:
|
||||||
|
return DocumentSummary(**document_fields(document, user))
|
||||||
|
|
||||||
|
|
||||||
|
def detail(
|
||||||
|
document: Document,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
reviews: list[ReviewOut] | None = None,
|
||||||
|
shared_departments: list[DepartmentRef] | None = None,
|
||||||
|
) -> DocumentDetail:
|
||||||
|
return DocumentDetail(
|
||||||
|
**document_fields(document, user),
|
||||||
|
content_md=document.content_md,
|
||||||
|
reviews=reviews or [],
|
||||||
|
shared_departments=shared_departments or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_reviews(
|
||||||
|
db: AsyncSession, document: Document, user: User
|
||||||
|
) -> list[ReviewOut]:
|
||||||
|
"""The document's requests with the names filled in.
|
||||||
|
|
||||||
|
One query for every name involved, rather than three relationships loaded
|
||||||
|
with every document: the names are needed on the detail page only, while
|
||||||
|
the requests themselves ride along everywhere (they decide who may edit).
|
||||||
|
"""
|
||||||
|
if not document.reviews:
|
||||||
|
return []
|
||||||
|
wanted = {
|
||||||
|
person_id
|
||||||
|
for review in document.reviews
|
||||||
|
for person_id in (
|
||||||
|
review.requester_id,
|
||||||
|
review.reviewer_id,
|
||||||
|
review.resolved_by_id,
|
||||||
|
)
|
||||||
|
if person_id is not None
|
||||||
|
}
|
||||||
|
names = dict(
|
||||||
|
(await db.execute(select(User.id, User.name).where(User.id.in_(wanted)))).all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ReviewOut(
|
||||||
|
id=review.id,
|
||||||
|
question=review.question,
|
||||||
|
requester_name=names.get(review.requester_id),
|
||||||
|
reviewer_id=review.reviewer_id,
|
||||||
|
reviewer_name=names.get(review.reviewer_id),
|
||||||
|
created_at=review.created_at,
|
||||||
|
resolved_at=review.resolved_at,
|
||||||
|
resolved_by_name=names.get(review.resolved_by_id),
|
||||||
|
is_mine=review.reviewer_id == user.id,
|
||||||
|
)
|
||||||
|
for review in document.reviews
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_shared_departments(
|
||||||
|
db: AsyncSession, document: Document
|
||||||
|
) -> list[DepartmentRef]:
|
||||||
|
"""The additional departments this document is shared with (its
|
||||||
|
`doc_permissions` grants), resolved to names for display."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Department.id, Department.name)
|
||||||
|
.join(DocPermission, DocPermission.department_id == Department.id)
|
||||||
|
.where(DocPermission.document_id == document.id)
|
||||||
|
.order_by(Department.name)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [DepartmentRef(id=row.id, name=row.name) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def granted_department_ids(
|
||||||
|
db: AsyncSession, document_id: uuid.UUID
|
||||||
|
) -> set[uuid.UUID]:
|
||||||
|
return set(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(DocPermission.department_id).where(
|
||||||
|
DocPermission.document_id == document_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def full_detail(
|
||||||
|
db: AsyncSession, document: Document, user: User
|
||||||
|
) -> DocumentDetail:
|
||||||
|
"""The detail with everything resolved — for the endpoints that answer
|
||||||
|
with a document the UI is about to render in full."""
|
||||||
|
return detail(
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
reviews=await resolve_reviews(db, document, user),
|
||||||
|
shared_departments=await resolve_shared_departments(db, document),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_review_for(
|
||||||
|
db: AsyncSession, document: Document, reviewer_id: uuid.UUID
|
||||||
|
) -> ReviewRequest | None:
|
||||||
|
"""An unanswered request on this document addressed to `reviewer_id`."""
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
select(ReviewRequest).where(
|
||||||
|
ReviewRequest.document_id == document.id,
|
||||||
|
ReviewRequest.reviewer_id == reviewer_id,
|
||||||
|
ReviewRequest.resolved_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""From draft to published, and the questions that hang off a document.
|
||||||
|
|
||||||
|
Two things that used to be one. **Publishing** is the author's own decision: a
|
||||||
|
draft is private until they say it is worth reading, one action, no waiting.
|
||||||
|
**A review request** is "please check this", and it is not a status — it can
|
||||||
|
sit on a draft the author is unsure about OR on a document that has been
|
||||||
|
published for months, and it marks the document wherever it appears until
|
||||||
|
someone answers it.
|
||||||
|
|
||||||
|
Being asked is what grants the right to edit: a reviewer who spots a wrong
|
||||||
|
number should fix it rather than file a second question about it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.documents.access import (
|
||||||
|
readable_document,
|
||||||
|
require_author_or_admin,
|
||||||
|
require_editor,
|
||||||
|
)
|
||||||
|
from app.api.documents.routing import documents_router
|
||||||
|
from app.api.documents.schemas import (
|
||||||
|
DocumentDetail,
|
||||||
|
ReviewerCandidate,
|
||||||
|
ReviewRequestBody,
|
||||||
|
)
|
||||||
|
from app.api.documents.view import full_detail
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.authoring.history import record_event
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.ingestion.handlers import INDEX_DOCUMENT
|
||||||
|
from app.ingestion.queue import enqueue
|
||||||
|
from app.models import DocumentEventAction, DocumentStatus, ReviewRequest, User
|
||||||
|
from app.rag.permissions import document_reader_filter
|
||||||
|
|
||||||
|
router = documents_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/publish")
|
||||||
|
async def publish_document(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
"""Make a draft readable and searchable for everyone its visibility allows.
|
||||||
|
|
||||||
|
The author's own call — an open question about the content does not block
|
||||||
|
it, it travels with the document instead (`open_reviews`), which is what
|
||||||
|
lets a colleague read it AND know it is not settled.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_author_or_admin(document, user)
|
||||||
|
if document.status != DocumentStatus.draft:
|
||||||
|
raise ApiError(409, "Only a draft can be published.", "invalid_status")
|
||||||
|
|
||||||
|
document.status = DocumentStatus.published
|
||||||
|
record_event(db, document, user, DocumentEventAction.published)
|
||||||
|
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
|
||||||
|
await db.commit()
|
||||||
|
return await full_detail(db, document, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/reviewers")
|
||||||
|
async def list_reviewers(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[ReviewerCandidate]:
|
||||||
|
"""Who can be asked: everyone who could read this document once published,
|
||||||
|
minus the author. Permission-safe and non-admin (unlike /admin/users), and
|
||||||
|
only id + name leave the server."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_editor(document, user)
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(User.id, User.name)
|
||||||
|
.where(document_reader_filter(document), User.id != document.author_id)
|
||||||
|
.order_by(User.name)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [ReviewerCandidate(id=row.id, name=row.name) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/reviews")
|
||||||
|
async def request_review(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
body: ReviewRequestBody,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
"""Ask a colleague to check this document, optionally about something
|
||||||
|
specific. The request grants them the right to read and edit it until it
|
||||||
|
is answered."""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
require_author_or_admin(document, user)
|
||||||
|
if body.reviewer_id == user.id:
|
||||||
|
raise ApiError(422, "You cannot ask yourself.", "invalid_reviewer")
|
||||||
|
|
||||||
|
allowed = (
|
||||||
|
await db.execute(
|
||||||
|
select(User.id).where(
|
||||||
|
User.id == body.reviewer_id,
|
||||||
|
document_reader_filter(document),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if allowed is None:
|
||||||
|
raise ApiError(
|
||||||
|
422, "That user cannot review this document.", "invalid_reviewer"
|
||||||
|
)
|
||||||
|
if any(review.reviewer_id == body.reviewer_id for review in document.open_reviews):
|
||||||
|
raise ApiError(
|
||||||
|
409, "That colleague has already been asked.", "review_already_open"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ReviewRequest(
|
||||||
|
document_id=document.id,
|
||||||
|
requester_id=user.id,
|
||||||
|
reviewer_id=body.reviewer_id,
|
||||||
|
question=(body.question or "").strip() or None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record_event(db, document, user, DocumentEventAction.review_requested)
|
||||||
|
await db.commit()
|
||||||
|
# Reload the collection, not just the columns: the serializer reads the
|
||||||
|
# requests, and a lazy load there would be IO in a sync property.
|
||||||
|
await db.refresh(document, attribute_names=["reviews"])
|
||||||
|
return await full_detail(db, document, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/reviews/{review_id}/resolve")
|
||||||
|
async def resolve_review(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
review_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> DocumentDetail:
|
||||||
|
"""Answer a request: the content was checked.
|
||||||
|
|
||||||
|
The reviewer answers their own request; the author (or an admin) can close
|
||||||
|
one that has become moot, because a question nobody will answer should not
|
||||||
|
mark a document forever.
|
||||||
|
"""
|
||||||
|
document = await readable_document(db, document_id, user)
|
||||||
|
review = next(
|
||||||
|
(review for review in document.reviews if review.id == review_id), None
|
||||||
|
)
|
||||||
|
if review is None:
|
||||||
|
raise ApiError(404, "Review request not found.", "not_found")
|
||||||
|
if review.resolved_at is not None:
|
||||||
|
raise ApiError(409, "This request is already answered.", "already_resolved")
|
||||||
|
if review.reviewer_id != user.id:
|
||||||
|
require_author_or_admin(document, user)
|
||||||
|
|
||||||
|
review.resolved_at = datetime.now(UTC)
|
||||||
|
review.resolved_by_id = user.id
|
||||||
|
record_event(db, document, user, DocumentEventAction.review_resolved)
|
||||||
|
await db.commit()
|
||||||
|
# Reload the collection, not just the columns: the serializer reads the
|
||||||
|
# requests, and a lazy load there would be IO in a sync property.
|
||||||
|
await db.refresh(document, attribute_names=["reviews"])
|
||||||
|
return await full_detail(db, document, user)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""The colleague directory.
|
||||||
|
|
||||||
|
A member-visible directory any authenticated user may browse: colleagues'
|
||||||
|
`{id, name, role, department}` — no email, no password hash. The same
|
||||||
|
permission-safe, non-admin shape as `ReviewerCandidate` in `api/documents.py`,
|
||||||
|
deliberately separate from the admin-only `/admin/users`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Department, User, UserRole
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/people", tags=["people"])
|
||||||
|
|
||||||
|
|
||||||
|
class PersonOut(BaseModel):
|
||||||
|
"""A colleague as the directory shows them — never email or credentials."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
role: UserRole
|
||||||
|
department: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _select_people():
|
||||||
|
return select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
User.role,
|
||||||
|
Department.name.label("department"),
|
||||||
|
).join(Department, User.department_id == Department.id, isouter=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _person(row) -> PersonOut:
|
||||||
|
return PersonOut(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
role=row.role,
|
||||||
|
department=row.department,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_people(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[PersonOut]:
|
||||||
|
"""Every colleague, ordered by name. Visible to any authenticated user."""
|
||||||
|
rows = (await db.execute(_select_people().order_by(User.name))).all()
|
||||||
|
return [_person(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{person_id}")
|
||||||
|
async def get_person(
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> PersonOut:
|
||||||
|
"""One colleague's profile."""
|
||||||
|
row = (await db.execute(_select_people().where(User.id == person_id))).first()
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "Person not found.", "not_found")
|
||||||
|
return _person(row)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Server-sent events, the one way this API streams.
|
||||||
|
|
||||||
|
Two endpoints stream (a chat turn and a section refinement) and they frame the
|
||||||
|
same way, so the wire format lives here rather than in both. `event:` names the frame, `data:` carries a JSON object, and
|
||||||
|
a blank line ends it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def sse(event: str, data: dict[str, Any]) -> str:
|
||||||
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""The templates API: the blueprints a document can be started from.
|
||||||
|
|
||||||
|
Reading is for everyone (the picker needs it), changing is admin-only, and the
|
||||||
|
two live on separate routers so the gate is structural rather than repeated per
|
||||||
|
endpoint. `catalog` is what ships with the product, `edit` what the customer
|
||||||
|
made of it.
|
||||||
|
|
||||||
|
**Route order matters.** The catalog's static paths are registered before
|
||||||
|
`/{template_id}`, or "catalog" would be parsed as a row id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.templates import browse, catalog, edit
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(catalog.router)
|
||||||
|
router.include_router(edit.router)
|
||||||
|
router.include_router(browse.router)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""A template's blueprint: parsing it, and keeping its id unique.
|
||||||
|
|
||||||
|
The config id is a slug, not a row id — it is what documents record as their
|
||||||
|
origin and what the catalog matches on, so two rows must never share one.
|
||||||
|
Three endpoints need that rule (add from catalog, save from the builder,
|
||||||
|
duplicate), which is why it is written once here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.authoring.schema import AuthoringTemplate
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Template
|
||||||
|
from app.template_import import TemplateImportError, parse_template
|
||||||
|
|
||||||
|
|
||||||
|
def parse_or_422(source: str) -> AuthoringTemplate:
|
||||||
|
"""YAML in, validated blueprint out. The parse error is the message: it
|
||||||
|
already says which field is wrong, and an admin is the one reading it."""
|
||||||
|
try:
|
||||||
|
return parse_template(source)
|
||||||
|
except TemplateImportError as exc:
|
||||||
|
raise ApiError(422, str(exc), "invalid_template") from None
|
||||||
|
|
||||||
|
|
||||||
|
async def taken_config_ids(db: AsyncSession) -> set[str]:
|
||||||
|
return set((await db.execute(select(Template.config["id"].astext))).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def unique_config_id(base: str, taken: set[str], *, suffix: str = "") -> str:
|
||||||
|
"""`base`, or `base-2`, `base-3` … until it is free.
|
||||||
|
|
||||||
|
`suffix` marks derived ids (a duplicate becomes `base-kopie`), so a copy
|
||||||
|
reads as a copy in the one place ids are visible.
|
||||||
|
"""
|
||||||
|
stem = f"{base}{suffix}"
|
||||||
|
candidate = stem
|
||||||
|
counter = 2
|
||||||
|
while candidate in taken:
|
||||||
|
candidate = f"{stem}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_config_id_free(
|
||||||
|
db: AsyncSession, config_id: str, *, except_row: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
"""Refuse an edit that would move a config id onto a DIFFERENT row.
|
||||||
|
|
||||||
|
Editing keeps the id stable, so a clash is never the row's own id — it
|
||||||
|
means someone would silently steal another template's identity.
|
||||||
|
"""
|
||||||
|
clash = (
|
||||||
|
await db.execute(
|
||||||
|
select(Template.id).where(
|
||||||
|
Template.config["id"].astext == config_id,
|
||||||
|
Template.id != except_row,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if clash is not None:
|
||||||
|
raise ApiError(
|
||||||
|
409, f"Another template already uses the id '{config_id}'.", "id_taken"
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Reading templates. Any authenticated user: the picker needs them."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.templates.routing import reader_router
|
||||||
|
from app.api.templates.schemas import TemplateDetail, TemplateSummary
|
||||||
|
from app.api.templates.view import detail, summary
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Template, User
|
||||||
|
|
||||||
|
router = reader_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_templates(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[TemplateSummary]:
|
||||||
|
"""Templates the picker offers. Templates in the reader's language come
|
||||||
|
first — they are customer content, so a mismatched one is still listed
|
||||||
|
rather than hidden."""
|
||||||
|
rows = (await db.execute(select(Template).order_by(Template.name))).scalars().all()
|
||||||
|
wanted = user.locale or get_settings().default_locale
|
||||||
|
ordered = sorted(rows, key=lambda row: row.config.get("locale") != wanted)
|
||||||
|
return [summary(row) for row in ordered]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}")
|
||||||
|
async def get_template(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TemplateDetail:
|
||||||
|
row = await db.get(Template, template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "Template not found.", "not_found")
|
||||||
|
return detail(row)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""The blueprints that ship with the product.
|
||||||
|
|
||||||
|
Nothing in the catalog is active. It is a shelf an admin takes from: adding a
|
||||||
|
blueprint copies it into an ordinary template row, which the customer then owns
|
||||||
|
and edits. The catalog never touches that row again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.templates.blueprints import parse_or_422, taken_config_ids
|
||||||
|
from app.api.templates.routing import editor_router
|
||||||
|
from app.api.templates.schemas import CatalogDetail, CatalogSummary, TemplateDetail
|
||||||
|
from app.api.templates.view import detail
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.template_catalog import catalog_for_locale, get_catalog_entry
|
||||||
|
from app.template_import import upsert_template
|
||||||
|
|
||||||
|
router = editor_router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
async def list_catalog(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> list[CatalogSummary]:
|
||||||
|
"""The blueprints shipped with Pablan, each marked with whether this
|
||||||
|
instance has already added it."""
|
||||||
|
added = await taken_config_ids(db)
|
||||||
|
return [
|
||||||
|
CatalogSummary(
|
||||||
|
id=entry.id,
|
||||||
|
name=entry.name,
|
||||||
|
description=entry.description,
|
||||||
|
sections=entry.sections,
|
||||||
|
added=entry.id in added,
|
||||||
|
)
|
||||||
|
for entry in catalog_for_locale()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog/{catalog_id}")
|
||||||
|
async def get_catalog_blueprint(catalog_id: str) -> CatalogDetail:
|
||||||
|
"""Read a blueprint before adding it — the whole point of "view" is that
|
||||||
|
an admin can see its structure before committing to it."""
|
||||||
|
entry = get_catalog_entry(catalog_id)
|
||||||
|
if entry is None:
|
||||||
|
raise ApiError(404, "Blueprint not found.", "not_found")
|
||||||
|
return CatalogDetail(
|
||||||
|
id=entry.id,
|
||||||
|
name=entry.name,
|
||||||
|
description=entry.description,
|
||||||
|
sections=entry.sections,
|
||||||
|
added=False,
|
||||||
|
yaml=entry.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/catalog/{catalog_id}")
|
||||||
|
async def add_from_catalog(
|
||||||
|
catalog_id: str,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TemplateDetail:
|
||||||
|
"""Copy a blueprint into this instance. The result is an ordinary
|
||||||
|
template row: editable, and never touched by the catalog again."""
|
||||||
|
entry = get_catalog_entry(catalog_id)
|
||||||
|
if entry is None:
|
||||||
|
raise ApiError(404, "Blueprint not found.", "not_found")
|
||||||
|
if entry.id in await taken_config_ids(db):
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"This template has already been added — edit or duplicate it instead.",
|
||||||
|
"already_added",
|
||||||
|
)
|
||||||
|
row, _created = await upsert_template(db, parse_or_422(entry.source))
|
||||||
|
await db.commit()
|
||||||
|
return detail(row)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Changing what this instance offers. Admin only, by the router it hangs on.
|
||||||
|
|
||||||
|
Two ways in, one guarantee: the form builder sends a structured config and the
|
||||||
|
YAML editor sends text, but both end as the same validated blueprint, so
|
||||||
|
neither path can save something the other would reject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.templates.blueprints import (
|
||||||
|
ensure_config_id_free,
|
||||||
|
parse_or_422,
|
||||||
|
taken_config_ids,
|
||||||
|
unique_config_id,
|
||||||
|
)
|
||||||
|
from app.api.templates.routing import editor_router
|
||||||
|
from app.api.templates.schemas import (
|
||||||
|
TemplateBuildRequest,
|
||||||
|
TemplateDetail,
|
||||||
|
TemplateImportRequest,
|
||||||
|
)
|
||||||
|
from app.api.templates.view import detail
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import Template
|
||||||
|
|
||||||
|
router = editor_router()
|
||||||
|
|
||||||
|
# What a template's config id falls back to when the form has nothing to slug.
|
||||||
|
FALLBACK_ID = "vorlage"
|
||||||
|
|
||||||
|
|
||||||
|
async def _row_or_404(db: AsyncSession, template_id: uuid.UUID) -> Template:
|
||||||
|
row = await db.get(Template, template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "Template not found.", "not_found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/build")
|
||||||
|
async def build_template(
|
||||||
|
body: TemplateBuildRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TemplateDetail:
|
||||||
|
"""Save a template from the structured form builder. Creates a new row
|
||||||
|
(template_id null) or updates one in place."""
|
||||||
|
template = body.config
|
||||||
|
config = template.model_dump(mode="json")
|
||||||
|
|
||||||
|
if body.template_id is None:
|
||||||
|
# The config id is an internal slug the form derives from the name;
|
||||||
|
# make it unique so a second "Onboarding" never overwrites the first.
|
||||||
|
config["id"] = unique_config_id(
|
||||||
|
template.id or FALLBACK_ID, await taken_config_ids(db)
|
||||||
|
)
|
||||||
|
row = Template(name=template.name, version=template.version, config=config)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
await ensure_config_id_free(db, template.id, except_row=body.template_id)
|
||||||
|
row = await _row_or_404(db, body.template_id)
|
||||||
|
row.name = template.name
|
||||||
|
row.version = template.version
|
||||||
|
row.config = config
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return detail(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}")
|
||||||
|
async def update_template(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
body: TemplateImportRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TemplateDetail:
|
||||||
|
"""Replace a template's YAML. Validated against the schema on save."""
|
||||||
|
row = await _row_or_404(db, template_id)
|
||||||
|
template = parse_or_422(body.yaml)
|
||||||
|
await ensure_config_id_free(db, template.id, except_row=row.id)
|
||||||
|
|
||||||
|
row.name = template.name
|
||||||
|
row.version = template.version
|
||||||
|
row.config = template.model_dump(mode="json")
|
||||||
|
await db.commit()
|
||||||
|
return detail(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _copy_name(db: AsyncSession, name: str) -> str:
|
||||||
|
"""The next free "<name> (2)".
|
||||||
|
|
||||||
|
A number rather than a word, because this name is shown in the interface
|
||||||
|
and the backend never renders UI-language strings (CLAUDE.md) — a German
|
||||||
|
"(Kopie)" would sit untranslated in an English admin panel. It is also
|
||||||
|
what file managers do, so it needs no explaining.
|
||||||
|
"""
|
||||||
|
taken = set((await db.execute(select(Template.name))).scalars().all())
|
||||||
|
counter = 2
|
||||||
|
while f"{name} ({counter})" in taken:
|
||||||
|
counter += 1
|
||||||
|
return f"{name} ({counter})"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{template_id}/duplicate")
|
||||||
|
async def duplicate_template(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TemplateDetail:
|
||||||
|
"""Fork a template — for trying a variant without losing the original.
|
||||||
|
The copy gets a fresh config id so the two never collide."""
|
||||||
|
row = await _row_or_404(db, template_id)
|
||||||
|
config_id = unique_config_id(
|
||||||
|
row.config.get("id", "template"), await taken_config_ids(db), suffix="-copy"
|
||||||
|
)
|
||||||
|
config = {**row.config, "id": config_id, "name": await _copy_name(db, row.name)}
|
||||||
|
copy = Template(name=config["name"], version=row.version, config=config)
|
||||||
|
db.add(copy)
|
||||||
|
await db.commit()
|
||||||
|
return detail(copy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}", status_code=204)
|
||||||
|
async def delete_template(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> None:
|
||||||
|
"""Remove a template. Documents created from it are independent and
|
||||||
|
survive (a template is only a starting point). If it came from the
|
||||||
|
catalog it can always be added back."""
|
||||||
|
row = await _row_or_404(db, template_id)
|
||||||
|
await db.delete(row)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Two routers, because templates have two audiences.
|
||||||
|
|
||||||
|
Everyone may READ the templates (the picker needs them); only an admin may
|
||||||
|
change what the instance offers. Expressing that as two constructors means a
|
||||||
|
new endpoint is gated by which router it is added to, not by remembering to
|
||||||
|
repeat a dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth.deps import require_admin
|
||||||
|
|
||||||
|
|
||||||
|
def reader_router() -> APIRouter:
|
||||||
|
return APIRouter(prefix="/templates", tags=["templates"])
|
||||||
|
|
||||||
|
|
||||||
|
def editor_router() -> APIRouter:
|
||||||
|
return APIRouter(
|
||||||
|
prefix="/templates", tags=["templates"], dependencies=[Depends(require_admin)]
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Request and response shapes for templates and the shipped catalog."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.authoring.schema import AuthoringTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSummary(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
# The blueprint id from the config (e.g. "onboarding-basis"): stable across
|
||||||
|
# installs, where the row id is not. Anything that wants to offer ONE known
|
||||||
|
# blueprint (the profile page's "write about yourself") finds it by this.
|
||||||
|
config_id: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateDetail(TemplateSummary):
|
||||||
|
config: dict[str, Any]
|
||||||
|
# The editable source. Serialized server-side because the frontend has
|
||||||
|
# no YAML library and must not gain one.
|
||||||
|
yaml: str
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogSummary(BaseModel):
|
||||||
|
"""A blueprint on disk. `id` is the config id, NOT a row id — a catalog
|
||||||
|
entry has no row until someone adds it."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
# How many skeleton sections the blueprint carries hints for.
|
||||||
|
sections: int
|
||||||
|
# Whether a template with this config id already exists, so the UI can
|
||||||
|
# offer "View" instead of a second "Add".
|
||||||
|
added: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogDetail(CatalogSummary):
|
||||||
|
yaml: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateImportRequest(BaseModel):
|
||||||
|
yaml: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateBuildRequest(BaseModel):
|
||||||
|
"""A template assembled by the form builder. The config is the same schema
|
||||||
|
a pasted YAML parses into, so both paths get one validation guarantee —
|
||||||
|
the frontend has no YAML library and must not gain one, so it sends the
|
||||||
|
structured config instead of serializing it."""
|
||||||
|
|
||||||
|
# The row to update, or null to create a new template. Kept separate from
|
||||||
|
# the config id (a stable slug) so renaming the display name never forks
|
||||||
|
# the row.
|
||||||
|
template_id: uuid.UUID | None = None
|
||||||
|
config: AuthoringTemplate
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Template rows to API shapes.
|
||||||
|
|
||||||
|
Both are built explicitly rather than validated from the row: the blueprint id
|
||||||
|
and the description live inside `config`, and `yaml` is rendered per request,
|
||||||
|
so there is nothing on the row to read them from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.api.templates.schemas import TemplateDetail, TemplateSummary
|
||||||
|
from app.models import Template
|
||||||
|
|
||||||
|
|
||||||
|
def summary(row: Template) -> TemplateSummary:
|
||||||
|
return TemplateSummary(
|
||||||
|
id=row.id,
|
||||||
|
config_id=row.config.get("id", ""),
|
||||||
|
name=row.name,
|
||||||
|
version=row.version,
|
||||||
|
description=row.config.get("description", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detail(row: Template) -> TemplateDetail:
|
||||||
|
return TemplateDetail(
|
||||||
|
id=row.id,
|
||||||
|
config_id=row.config.get("id", ""),
|
||||||
|
name=row.name,
|
||||||
|
version=row.version,
|
||||||
|
description=row.config.get("description", ""),
|
||||||
|
config=row.config,
|
||||||
|
yaml=yaml.safe_dump(row.config, allow_unicode=True, sort_keys=False, width=80),
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.sessions import COOKIE_NAME, get_valid_session
|
||||||
|
from app.db import get_db
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.models import AuthSession, User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_auth_session(
|
||||||
|
request: Request, db: Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
) -> AuthSession:
|
||||||
|
raw = request.cookies.get(COOKIE_NAME)
|
||||||
|
if raw is None:
|
||||||
|
raise ApiError(401, "Not authenticated.", "not_authenticated")
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ApiError(401, "Not authenticated.", "not_authenticated") from None
|
||||||
|
session = await get_valid_session(db, session_id)
|
||||||
|
if session is None:
|
||||||
|
raise ApiError(401, "Not authenticated.", "not_authenticated")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
session: Annotated[AuthSession, Depends(get_current_auth_session)],
|
||||||
|
) -> User:
|
||||||
|
return session.user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
if user.role != UserRole.admin:
|
||||||
|
raise ApiError(403, "Admin privileges required.", "forbidden")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerificationError
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
# Verified against when the user does not exist, so login duration does not
|
||||||
|
# reveal whether an email address is registered.
|
||||||
|
_DUMMY_HASH = _hasher.hash("pablan-dummy-password")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except (VerificationError, InvalidHashError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def burn_verification_time() -> None:
|
||||||
|
verify_password(_DUMMY_HASH, "wrong-password")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import Response
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import AuthSession, User
|
||||||
|
|
||||||
|
COOKIE_NAME = "pablan_session"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_auth_session(db: AsyncSession, user: User) -> AuthSession:
|
||||||
|
settings = get_settings()
|
||||||
|
session = AuthSession(
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(days=settings.auth_session_ttl_days),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.flush()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def get_valid_session(
|
||||||
|
db: AsyncSession, session_id: uuid.UUID
|
||||||
|
) -> AuthSession | None:
|
||||||
|
session = await db.get(AuthSession, session_id)
|
||||||
|
if session is None or session.expires_at <= datetime.now(UTC):
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_user_sessions(
|
||||||
|
db: AsyncSession, user_id: uuid.UUID, *, keep_session_id: uuid.UUID | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Log a user out everywhere — the session-revocation primitive.
|
||||||
|
|
||||||
|
`keep_session_id` spares the caller's own session, which is what a
|
||||||
|
self-service password change wants: every other device is logged out,
|
||||||
|
the one you are typing on is not.
|
||||||
|
"""
|
||||||
|
statement = delete(AuthSession).where(AuthSession.user_id == user_id)
|
||||||
|
if keep_session_id is not None:
|
||||||
|
statement = statement.where(AuthSession.id != keep_session_id)
|
||||||
|
await db.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_cookie(response: Response, session: AuthSession) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME,
|
||||||
|
str(session.id),
|
||||||
|
max_age=settings.auth_session_ttl_days * 24 * 60 * 60,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_cookie(response: Response) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
response.delete_cookie(
|
||||||
|
COOKIE_NAME,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Writing-first knowledge capture.
|
||||||
|
|
||||||
|
Capture is not a conversation Mode: the artifact is a Document the user
|
||||||
|
writes directly (Markdown is the source of truth), and the model
|
||||||
|
refines one section at a time (FIM-style). This package holds the template
|
||||||
|
schema, the skeleton/title rendering, the active-section boundary and the
|
||||||
|
refinement prompt. The HTTP surface lives in `app/api/authoring.py`.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Conversation context for a capture.
|
||||||
|
|
||||||
|
When a document is written out of a chat, that chat's subject is useful twice:
|
||||||
|
to find existing documents the user might extend, and as background for section
|
||||||
|
refinement. Both use a short LLM topic summary of the conversation. All LLM
|
||||||
|
traffic goes through `llm/client.py`; nothing here logs content — a failure
|
||||||
|
degrades quietly rather than breaking the capture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.authoring.prompts import render_topic_summary_prompt
|
||||||
|
from app.llm.client import chat_json
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.models import Conversation, MessageRole
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.authoring")
|
||||||
|
|
||||||
|
# How many recent turns feed the summary — enough for the subject, bounded so
|
||||||
|
# a long thread cannot blow up the utility prompt.
|
||||||
|
MAX_CONTEXT_MESSAGES = 12
|
||||||
|
|
||||||
|
# Skip the reasoning model's hidden thinking: this is a short, latency-
|
||||||
|
# sensitive utility call.
|
||||||
|
_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||||
|
|
||||||
|
|
||||||
|
class _TopicSummary(BaseModel):
|
||||||
|
topic: str
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_transcript(conversation: Conversation) -> str:
|
||||||
|
"""The recent user/assistant turns as a plain transcript."""
|
||||||
|
turns = [
|
||||||
|
message
|
||||||
|
for message in conversation.messages
|
||||||
|
if message.role in (MessageRole.user, MessageRole.assistant)
|
||||||
|
][-MAX_CONTEXT_MESSAGES:]
|
||||||
|
return "\n".join(
|
||||||
|
f"{'User' if message.role == MessageRole.user else 'Assistant'}: "
|
||||||
|
f"{message.content}"
|
||||||
|
for message in turns
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_transcript(transcript: str) -> str:
|
||||||
|
"""A short topic summary of a conversation transcript, or '' if none can
|
||||||
|
be made. Failures degrade quietly."""
|
||||||
|
if not transcript.strip():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
result = await chat_json(
|
||||||
|
render_topic_summary_prompt(transcript),
|
||||||
|
_TopicSummary,
|
||||||
|
extra_body=_NO_THINKING,
|
||||||
|
)
|
||||||
|
except LLMError:
|
||||||
|
logger.info("topic summary failed", extra={"event": "topic_summary_failed"})
|
||||||
|
return ""
|
||||||
|
return result.topic.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_conversation(conversation: Conversation) -> str:
|
||||||
|
"""A short topic summary of the conversation, or '' if none can be made."""
|
||||||
|
return await summarize_transcript(conversation_transcript(conversation))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Render a template's Markdown skeleton and title into a new draft document."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.authoring.schema import AuthoringTemplate
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def render_title(template: AuthoringTemplate, user: User) -> str:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
return (
|
||||||
|
template.title_template.replace("{{user.name}}", user.name)
|
||||||
|
.replace("{{date}}", today)
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_skeleton(template: AuthoringTemplate) -> str:
|
||||||
|
"""The Markdown the editor opens with: the skeleton verbatim, normalized
|
||||||
|
to a single trailing newline. An empty skeleton yields an empty document
|
||||||
|
the author fills from scratch."""
|
||||||
|
skeleton = template.skeleton.strip()
|
||||||
|
return f"{skeleton}\n" if skeleton else ""
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""The document audit trail.
|
||||||
|
|
||||||
|
Every content edit and lifecycle transition is appended to `document_events`
|
||||||
|
as an immutable record of who did what, when. Content-bearing actions snapshot
|
||||||
|
the Markdown source of truth (never the disposable chunks) so a past version
|
||||||
|
can later be viewed or diffed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Document, DocumentEvent, DocumentEventAction, User
|
||||||
|
|
||||||
|
|
||||||
|
def record_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
document: Document,
|
||||||
|
actor: User,
|
||||||
|
action: DocumentEventAction,
|
||||||
|
*,
|
||||||
|
snapshot: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Append an audit record for `document`.
|
||||||
|
|
||||||
|
`snapshot` freezes the current Markdown, title and meta so the version can
|
||||||
|
be reconstructed later — pass it for content-bearing events (created /
|
||||||
|
edited). Visibility is small, so it is always recorded. Leave `snapshot`
|
||||||
|
False for pure transitions that carry no new content. The document must
|
||||||
|
already have an id (flush a freshly created document first).
|
||||||
|
"""
|
||||||
|
db.add(
|
||||||
|
DocumentEvent(
|
||||||
|
document_id=document.id,
|
||||||
|
actor_id=actor.id,
|
||||||
|
action=action,
|
||||||
|
content_md=document.content_md if snapshot else None,
|
||||||
|
title=document.title if snapshot else None,
|
||||||
|
visibility=document.visibility,
|
||||||
|
meta=document.meta if snapshot else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Refinement prompt for the writing editor — natural language only.
|
||||||
|
|
||||||
|
The model refines exactly ONE section of a document the user is writing. The
|
||||||
|
rest of the document travels as prefix/suffix context so the section stays
|
||||||
|
coherent with its surroundings, but the model regenerates ONLY the section —
|
||||||
|
a large document is never re-emitted whole (FIM-style).
|
||||||
|
|
||||||
|
The base texts (persona, rules, framings) are admin-editable: they come from
|
||||||
|
`app/prompts/overrides.py::get_prompt`, which returns a DB override when one
|
||||||
|
exists and the code default (`app/prompts/defaults.py`) otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.llm.client import ChatMessage
|
||||||
|
from app.prompts.overrides import get_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def render_refine_prompt(
|
||||||
|
section: str,
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
suffix: str,
|
||||||
|
persona: str | None,
|
||||||
|
hint: str | None,
|
||||||
|
context: str | None = None,
|
||||||
|
knowledge: list[str] | None = None,
|
||||||
|
) -> list[ChatMessage]:
|
||||||
|
# A template may carry its own persona; otherwise the admin-editable default.
|
||||||
|
system_parts = [persona.strip() if persona else get_prompt("refine_persona")]
|
||||||
|
if hint:
|
||||||
|
system_parts.append(f"What this section should convey: {hint}")
|
||||||
|
if context:
|
||||||
|
# Background from the chat this capture came from, so the refinement
|
||||||
|
# is on-topic — but only as orientation, never a source of new facts.
|
||||||
|
system_parts.append(
|
||||||
|
f"Background (the conversation this document came from, for "
|
||||||
|
f"orientation only — do not invent facts from it): {context}"
|
||||||
|
)
|
||||||
|
system_parts.append(get_prompt("refine_rules"))
|
||||||
|
|
||||||
|
# The document being edited leads the user turn (prefix/suffix/section);
|
||||||
|
# the retrieved knowledge trails it, because it changes on every call and
|
||||||
|
# keeping it last leaves the stable prompt prefix reusable between calls.
|
||||||
|
user_parts: list[str] = []
|
||||||
|
if prefix.strip():
|
||||||
|
user_parts.append(
|
||||||
|
f"Text before the section (context only, do not repeat it):\n{prefix}"
|
||||||
|
)
|
||||||
|
if suffix.strip():
|
||||||
|
user_parts.append(
|
||||||
|
f"Text after the section (context only, do not repeat it):\n{suffix}"
|
||||||
|
)
|
||||||
|
user_parts.append(f"Refine only this section:\n{section}")
|
||||||
|
if knowledge:
|
||||||
|
# What the company has already documented elsewhere. It is grounding,
|
||||||
|
# not source material: it keeps terminology and facts consistent and
|
||||||
|
# lets the section point at related documents, but it must not be
|
||||||
|
# copied in or become a way to add facts the section's notes do not
|
||||||
|
# support.
|
||||||
|
joined = "\n\n".join(knowledge)
|
||||||
|
user_parts.append(f"{get_prompt('grounding_framing')}\n{joined}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": "\n\n".join(system_parts)},
|
||||||
|
{"role": "user", "content": "\n\n".join(user_parts)},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_topic_summary_prompt(transcript: str) -> list[ChatMessage]:
|
||||||
|
"""Condense a conversation into a short search topic (a few words)."""
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": get_prompt("topic_summary")},
|
||||||
|
{"role": "user", "content": f"Conversation:\n{transcript}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_title_prompt(content_md: str) -> list[ChatMessage]:
|
||||||
|
"""Suggest a concise document title from its written content."""
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": get_prompt("title")},
|
||||||
|
{"role": "user", "content": f"Document:\n{content_md}"},
|
||||||
|
]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Pydantic schema for authoring templates (schema version 1.0).
|
||||||
|
|
||||||
|
A template is a **Markdown skeleton** — a starting document with headings the
|
||||||
|
author fills in — plus a persona and optional per-section hints that steer the
|
||||||
|
section-refinement model. It is declarative configuration, not code (see
|
||||||
|
docs/authoring-templates.md), stored in `templates.config` (JSONB) and
|
||||||
|
validated on load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateModelHints(BaseModel):
|
||||||
|
temperature: float = 0.4
|
||||||
|
# UI warning when the configured endpoint is weaker than the template
|
||||||
|
# expects; read by api/templates.py.
|
||||||
|
min_class_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SectionHint(BaseModel):
|
||||||
|
"""Steers what the refinement model should draw out of one section.
|
||||||
|
|
||||||
|
`heading` is matched to a skeleton heading by its exact text, so the hint
|
||||||
|
only reaches the model while the author is writing under that heading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
heading: str
|
||||||
|
hint: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateMetadata(BaseModel):
|
||||||
|
visibility: Literal["public", "department", "restricted"] = "department"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthoringTemplate(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
# Names the template's shape, so a differently-shaped config is rejected
|
||||||
|
# rather than silently loaded as an authoring template.
|
||||||
|
kind: Literal["authoring"] = "authoring"
|
||||||
|
# The language this template's CONTENT is written in — persona, skeleton
|
||||||
|
# and hints, not the UI. The picker lists matching templates first.
|
||||||
|
locale: Literal["de", "en"] | None = None
|
||||||
|
description: str = ""
|
||||||
|
model: TemplateModelHints = TemplateModelHints()
|
||||||
|
persona: str
|
||||||
|
# The Markdown the editor opens with: headings the author fills in. This
|
||||||
|
# IS the starting content, not a description of it.
|
||||||
|
skeleton: str
|
||||||
|
sections: list[SectionHint] = []
|
||||||
|
title_template: str
|
||||||
|
metadata: TemplateMetadata = TemplateMetadata()
|
||||||
|
|
||||||
|
@field_validator("version", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _version_to_string(cls, value: object) -> str:
|
||||||
|
# YAML reads an unquoted 1.0 as a float.
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def hint_for(self, heading: str) -> str | None:
|
||||||
|
for section in self.sections:
|
||||||
|
if section.heading == heading:
|
||||||
|
return section.hint
|
||||||
|
return None
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Find the section of a Markdown document the cursor sits in.
|
||||||
|
|
||||||
|
The refinement endpoint refines exactly one section at a time (FIM-style),
|
||||||
|
so this is the AUTHORITATIVE boundary computation — the client mirrors it for
|
||||||
|
a visual highlight, but the server owns it. A section runs from the nearest
|
||||||
|
heading at or above the cursor down to the line before the next heading of
|
||||||
|
the same or higher level; content before the first heading is its own
|
||||||
|
section. A section whose body exceeds the chunk cap narrows to the blank-line
|
||||||
|
paragraph at the cursor, so a large document never refines as one giant block.
|
||||||
|
|
||||||
|
Shares the heading regex, fence-awareness and cap with `rag/chunking.py` so
|
||||||
|
"what is a section" means the same thing to refinement and to indexing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.rag.chunking import HEADING_RE, TARGET_CHUNK_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActiveSection:
|
||||||
|
start_line: int # 1-based, inclusive, into content_md
|
||||||
|
end_line: int # 1-based, inclusive
|
||||||
|
|
||||||
|
|
||||||
|
def _heading_lines(lines: list[str]) -> list[tuple[int, int]]:
|
||||||
|
"""(line_index_0based, level) for every heading line, ignoring fences."""
|
||||||
|
headings: list[tuple[int, int]] = []
|
||||||
|
in_fence = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.lstrip().startswith("```"):
|
||||||
|
in_fence = not in_fence
|
||||||
|
continue
|
||||||
|
if in_fence:
|
||||||
|
continue
|
||||||
|
match = HEADING_RE.match(line)
|
||||||
|
if match:
|
||||||
|
headings.append((i, len(match.group(1))))
|
||||||
|
return headings
|
||||||
|
|
||||||
|
|
||||||
|
def _paragraph_at(
|
||||||
|
lines: list[str], start0: int, end0: int, cursor0: int
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
"""The blank-line-delimited block (fence-aware) at the cursor, within
|
||||||
|
[start0, end0]. Falls back to the block just before the cursor when it
|
||||||
|
sits on a blank gap, else the first block."""
|
||||||
|
blocks: list[tuple[int, int]] = []
|
||||||
|
block_start: int | None = None
|
||||||
|
in_fence = False
|
||||||
|
for i in range(start0, end0 + 1):
|
||||||
|
line = lines[i]
|
||||||
|
if line.lstrip().startswith("```"):
|
||||||
|
in_fence = not in_fence
|
||||||
|
if block_start is None:
|
||||||
|
block_start = i
|
||||||
|
continue
|
||||||
|
if not line.strip() and not in_fence:
|
||||||
|
if block_start is not None:
|
||||||
|
blocks.append((block_start, i - 1))
|
||||||
|
block_start = None
|
||||||
|
elif block_start is None:
|
||||||
|
block_start = i
|
||||||
|
if block_start is not None:
|
||||||
|
blocks.append((block_start, end0))
|
||||||
|
if not blocks:
|
||||||
|
return None
|
||||||
|
for b_start, b_end in blocks:
|
||||||
|
if b_start <= cursor0 <= b_end:
|
||||||
|
return b_start, b_end
|
||||||
|
for b_start, b_end in reversed(blocks):
|
||||||
|
if b_end < cursor0:
|
||||||
|
return b_start, b_end
|
||||||
|
return blocks[0]
|
||||||
|
|
||||||
|
|
||||||
|
def active_section(content_md: str, cursor_line: int) -> ActiveSection:
|
||||||
|
lines = content_md.splitlines()
|
||||||
|
n = len(lines)
|
||||||
|
if n == 0:
|
||||||
|
return ActiveSection(1, 1)
|
||||||
|
cursor0 = max(1, min(cursor_line, n)) - 1
|
||||||
|
|
||||||
|
headings = _heading_lines(lines)
|
||||||
|
owner: tuple[int, int] | None = None
|
||||||
|
for idx, level in headings:
|
||||||
|
if idx <= cursor0:
|
||||||
|
owner = (idx, level)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if owner is None:
|
||||||
|
# Preamble before the first heading (or a document with no headings).
|
||||||
|
start0 = 0
|
||||||
|
end0 = headings[0][0] - 1 if headings else n - 1
|
||||||
|
else:
|
||||||
|
start0, owner_level = owner
|
||||||
|
end0 = n - 1
|
||||||
|
for idx, level in headings:
|
||||||
|
if idx > start0 and level <= owner_level:
|
||||||
|
end0 = idx - 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# Trailing blank lines belong to the separation before the next section,
|
||||||
|
# not to this one: keeping them in the range would let an accepted
|
||||||
|
# suggestion swallow the blank line above the next heading.
|
||||||
|
while end0 > start0 and not lines[end0].strip():
|
||||||
|
end0 -= 1
|
||||||
|
|
||||||
|
body = "\n".join(lines[start0 : end0 + 1])
|
||||||
|
if len(body) > TARGET_CHUNK_CHARS:
|
||||||
|
narrowed = _paragraph_at(lines, start0, end0, cursor0)
|
||||||
|
if narrowed is not None:
|
||||||
|
start0, end0 = narrowed
|
||||||
|
|
||||||
|
return ActiveSection(start_line=start0 + 1, end_line=end0 + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def slice_lines(
|
||||||
|
content_md: str, start_line: int, end_line: int
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
"""(prefix, section, suffix) split at the 1-based inclusive line range.
|
||||||
|
|
||||||
|
The section is the lines the model refines; prefix/suffix are the rest of
|
||||||
|
the document, handed to the model as context it must not re-emit.
|
||||||
|
"""
|
||||||
|
lines = content_md.splitlines()
|
||||||
|
prefix = "\n".join(lines[: start_line - 1])
|
||||||
|
section = "\n".join(lines[start_line - 1 : end_line])
|
||||||
|
suffix = "\n".join(lines[end_line:])
|
||||||
|
return prefix, section, suffix
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# Repo-root .env for native dev; inside Docker the file is absent and
|
||||||
|
# configuration comes from real environment variables (which take precedence).
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_ENV_FILE = _REPO_ROOT / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="PABLAN_", env_file=_ENV_FILE, extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
env: Literal["development", "production"] = "development"
|
||||||
|
database_url: str = "postgresql+asyncpg://pablan:change-me@localhost:5432/pablan"
|
||||||
|
|
||||||
|
# Secure=false is needed for dev over plain http on non-localhost
|
||||||
|
# addresses (WireGuard IPs) — see .env.example.
|
||||||
|
cookie_secure: bool = True
|
||||||
|
auth_session_ttl_days: int = 14
|
||||||
|
|
||||||
|
query_retention_days: int = 90
|
||||||
|
|
||||||
|
# The shipped template catalog (repo templates/ in dev; the customer
|
||||||
|
# stack mounts the directory and overrides this path).
|
||||||
|
templates_dir: str = str(_REPO_ROOT / "templates")
|
||||||
|
help_dir: str = str(_REPO_ROOT / "help")
|
||||||
|
|
||||||
|
# The instance's own language: which blueprint variant the first-install
|
||||||
|
# starter set uses, and the fallback when a visitor states no preference.
|
||||||
|
# Per-user choice lives on users.locale and wins over this.
|
||||||
|
default_locale: Literal["de", "en"] = "de"
|
||||||
|
|
||||||
|
log_level: str = "INFO"
|
||||||
|
# Content debug logging (prompts/responses) — NEVER in production.
|
||||||
|
debug_log_prompts: bool = False
|
||||||
|
llm_timeout_seconds: float = 120.0
|
||||||
|
# How many requests Pablan lets one endpoint see at once, and how long a
|
||||||
|
# request waits for a free slot before it is answered with "busy". Match
|
||||||
|
# llm_max_parallel to the server's parallel slots (llama.cpp: --parallel).
|
||||||
|
# See app/llm/gate.py.
|
||||||
|
llm_max_parallel: int = 4
|
||||||
|
llm_queue_wait_seconds: float = 20.0
|
||||||
|
llm_max_queued: int = 24
|
||||||
|
job_poll_seconds: float = 1.0
|
||||||
|
|
||||||
|
chat_base_url: str = "http://localhost:8001/v1"
|
||||||
|
chat_api_key: str = "none"
|
||||||
|
chat_model: str = ""
|
||||||
|
utility_base_url: str = "http://localhost:8001/v1"
|
||||||
|
utility_api_key: str = "none"
|
||||||
|
utility_model: str = ""
|
||||||
|
embedding_base_url: str = "http://localhost:8002/v1"
|
||||||
|
embedding_api_key: str = "none"
|
||||||
|
embedding_model: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
engine = create_async_engine(get_settings().database_url)
|
||||||
|
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
"""API error rendered as the protocol's {detail, code} problem shape."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, detail: str, code: str) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(ApiError)
|
||||||
|
async def handle_api_error(request: Request, exc: ApiError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail, "code": exc.code},
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Built-in help documents: Markdown files → documents table.
|
||||||
|
|
||||||
|
The help pages that describe Pablan itself ship with the product and live in
|
||||||
|
the repo-level help/ directory (product content, not code). They are
|
||||||
|
re-imported on every start, so a release always carries the current
|
||||||
|
documentation, and they are flagged `is_builtin` so the API refuses to edit
|
||||||
|
or delete them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.ingestion.handlers import INDEX_DOCUMENT
|
||||||
|
from app.ingestion.queue import enqueue
|
||||||
|
from app.models import (
|
||||||
|
Document,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.help")
|
||||||
|
|
||||||
|
FRONTMATTER_SEPARATOR = "---"
|
||||||
|
META_KEY = "help_key"
|
||||||
|
|
||||||
|
|
||||||
|
class HelpImportError(Exception):
|
||||||
|
"""Malformed help file — a packaging bug, never user input."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_help_document(source: str) -> tuple[str, str, str]:
|
||||||
|
"""Split the `key`/`title` frontmatter from the Markdown body."""
|
||||||
|
if not source.startswith(FRONTMATTER_SEPARATOR):
|
||||||
|
raise HelpImportError("Help document must start with YAML frontmatter.")
|
||||||
|
_, frontmatter, body = source.split(FRONTMATTER_SEPARATOR, 2)
|
||||||
|
try:
|
||||||
|
meta = yaml.safe_load(frontmatter)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise HelpImportError(f"Invalid frontmatter: {type(exc).__name__}") from None
|
||||||
|
if not isinstance(meta, dict) or not meta.get("key") or not meta.get("title"):
|
||||||
|
raise HelpImportError("Help frontmatter needs at least 'key' and 'title'.")
|
||||||
|
return str(meta["key"]), str(meta["title"]), body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def import_help_documents(db: AsyncSession) -> int:
|
||||||
|
"""Upsert every help/*.md by its key. Returns the number re-indexed."""
|
||||||
|
directory = Path(get_settings().help_dir)
|
||||||
|
if not directory.is_dir():
|
||||||
|
logger.warning("help directory missing", extra={"event": "help_import_skipped"})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reindexed = 0
|
||||||
|
for path in sorted(directory.glob("*.md")):
|
||||||
|
key, title, body = parse_help_document(path.read_text())
|
||||||
|
existing = (
|
||||||
|
await db.execute(
|
||||||
|
select(Document).where(
|
||||||
|
Document.is_builtin.is_(True),
|
||||||
|
Document.meta[META_KEY].astext == key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
document = Document(
|
||||||
|
title=title,
|
||||||
|
status=DocumentStatus.published,
|
||||||
|
# Help is for everyone; it has no author and no department.
|
||||||
|
visibility=DocumentVisibility.public,
|
||||||
|
content_md=body,
|
||||||
|
meta={META_KEY: key},
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
await db.flush()
|
||||||
|
elif existing.content_md == body and existing.title == title:
|
||||||
|
continue # unchanged — no need to re-embed
|
||||||
|
else:
|
||||||
|
existing.title = title
|
||||||
|
existing.content_md = body
|
||||||
|
existing.meta = {**existing.meta, META_KEY: key}
|
||||||
|
document = existing
|
||||||
|
|
||||||
|
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
|
||||||
|
reindexed += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"help documents imported",
|
||||||
|
extra={"event": "help_import", "reindexed": reindexed},
|
||||||
|
)
|
||||||
|
return reindexed
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Job handlers. Importing this module registers them with the queue."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.ingestion.queue import enqueue, job_handler
|
||||||
|
from app.models import (
|
||||||
|
AuthSession,
|
||||||
|
Conversation,
|
||||||
|
ConversationMode,
|
||||||
|
Document,
|
||||||
|
DocumentStatus,
|
||||||
|
Job,
|
||||||
|
JobStatus,
|
||||||
|
)
|
||||||
|
from app.rag.indexing import reindex_document, remove_chunks
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.queue")
|
||||||
|
|
||||||
|
RETENTION_CLEANUP = "retention_cleanup"
|
||||||
|
INDEX_DOCUMENT = "index_document"
|
||||||
|
REINDEX_ALL = "reindex_all"
|
||||||
|
|
||||||
|
|
||||||
|
@job_handler(INDEX_DOCUMENT)
|
||||||
|
async def index_document(db: AsyncSession, job: Job) -> None:
|
||||||
|
"""(Re)build the chunks of one document; drop them if it is not published."""
|
||||||
|
document_id = uuid.UUID(job.payload["document_id"])
|
||||||
|
document = await db.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
logger.info(
|
||||||
|
"index skipped, document gone",
|
||||||
|
extra={"event": "index_skipped", "document_id": str(document_id)},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if document.status == DocumentStatus.published:
|
||||||
|
await reindex_document(db, document)
|
||||||
|
else:
|
||||||
|
await remove_chunks(db, document.id)
|
||||||
|
|
||||||
|
|
||||||
|
@job_handler(REINDEX_ALL)
|
||||||
|
async def reindex_all(db: AsyncSession, job: Job) -> None:
|
||||||
|
"""Fan out one index_document job per published document.
|
||||||
|
|
||||||
|
Never embeds the corpus in this handler itself — the queue holds the
|
||||||
|
claim transaction open for the whole handler run.
|
||||||
|
"""
|
||||||
|
document_ids = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Document.id).where(Document.status == DocumentStatus.published)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for document_id in document_ids:
|
||||||
|
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document_id)})
|
||||||
|
logger.info(
|
||||||
|
"reindex fan-out",
|
||||||
|
extra={"event": "reindex_all", "document_count": len(document_ids)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@job_handler(RETENTION_CLEANUP)
|
||||||
|
async def retention_cleanup(db: AsyncSession, job: Job) -> None:
|
||||||
|
"""GDPR retention: drop old query conversations and expired auth sessions.
|
||||||
|
|
||||||
|
Messages go with their conversation via ON DELETE CASCADE. Reschedules
|
||||||
|
itself daily.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
cutoff = now - timedelta(days=get_settings().query_retention_days)
|
||||||
|
|
||||||
|
conversations_deleted = (
|
||||||
|
await db.execute(
|
||||||
|
delete(Conversation).where(
|
||||||
|
Conversation.mode == ConversationMode.query,
|
||||||
|
Conversation.updated_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).rowcount
|
||||||
|
sessions_deleted = (
|
||||||
|
await db.execute(delete(AuthSession).where(AuthSession.expires_at < now))
|
||||||
|
).rowcount
|
||||||
|
|
||||||
|
await enqueue(db, RETENTION_CLEANUP, run_after=now + timedelta(days=1))
|
||||||
|
logger.info(
|
||||||
|
"retention cleanup",
|
||||||
|
extra={
|
||||||
|
"event": "retention_cleanup",
|
||||||
|
"conversations_deleted": conversations_deleted,
|
||||||
|
"auth_sessions_deleted": sessions_deleted,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_retention_scheduled(db: AsyncSession) -> None:
|
||||||
|
"""Idempotent startup bootstrap: exactly one pending retention job."""
|
||||||
|
existing = (
|
||||||
|
await db.execute(
|
||||||
|
select(Job.id).where(
|
||||||
|
Job.type == RETENTION_CLEANUP, Job.status == JobStatus.pending
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing is None:
|
||||||
|
await enqueue(db, RETENTION_CLEANUP)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Postgres-backed background queue.
|
||||||
|
|
||||||
|
One asyncio loop in the app lifespan claims jobs via
|
||||||
|
SELECT … FOR UPDATE SKIP LOCKED. The claim transaction stays open while the
|
||||||
|
handler runs: a crash rolls everything back and the job remains pending and
|
||||||
|
claimable after restart — handler writes are atomic with job completion.
|
||||||
|
Failure bookkeeping (attempts, backoff, last_error) happens in a follow-up
|
||||||
|
transaction. Single worker per process; the loop moves into a worker
|
||||||
|
container unchanged when scale demands it (Variant B).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import async_session_factory
|
||||||
|
from app.log import safe_error
|
||||||
|
from app.metrics import metrics
|
||||||
|
from app.models import Job, JobStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.queue")
|
||||||
|
|
||||||
|
JobHandler = Callable[[AsyncSession, Job], Awaitable[None]]
|
||||||
|
|
||||||
|
_HANDLERS: dict[str, JobHandler] = {}
|
||||||
|
|
||||||
|
MAX_ATTEMPTS = 5
|
||||||
|
BACKOFF_BASE_SECONDS = 30.0 # 30s, 1m, 2m, 4m between retries
|
||||||
|
|
||||||
|
|
||||||
|
def job_handler(job_type: str) -> Callable[[JobHandler], JobHandler]:
|
||||||
|
def register(fn: JobHandler) -> JobHandler:
|
||||||
|
_HANDLERS[job_type] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return register
|
||||||
|
|
||||||
|
|
||||||
|
def backoff_delay(attempts: int) -> timedelta:
|
||||||
|
return timedelta(seconds=BACKOFF_BASE_SECONDS * 2 ** (attempts - 1))
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue(
|
||||||
|
db: AsyncSession,
|
||||||
|
job_type: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
run_after: datetime | None = None,
|
||||||
|
) -> Job:
|
||||||
|
job = Job(type=job_type, payload=payload or {})
|
||||||
|
if run_after is not None:
|
||||||
|
job.run_after = run_after
|
||||||
|
db.add(job)
|
||||||
|
await db.flush()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
async def process_one(
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] = async_session_factory,
|
||||||
|
) -> bool:
|
||||||
|
"""Claim and process a single due job. Returns True if one was processed."""
|
||||||
|
started = asyncio.get_running_loop().time()
|
||||||
|
async with session_factory() as db:
|
||||||
|
job = (
|
||||||
|
await db.execute(
|
||||||
|
select(Job)
|
||||||
|
.where(Job.status == JobStatus.pending, Job.run_after <= func.now())
|
||||||
|
.order_by(Job.run_after)
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if job is None:
|
||||||
|
await db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
job_id, job_type, attempts_before = job.id, job.type, job.attempts
|
||||||
|
try:
|
||||||
|
handler = _HANDLERS.get(job_type)
|
||||||
|
if handler is None:
|
||||||
|
raise LookupError(f"no handler registered for job type {job_type!r}")
|
||||||
|
await handler(db, job)
|
||||||
|
job.status = JobStatus.done
|
||||||
|
job.attempts = attempts_before + 1
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
await _record_failure(session_factory, job_id, exc)
|
||||||
|
duration = asyncio.get_running_loop().time() - started
|
||||||
|
metrics.inc("jobs_processed_total", {"type": job_type, "status": "failed"})
|
||||||
|
metrics.observe("job_seconds", duration, {"type": job_type})
|
||||||
|
logger.warning(
|
||||||
|
"job failed",
|
||||||
|
extra={
|
||||||
|
"event": "job_failed",
|
||||||
|
"job_id": str(job_id),
|
||||||
|
"job_type": job_type,
|
||||||
|
"attempt": attempts_before + 1,
|
||||||
|
"error": safe_error(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
duration = asyncio.get_running_loop().time() - started
|
||||||
|
metrics.inc("jobs_processed_total", {"type": job_type, "status": "done"})
|
||||||
|
metrics.observe("job_seconds", duration, {"type": job_type})
|
||||||
|
logger.info(
|
||||||
|
"job done",
|
||||||
|
extra={
|
||||||
|
"event": "job_done",
|
||||||
|
"job_id": str(job_id),
|
||||||
|
"job_type": job_type,
|
||||||
|
"attempt": attempts_before + 1,
|
||||||
|
"duration_ms": round(duration * 1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_failure(
|
||||||
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
job_id: Any,
|
||||||
|
exc: Exception,
|
||||||
|
) -> None:
|
||||||
|
async with session_factory() as db:
|
||||||
|
job = await db.get(Job, job_id, with_for_update=True)
|
||||||
|
if job is None: # pragma: no cover — job deleted underneath us
|
||||||
|
return
|
||||||
|
job.attempts += 1
|
||||||
|
job.last_error = safe_error(exc, limit=500)
|
||||||
|
if job.attempts >= MAX_ATTEMPTS:
|
||||||
|
job.status = JobStatus.failed
|
||||||
|
metrics.inc("jobs_exhausted_total", {"type": job.type})
|
||||||
|
else:
|
||||||
|
job.status = JobStatus.pending
|
||||||
|
job.run_after = datetime.now(UTC) + backoff_delay(job.attempts)
|
||||||
|
metrics.inc("jobs_retried_total", {"type": job.type})
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_depth_gauge(
|
||||||
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with session_factory() as db:
|
||||||
|
depth = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count(Job.id)).where(Job.status == JobStatus.pending)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
metrics.set_gauge("jobs_queue_depth", float(depth))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_queue(
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] = async_session_factory,
|
||||||
|
) -> None:
|
||||||
|
poll_seconds = get_settings().job_poll_seconds
|
||||||
|
logger.info("job queue started", extra={"event": "queue_started"})
|
||||||
|
while not stop_event.is_set():
|
||||||
|
worked = False
|
||||||
|
try:
|
||||||
|
worked = await process_one(session_factory)
|
||||||
|
await _update_depth_gauge(session_factory)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"queue iteration failed",
|
||||||
|
extra={"event": "queue_error", "error": safe_error(exc)},
|
||||||
|
)
|
||||||
|
if not worked:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=poll_seconds)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
logger.info("job queue stopped", extra={"event": "queue_stopped"})
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""The ONLY code that talks to LLM endpoints.
|
||||||
|
|
||||||
|
Exactly three functions: chat_stream, chat_json, embed. Three model roles
|
||||||
|
(chat / utility / embedding), each base_url + api_key + model from settings.
|
||||||
|
The openai SDK is used purely as a client for OpenAI-compatible endpoints
|
||||||
|
(llama.cpp locally, cloud APIs in production).
|
||||||
|
|
||||||
|
Logging policy: metadata only — prompts and responses are logged ONLY at
|
||||||
|
DEBUG level behind PABLAN_DEBUG_LOG_PROMPTS=true (never in production).
|
||||||
|
LLMError messages are sanitized and never contain content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Literal, TypeVar
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.llm.errors import LLMError, llm_error
|
||||||
|
from app.llm.gate import slot
|
||||||
|
from app.llm.overrides import env_defaults, get_config
|
||||||
|
from app.metrics import metrics
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.llm")
|
||||||
|
|
||||||
|
Role = Literal["chat", "utility", "embedding"]
|
||||||
|
ChatMessage = dict[str, str]
|
||||||
|
T = TypeVar("T", bound=BaseModel)
|
||||||
|
|
||||||
|
# Turn off a reasoning model's hidden thinking. Latency-critical calls (a
|
||||||
|
# refinement fires on a typing pause) want the answer, not the deliberation:
|
||||||
|
# ~1s instead of ~10s with no quality loss on mechanical rewrites. Endpoints
|
||||||
|
# and templates that do not know the parameter ignore it.
|
||||||
|
NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||||
|
|
||||||
|
_RETRY_INSTRUCTION = (
|
||||||
|
"Your previous reply did not match the required JSON schema. "
|
||||||
|
"Reply again with ONLY valid JSON matching the schema — no prose."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _http_client_factory() -> httpx.AsyncClient | None:
|
||||||
|
"""Tests override this to inject an ASGI transport."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def role_config(role: Role) -> tuple[str, str, str]:
|
||||||
|
"""Effective endpoint config: the DB row, seeded from `.env` at first
|
||||||
|
start (see app/llm/overrides.py — "bootstrap, then DB").
|
||||||
|
|
||||||
|
The `or env` fallbacks are a safety net, not the model: they cover the
|
||||||
|
window before `load_config()` has run (early startup, tests that never
|
||||||
|
touch the table) and a field an admin blanked. In a bootstrapped
|
||||||
|
instance the stored value always wins.
|
||||||
|
"""
|
||||||
|
stored = get_config(role)
|
||||||
|
env = env_defaults(role)
|
||||||
|
return (
|
||||||
|
stored.base_url or env.base_url or "",
|
||||||
|
stored.api_key or env.api_key or "",
|
||||||
|
stored.model or env.model or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(base_url: str, api_key: str) -> AsyncOpenAI:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"timeout": get_settings().llm_timeout_seconds,
|
||||||
|
# One SDK retry: llama.cpp closes idle keep-alive connections, and
|
||||||
|
# the first call on a stale connection fails with APIConnectionError.
|
||||||
|
# (SDK-internal retries are not separately metered.)
|
||||||
|
"max_retries": 1,
|
||||||
|
}
|
||||||
|
http_client = _http_client_factory()
|
||||||
|
if http_client is not None:
|
||||||
|
kwargs["http_client"] = http_client
|
||||||
|
return AsyncOpenAI(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _client_for(role: Role) -> AsyncOpenAI:
|
||||||
|
base_url, api_key, _ = role_config(role)
|
||||||
|
return _build_client(base_url, api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_clients() -> None:
|
||||||
|
"""Apply changed endpoint config without a restart: the cached clients
|
||||||
|
hold the old base_url and key, so they must go."""
|
||||||
|
_client_for.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def probe(
|
||||||
|
role: Role,
|
||||||
|
*,
|
||||||
|
base_url: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Smallest possible call against a candidate config, so an admin can
|
||||||
|
test an endpoint before saving it. Raises LLMError on failure."""
|
||||||
|
effective_url, effective_key, effective_model = role_config(role)
|
||||||
|
client = _build_client(base_url or effective_url, api_key or effective_key)
|
||||||
|
target = model or effective_model
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
if role == "embedding":
|
||||||
|
await client.embeddings.create(model=target, input=["ping"])
|
||||||
|
else:
|
||||||
|
stream = await client.chat.completions.create(
|
||||||
|
model=target,
|
||||||
|
messages=[{"role": "user", "content": "ping"}],
|
||||||
|
max_tokens=1,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
async for _ in stream:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
raise llm_error("probe", role, exc, started) from None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_models(
|
||||||
|
role: Role,
|
||||||
|
*,
|
||||||
|
base_url: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Ask an endpoint what it serves (`GET /v1/models`).
|
||||||
|
|
||||||
|
Server-side on purpose: the credentials must never leave the backend,
|
||||||
|
and the browser has no business talking to the model endpoint at all.
|
||||||
|
|
||||||
|
Not every OpenAI-compatible server implements the route, so a failure
|
||||||
|
here is ordinary rather than exceptional — the caller degrades to a
|
||||||
|
free-text model field. Raises LLMError so the caller can distinguish
|
||||||
|
"no such route" from "wrong credentials".
|
||||||
|
"""
|
||||||
|
effective_url, effective_key, _ = role_config(role)
|
||||||
|
client = _build_client(base_url or effective_url, api_key or effective_key)
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
page = await client.models.list()
|
||||||
|
except Exception as exc:
|
||||||
|
raise llm_error("list_models", role, exc, started) from None
|
||||||
|
# Ids only, sorted for a stable dropdown. Model ids are configuration,
|
||||||
|
# not content, so they may be returned and logged by count.
|
||||||
|
return sorted({model.id for model in page.data if getattr(model, "id", None)})
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
role: Role,
|
||||||
|
kind: str,
|
||||||
|
status: str,
|
||||||
|
started: float,
|
||||||
|
usage: Any = None,
|
||||||
|
**extra_fields: Any,
|
||||||
|
) -> None:
|
||||||
|
duration = time.monotonic() - started
|
||||||
|
metrics.inc("llm_calls_total", {"role": role, "kind": kind, "status": status})
|
||||||
|
metrics.observe("llm_call_seconds", duration, {"role": role, "kind": kind})
|
||||||
|
extra: dict[str, Any] = {
|
||||||
|
"event": "llm_call",
|
||||||
|
"role": role,
|
||||||
|
"kind": kind,
|
||||||
|
"status": status,
|
||||||
|
"duration_ms": round(duration * 1000),
|
||||||
|
**extra_fields,
|
||||||
|
}
|
||||||
|
if usage is not None:
|
||||||
|
prompt_tokens = getattr(usage, "prompt_tokens", None)
|
||||||
|
completion_tokens = getattr(usage, "completion_tokens", None)
|
||||||
|
if prompt_tokens:
|
||||||
|
metrics.inc(
|
||||||
|
"llm_tokens_total", {"role": role, "direction": "prompt"}, prompt_tokens
|
||||||
|
)
|
||||||
|
extra["prompt_tokens"] = prompt_tokens
|
||||||
|
if completion_tokens:
|
||||||
|
metrics.inc(
|
||||||
|
"llm_tokens_total",
|
||||||
|
{"role": role, "direction": "completion"},
|
||||||
|
completion_tokens,
|
||||||
|
)
|
||||||
|
extra["completion_tokens"] = completion_tokens
|
||||||
|
logger.info("llm call", extra=extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _debug_log_content(label: str, content: Any) -> None:
|
||||||
|
if get_settings().debug_log_prompts:
|
||||||
|
logger.debug("llm content", extra={"label": label, "content": content})
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_stream(
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
*,
|
||||||
|
role: Role = "chat",
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
extra_body: dict[str, Any] | None = None,
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Stream a chat completion as text deltas.
|
||||||
|
|
||||||
|
`extra_body` is passed through to the endpoint verbatim — used to reach
|
||||||
|
non-standard OpenAI-compatible parameters such as
|
||||||
|
`{"chat_template_kwargs": {"enable_thinking": False}}`, which turns off a
|
||||||
|
reasoning model's hidden thinking for latency-critical calls. Only
|
||||||
|
`delta.content` is ever yielded, so a reasoning channel never leaks into
|
||||||
|
the output regardless.
|
||||||
|
"""
|
||||||
|
base_url, _, model = role_config(role)
|
||||||
|
_debug_log_content("chat_stream.messages", messages)
|
||||||
|
options: dict[str, Any] = {}
|
||||||
|
if temperature is not None:
|
||||||
|
options["temperature"] = temperature
|
||||||
|
if max_tokens is not None:
|
||||||
|
options["max_tokens"] = max_tokens
|
||||||
|
if extra_body is not None:
|
||||||
|
options["extra_body"] = extra_body
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
status = "ok"
|
||||||
|
usage = None
|
||||||
|
try:
|
||||||
|
# The slot is held until the last token: a streaming completion
|
||||||
|
# occupies its server slot for its whole life (app/llm/gate.py).
|
||||||
|
async with slot(base_url, role):
|
||||||
|
stream = await _client_for(role).chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=messages, # type: ignore[arg-type]
|
||||||
|
stream=True,
|
||||||
|
stream_options={"include_usage": True},
|
||||||
|
**options,
|
||||||
|
)
|
||||||
|
async for chunk in stream:
|
||||||
|
if chunk.usage is not None:
|
||||||
|
usage = chunk.usage
|
||||||
|
if chunk.choices and chunk.choices[0].delta.content:
|
||||||
|
yield chunk.choices[0].delta.content
|
||||||
|
except GeneratorExit:
|
||||||
|
status = "aborted"
|
||||||
|
raise
|
||||||
|
except LLMError:
|
||||||
|
status = "error"
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
status = "error"
|
||||||
|
raise llm_error("chat_stream", role, exc, started) from None
|
||||||
|
finally:
|
||||||
|
_record(role, "chat_stream", status, started, usage)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_json(
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
schema: type[T],
|
||||||
|
*,
|
||||||
|
role: Role = "utility",
|
||||||
|
temperature: float = 0.0,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
extra_body: dict[str, Any] | None = None,
|
||||||
|
) -> T:
|
||||||
|
"""Structured output: response_format JSON schema + validation + one retry.
|
||||||
|
|
||||||
|
`extra_body` is passed through verbatim (e.g.
|
||||||
|
`{"chat_template_kwargs": {"enable_thinking": False}}` to skip a reasoning
|
||||||
|
model's hidden thinking on latency-sensitive utility calls)."""
|
||||||
|
base_url, _, model = role_config(role)
|
||||||
|
response_format = {
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": schema.__name__,
|
||||||
|
"schema": schema.model_json_schema(),
|
||||||
|
"strict": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
options: dict[str, Any] = {"temperature": temperature}
|
||||||
|
if max_tokens is not None:
|
||||||
|
options["max_tokens"] = max_tokens
|
||||||
|
if extra_body is not None:
|
||||||
|
options["extra_body"] = extra_body
|
||||||
|
|
||||||
|
attempt_messages = list(messages)
|
||||||
|
for attempt in (1, 2):
|
||||||
|
_debug_log_content("chat_json.messages", attempt_messages)
|
||||||
|
started = time.monotonic()
|
||||||
|
usage = None
|
||||||
|
try:
|
||||||
|
async with slot(base_url, role):
|
||||||
|
response = await _client_for(role).chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=attempt_messages, # type: ignore[arg-type]
|
||||||
|
response_format=response_format, # type: ignore[arg-type]
|
||||||
|
**options,
|
||||||
|
)
|
||||||
|
usage = response.usage
|
||||||
|
content = response.choices[0].message.content or ""
|
||||||
|
result = schema.model_validate_json(content)
|
||||||
|
_record(role, "chat_json", "ok", started, usage, attempt=attempt)
|
||||||
|
return result
|
||||||
|
except ValidationError:
|
||||||
|
_record(role, "chat_json", "invalid", started, usage, attempt=attempt)
|
||||||
|
_debug_log_content("chat_json.invalid_response", content)
|
||||||
|
attempt_messages = [
|
||||||
|
*attempt_messages,
|
||||||
|
{"role": "assistant", "content": content},
|
||||||
|
{"role": "user", "content": _RETRY_INSTRUCTION},
|
||||||
|
]
|
||||||
|
except LLMError:
|
||||||
|
_record(role, "chat_json", "error", started, usage, attempt=attempt)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_record(role, "chat_json", "error", started, usage, attempt=attempt)
|
||||||
|
raise llm_error("chat_json", role, exc, started, attempt=attempt) from None
|
||||||
|
|
||||||
|
raise LLMError(
|
||||||
|
f"chat_json failed (role={role}): response did not match schema "
|
||||||
|
f"{schema.__name__} after retry",
|
||||||
|
role=role,
|
||||||
|
kind="chat_json",
|
||||||
|
status="invalid",
|
||||||
|
cause_type="ValidationError",
|
||||||
|
duration_ms=round((time.monotonic() - started) * 1000),
|
||||||
|
attempt=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def embed(texts: list[str], *, role: Role = "embedding") -> list[list[float]]:
|
||||||
|
"""Embed a batch of texts; order of results matches the input order."""
|
||||||
|
base_url, _, model = role_config(role)
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with slot(base_url, role):
|
||||||
|
response = await _client_for(role).embeddings.create(
|
||||||
|
model=model, input=texts
|
||||||
|
)
|
||||||
|
except LLMError:
|
||||||
|
_record(role, "embed", "error", started, batch_size=len(texts))
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_record(role, "embed", "error", started, batch_size=len(texts))
|
||||||
|
raise llm_error("embed", role, exc, started) from None
|
||||||
|
_record(role, "embed", "ok", started, response.usage, batch_size=len(texts))
|
||||||
|
ordered = sorted(response.data, key=lambda item: item.index)
|
||||||
|
return [item.embedding for item in ordered]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""What it means when an endpoint does not answer.
|
||||||
|
|
||||||
|
Separate from `client.py` because eight modules catch this and none of them
|
||||||
|
talk to an endpoint: routers, modes and the authoring code only need to know
|
||||||
|
what went wrong and how to say it. The client itself stays the one place that
|
||||||
|
CALLS an endpoint.
|
||||||
|
|
||||||
|
Nothing here ever carries content — not the prompt, not the reply, not the
|
||||||
|
original exception's message. A failure is described by class name, status
|
||||||
|
code and duration, which is everything a log line may hold (rule 12).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Exception class names that mean "nothing answered at the other end" versus
|
||||||
|
# "the other end is there but not ready for us". The SDK wraps both, so the
|
||||||
|
# class name is all we have: APITimeoutError subclasses APIConnectionError,
|
||||||
|
# which is why timeouts are matched first.
|
||||||
|
_TIMEOUT_CAUSES = frozenset(
|
||||||
|
{"APITimeoutError", "ReadTimeout", "PoolTimeout", "TimeoutError"}
|
||||||
|
)
|
||||||
|
_CONNECTION_CAUSES = frozenset(
|
||||||
|
{"APIConnectionError", "ConnectError", "ConnectTimeout", "RemoteProtocolError"}
|
||||||
|
)
|
||||||
|
# Server said "come back later" (rate limit, no free slot, model still loading).
|
||||||
|
_BUSY_STATUS = frozenset({408, 429, 503, 504})
|
||||||
|
# Server said "not with these credentials / not this model".
|
||||||
|
_SETUP_STATUS = frozenset({401, 403, 404})
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""Sanitized LLM failure: structured metadata for debugging —
|
||||||
|
never content, never original exception messages.
|
||||||
|
|
||||||
|
Fields: role, kind, status ("error" | "invalid"), cause_type (original
|
||||||
|
exception CLASS NAME only), status_code (HTTP, if any), duration_ms,
|
||||||
|
attempt (chat_json: 1 or 2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
kind: str,
|
||||||
|
status: str,
|
||||||
|
cause_type: str | None = None,
|
||||||
|
status_code: int | None = None,
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
attempt: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.role = role
|
||||||
|
self.kind = kind
|
||||||
|
self.status = status
|
||||||
|
self.cause_type = cause_type
|
||||||
|
self.status_code = status_code
|
||||||
|
self.duration_ms = duration_ms
|
||||||
|
self.attempt = attempt
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self) -> str:
|
||||||
|
"""The API error code for this failure — the ONE place an endpoint
|
||||||
|
failure is classified, so every caller reports the same reason and the
|
||||||
|
frontend can phrase it (`docs/api-protocol.md`).
|
||||||
|
|
||||||
|
`llm_busy` and `llm_unreachable` are worth telling apart: the first is
|
||||||
|
worth retrying in a moment, the second needs someone to start the
|
||||||
|
endpoint.
|
||||||
|
"""
|
||||||
|
if self.status_code in _BUSY_STATUS:
|
||||||
|
return "llm_busy"
|
||||||
|
if self.status_code in _SETUP_STATUS:
|
||||||
|
return "llm_misconfigured"
|
||||||
|
if self.cause_type in _TIMEOUT_CAUSES:
|
||||||
|
return "llm_busy"
|
||||||
|
if self.cause_type in _CONNECTION_CAUSES:
|
||||||
|
return "llm_unreachable"
|
||||||
|
return "llm_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def llm_error(
|
||||||
|
kind: str,
|
||||||
|
role: str,
|
||||||
|
exc: Exception,
|
||||||
|
started: float,
|
||||||
|
*,
|
||||||
|
attempt: int | None = None,
|
||||||
|
) -> LLMError:
|
||||||
|
"""Wrap whatever the SDK raised, keeping only what may be logged."""
|
||||||
|
status_code = getattr(exc, "status_code", None)
|
||||||
|
return LLMError(
|
||||||
|
f"{kind} failed (role={role}): {type(exc).__name__}",
|
||||||
|
role=role,
|
||||||
|
kind=kind,
|
||||||
|
status="error",
|
||||||
|
cause_type=type(exc).__name__,
|
||||||
|
status_code=status_code if isinstance(status_code, int) else None,
|
||||||
|
duration_ms=round((time.monotonic() - started) * 1000),
|
||||||
|
attempt=attempt,
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""How many requests Pablan lets an endpoint see at once.
|
||||||
|
|
||||||
|
A self-hosted llama.cpp server has a fixed number of parallel slots. Sending
|
||||||
|
more than that does not make it faster: the extra requests sit in the server's
|
||||||
|
own queue where Pablan can neither see nor bound them, and every one of them
|
||||||
|
counts against the HTTP timeout. Two colleagues chatting while a reindex runs
|
||||||
|
is enough to turn a working instance into one where everything times out at
|
||||||
|
once.
|
||||||
|
|
||||||
|
So the waiting happens here instead, in front of the endpoint:
|
||||||
|
|
||||||
|
- **One gate per endpoint, not per role.** chat and utility usually point at
|
||||||
|
the same server (they do in the shipped `.env`), and it is the SERVER that
|
||||||
|
has the slots. Keying by base_url is what makes the limit real.
|
||||||
|
- **A bounded wait.** A caller waits at most `llm_queue_wait_seconds` for a
|
||||||
|
slot and then fails as `llm_busy` — a fast, honest "try again" instead of a
|
||||||
|
two-minute timeout that looks like a broken endpoint.
|
||||||
|
- **A bounded queue.** Past `llm_max_queued` waiters the gate stops admitting:
|
||||||
|
when far more work has arrived than the endpoint can absorb, the useful
|
||||||
|
answer is "busy", given immediately, to everyone beyond the line.
|
||||||
|
|
||||||
|
`llm_busy` is already the vocabulary for this (`llm/errors.py`), and the
|
||||||
|
frontend phrases it as "the model is busy" — so a queue rejection reaches the
|
||||||
|
user as the same, correct sentence as a 429 from a cloud provider.
|
||||||
|
|
||||||
|
Admin diagnostics (`probe`, `list_models`) deliberately do NOT pass through
|
||||||
|
the gate: they are single tiny calls, and an admin has to be able to test an
|
||||||
|
endpoint precisely when it is saturated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.llm.errors import LLMError
|
||||||
|
from app.metrics import metrics
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.llm")
|
||||||
|
|
||||||
|
|
||||||
|
class _Endpoint:
|
||||||
|
"""The live picture of one endpoint: who is in it, who is waiting."""
|
||||||
|
|
||||||
|
def __init__(self, limit: int) -> None:
|
||||||
|
self.limit = limit
|
||||||
|
self.semaphore = asyncio.Semaphore(limit)
|
||||||
|
self.in_flight = 0
|
||||||
|
self.waiting = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saturated(self) -> bool:
|
||||||
|
return self.in_flight >= self.limit
|
||||||
|
|
||||||
|
|
||||||
|
_endpoints: dict[str, _Endpoint] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _busy_error(role: str, reason: str, waited: float) -> LLMError:
|
||||||
|
"""A queue rejection, in the same shape as an endpoint's own 503 — the
|
||||||
|
caller classifies it through `LLMError.code` like any other failure."""
|
||||||
|
metrics.inc("llm_queue_rejected_total", {"role": role, "reason": reason})
|
||||||
|
logger.info(
|
||||||
|
"llm request not admitted",
|
||||||
|
extra={
|
||||||
|
"event": "llm_queue_rejected",
|
||||||
|
"role": role,
|
||||||
|
"reason": reason,
|
||||||
|
"waited_ms": round(waited * 1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return LLMError(
|
||||||
|
f"endpoint busy (role={role}): {reason}",
|
||||||
|
role=role,
|
||||||
|
kind="queue",
|
||||||
|
status="error",
|
||||||
|
# 503 is what a saturated endpoint says itself, and what maps to
|
||||||
|
# `llm_busy`. Keeping the queue's own rejection in that vocabulary
|
||||||
|
# means one reason reaches the user, not two.
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_for(base_url: str) -> _Endpoint:
|
||||||
|
limit = max(1, get_settings().llm_max_parallel)
|
||||||
|
endpoint = _endpoints.get(base_url)
|
||||||
|
if endpoint is None or endpoint.limit != limit:
|
||||||
|
# A changed limit (settings reloaded in a test) rebuilds the gate.
|
||||||
|
# In-flight callers hold the old semaphore and still release it.
|
||||||
|
endpoint = _Endpoint(limit)
|
||||||
|
_endpoints[base_url] = endpoint
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_busy(base_url: str) -> bool:
|
||||||
|
"""Is every slot on this endpoint taken right now?
|
||||||
|
|
||||||
|
Read by the query mode so a waiting turn can SAY it is waiting instead of
|
||||||
|
showing a frozen cursor. Advisory: by the time the caller acquires, a slot
|
||||||
|
may well have freed.
|
||||||
|
"""
|
||||||
|
endpoint = _endpoints.get(base_url)
|
||||||
|
return endpoint is not None and endpoint.saturated
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def slot(base_url: str, role: str) -> AsyncIterator[None]:
|
||||||
|
"""Hold one of the endpoint's slots for the whole call.
|
||||||
|
|
||||||
|
For a stream that means until the last token: a streaming completion
|
||||||
|
occupies its server slot until it ends, and releasing early would let the
|
||||||
|
gate admit work the endpoint has no room for.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
endpoint = _endpoint_for(base_url)
|
||||||
|
|
||||||
|
if endpoint.saturated and endpoint.waiting >= max(0, settings.llm_max_queued):
|
||||||
|
raise _busy_error(role, "queue_full", 0.0)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
endpoint.waiting += 1
|
||||||
|
metrics.set_gauge("llm_queue_waiting", float(endpoint.waiting), {"role": role})
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
endpoint.semaphore.acquire(), timeout=settings.llm_queue_wait_seconds
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
raise _busy_error(role, "queue_timeout", time.monotonic() - started) from None
|
||||||
|
finally:
|
||||||
|
endpoint.waiting -= 1
|
||||||
|
metrics.set_gauge("llm_queue_waiting", float(endpoint.waiting), {"role": role})
|
||||||
|
|
||||||
|
waited = time.monotonic() - started
|
||||||
|
if waited > 0.01:
|
||||||
|
metrics.observe("llm_queue_wait_seconds", waited, {"role": role})
|
||||||
|
endpoint.in_flight += 1
|
||||||
|
metrics.set_gauge("llm_in_flight", float(endpoint.in_flight), {"role": role})
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
endpoint.in_flight -= 1
|
||||||
|
metrics.set_gauge("llm_in_flight", float(endpoint.in_flight), {"role": role})
|
||||||
|
endpoint.semaphore.release()
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Drop every gate. Tests only — a live gate holds waiters."""
|
||||||
|
_endpoints.clear()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""The LLM endpoint configuration, as the process sees it.
|
||||||
|
|
||||||
|
**Bootstrap, then DB.** On the very first start the `PABLAN_*` environment
|
||||||
|
variables are copied into `llm_settings`, one row per role. From that
|
||||||
|
moment the table is the truth: later `.env` edits are ignored, because a
|
||||||
|
configuration an admin can change in the UI and a configuration the
|
||||||
|
deployment can change underneath them cannot both be authoritative. The
|
||||||
|
environment stays reachable as the value a field can be *reset* to, which
|
||||||
|
is what `env_defaults()` is for.
|
||||||
|
|
||||||
|
The configuration lives in a module-level cache because `_role_config` is a
|
||||||
|
hot, synchronous function on every LLM call — it cannot await a query. The
|
||||||
|
cache is filled at startup and refreshed whenever an admin writes, which is
|
||||||
|
also when the OpenAI clients are rebuilt.
|
||||||
|
|
||||||
|
Single-process by design: the customer stack pins `--workers 1` (see
|
||||||
|
architecture.md), so there is exactly one cache to refresh. A multi-worker
|
||||||
|
deployment would need a notification channel instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import LLMSetting
|
||||||
|
|
||||||
|
logger = logging.getLogger("pablan.llm")
|
||||||
|
|
||||||
|
ROLES = ("chat", "utility", "embedding")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoleConfig:
|
||||||
|
"""One role's stored configuration. A None field means the column is
|
||||||
|
empty, which after bootstrap only happens if an admin blanked it."""
|
||||||
|
|
||||||
|
base_url: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_config: dict[str, RoleConfig] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def env_defaults(role: str) -> RoleConfig:
|
||||||
|
"""What `.env` says for this role — the value "reset to .env" restores.
|
||||||
|
|
||||||
|
Read live rather than remembered from bootstrap: an admin who fixes a
|
||||||
|
typo in `.env` and resets the field should get the corrected value, not
|
||||||
|
the one that was wrong at install time.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
base_url, api_key, model = {
|
||||||
|
"chat": (settings.chat_base_url, settings.chat_api_key, settings.chat_model),
|
||||||
|
"utility": (
|
||||||
|
settings.utility_base_url,
|
||||||
|
settings.utility_api_key,
|
||||||
|
settings.utility_model,
|
||||||
|
),
|
||||||
|
"embedding": (
|
||||||
|
settings.embedding_base_url,
|
||||||
|
settings.embedding_api_key,
|
||||||
|
settings.embedding_model,
|
||||||
|
),
|
||||||
|
}[role]
|
||||||
|
return RoleConfig(base_url=base_url, model=model, api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
|
_FIELDS = ("base_url", "model", "api_key")
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_llm_settings(db: AsyncSession) -> int:
|
||||||
|
"""Copy the environment into any field that still defers to it.
|
||||||
|
|
||||||
|
Runs at every startup, but only ever fills blanks: a field is written
|
||||||
|
exactly when it is flagged `*_from_env` AND currently empty. That is
|
||||||
|
true for a fresh install (no rows yet) and for a field an upgrade
|
||||||
|
marked as still belonging to `.env`, and false for anything an admin
|
||||||
|
has typed, which is never touched.
|
||||||
|
|
||||||
|
Returns the number of fields written.
|
||||||
|
"""
|
||||||
|
rows = {row.role: row for row in (await db.execute(select(LLMSetting))).scalars()}
|
||||||
|
written = 0
|
||||||
|
for role in ROLES:
|
||||||
|
row = rows.get(role)
|
||||||
|
if row is None:
|
||||||
|
# Flags set explicitly rather than left to the column defaults:
|
||||||
|
# those only materialise on flush, and the loop below reads them
|
||||||
|
# before that.
|
||||||
|
row = LLMSetting(
|
||||||
|
role=role,
|
||||||
|
base_url_from_env=True,
|
||||||
|
model_from_env=True,
|
||||||
|
api_key_from_env=True,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
defaults = env_defaults(role)
|
||||||
|
for field in _FIELDS:
|
||||||
|
if not getattr(row, f"{field}_from_env") or getattr(row, field):
|
||||||
|
continue
|
||||||
|
setattr(row, field, getattr(defaults, field) or None)
|
||||||
|
written += 1
|
||||||
|
if written:
|
||||||
|
await db.commit()
|
||||||
|
# Counts and roles only — never the values, one of which is a key.
|
||||||
|
# NB: not `created` — logging reserves that name on
|
||||||
|
# LogRecord and raises KeyError when an `extra` key collides with it.
|
||||||
|
logger.info(
|
||||||
|
"llm settings bootstrapped",
|
||||||
|
extra={"event": "llm_bootstrap", "fields_written": written},
|
||||||
|
)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
async def load_config(db: AsyncSession) -> None:
|
||||||
|
"""Re-read every stored row. Call after any write."""
|
||||||
|
rows = (await db.execute(select(LLMSetting))).scalars().all()
|
||||||
|
_config.clear()
|
||||||
|
_config.update(
|
||||||
|
{
|
||||||
|
row.role: RoleConfig(
|
||||||
|
base_url=row.base_url, model=row.model, api_key=row.api_key
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"llm settings loaded",
|
||||||
|
extra={"event": "llm_settings_loaded", "roles": sorted(_config)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(role: str) -> RoleConfig:
|
||||||
|
return _config.get(role, RoleConfig())
|
||||||
|
|
||||||
|
|
||||||
|
def clear() -> None:
|
||||||
|
"""Drop the cache — used by tests between cases."""
|
||||||
|
_config.clear()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Structured JSON logging (stdlib only).
|
||||||
|
|
||||||
|
Logging policy: log lines carry metadata only — never
|
||||||
|
prompts, LLM responses, user messages or document text. Exceptions are
|
||||||
|
reduced to their type plus a sanitized message; SQLAlchemy statement/param
|
||||||
|
dumps are stripped because parameters can contain user content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None)
|
||||||
|
conversation_id: ContextVar[str | None] = ContextVar("conversation_id", default=None)
|
||||||
|
|
||||||
|
# LogRecord attributes that are not user-supplied extras.
|
||||||
|
_STANDARD_ATTRS = frozenset(
|
||||||
|
{
|
||||||
|
"args",
|
||||||
|
"asctime",
|
||||||
|
"created",
|
||||||
|
"exc_info",
|
||||||
|
"exc_text",
|
||||||
|
"filename",
|
||||||
|
"funcName",
|
||||||
|
"levelname",
|
||||||
|
"levelno",
|
||||||
|
"lineno",
|
||||||
|
"message",
|
||||||
|
"module",
|
||||||
|
"msecs",
|
||||||
|
"msg",
|
||||||
|
"name",
|
||||||
|
"pathname",
|
||||||
|
"process",
|
||||||
|
"processName",
|
||||||
|
"relativeCreated",
|
||||||
|
"stack_info",
|
||||||
|
"taskName",
|
||||||
|
"thread",
|
||||||
|
"threadName",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_error(exc: BaseException, limit: int = 300) -> str:
|
||||||
|
"""Exception text that is safe to log or persist (no content leaks).
|
||||||
|
|
||||||
|
SQLAlchemy appends "[SQL: ...] [parameters: (...)]" to its messages;
|
||||||
|
parameters can contain user content, so everything from "[SQL" on is cut.
|
||||||
|
"""
|
||||||
|
text = str(exc).split("[SQL", 1)[0].strip()
|
||||||
|
return f"{type(exc).__name__}: {text[:limit]}"
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat(
|
||||||
|
timespec="milliseconds"
|
||||||
|
),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
cid = correlation_id.get()
|
||||||
|
if cid:
|
||||||
|
payload["correlation_id"] = cid
|
||||||
|
conv = conversation_id.get()
|
||||||
|
if conv:
|
||||||
|
payload["conversation_id"] = conv
|
||||||
|
for key, value in record.__dict__.items():
|
||||||
|
if key not in _STANDARD_ATTRS and not key.startswith("_"):
|
||||||
|
payload[key] = value
|
||||||
|
if record.exc_info and record.exc_info[1] is not None:
|
||||||
|
payload["error"] = safe_error(record.exc_info[1])
|
||||||
|
return json.dumps(payload, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
# These third-party loggers dump request/response bodies at DEBUG — with
|
||||||
|
# prompts and user content in them, so they stay capped at INFO unless
|
||||||
|
# content debug logging is explicitly enabled.
|
||||||
|
_CONTENT_DEBUG_LOGGERS = ("openai", "httpx", "httpcore")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_content_log_guard() -> None:
|
||||||
|
level = logging.DEBUG if get_settings().debug_log_prompts else logging.INFO
|
||||||
|
for name in _CONTENT_DEBUG_LOGGERS:
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = [handler]
|
||||||
|
root.setLevel(get_settings().log_level.upper())
|
||||||
|
apply_content_log_guard()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
|
||||||
|
from app.api import api_router
|
||||||
|
from app.db import async_session_factory
|
||||||
|
from app.errors import register_exception_handlers
|
||||||
|
from app.help_import import import_help_documents
|
||||||
|
from app.ingestion.handlers import ensure_retention_scheduled
|
||||||
|
from app.ingestion.queue import run_queue
|
||||||
|
from app.llm.overrides import bootstrap_llm_settings
|
||||||
|
from app.llm.overrides import load_config as load_llm_config
|
||||||
|
from app.log import correlation_id, setup_logging
|
||||||
|
from app.prompts.overrides import load_config as load_prompt_config
|
||||||
|
from app.template_catalog import seed_starter_templates
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
setup_logging()
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
await ensure_retention_scheduled(db)
|
||||||
|
await seed_starter_templates(db)
|
||||||
|
await import_help_documents(db)
|
||||||
|
await bootstrap_llm_settings(db)
|
||||||
|
await load_llm_config(db)
|
||||||
|
await load_prompt_config(db)
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
queue_task = asyncio.create_task(run_queue(stop_event))
|
||||||
|
yield
|
||||||
|
stop_event.set()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(queue_task, timeout=10)
|
||||||
|
except TimeoutError: # pragma: no cover — a handler refused to finish
|
||||||
|
queue_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Pablan", version="0.1.0", lifespan=lifespan)
|
||||||
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_correlation_id(
|
||||||
|
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
cid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]
|
||||||
|
token = correlation_id.set(cid)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
finally:
|
||||||
|
correlation_id.reset(token)
|
||||||
|
response.headers["x-request-id"] = cid
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""In-process metrics registry — no dependencies, single event loop.
|
||||||
|
|
||||||
|
Counters, gauges and histogram summaries (count/sum/min/max), labeled.
|
||||||
|
Exposed as JSON via GET /api/admin/metrics; a Prometheus text exporter would
|
||||||
|
sit on top of this registry rather than replace it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
LabelKey = tuple[tuple[str, str], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _key(labels: dict[str, str] | None) -> LabelKey:
|
||||||
|
return tuple(sorted((labels or {}).items()))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistogramData:
|
||||||
|
count: int = 0
|
||||||
|
total: float = 0.0
|
||||||
|
minimum: float | None = None
|
||||||
|
maximum: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._counters: dict[str, dict[LabelKey, float]] = defaultdict(
|
||||||
|
lambda: defaultdict(float)
|
||||||
|
)
|
||||||
|
self._gauges: dict[str, dict[LabelKey, float]] = defaultdict(dict)
|
||||||
|
self._histograms: dict[str, dict[LabelKey, HistogramData]] = defaultdict(dict)
|
||||||
|
|
||||||
|
def inc(
|
||||||
|
self, name: str, labels: dict[str, str] | None = None, value: float = 1.0
|
||||||
|
) -> None:
|
||||||
|
self._counters[name][_key(labels)] += value
|
||||||
|
|
||||||
|
def set_gauge(
|
||||||
|
self, name: str, value: float, labels: dict[str, str] | None = None
|
||||||
|
) -> None:
|
||||||
|
self._gauges[name][_key(labels)] = value
|
||||||
|
|
||||||
|
def observe(
|
||||||
|
self, name: str, value: float, labels: dict[str, str] | None = None
|
||||||
|
) -> None:
|
||||||
|
data = self._histograms[name].setdefault(_key(labels), HistogramData())
|
||||||
|
data.count += 1
|
||||||
|
data.total += value
|
||||||
|
data.minimum = value if data.minimum is None else min(data.minimum, value)
|
||||||
|
data.maximum = value if data.maximum is None else max(data.maximum, value)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"counters": {
|
||||||
|
name: [
|
||||||
|
{"labels": dict(key), "value": value}
|
||||||
|
for key, value in sorted(series.items())
|
||||||
|
]
|
||||||
|
for name, series in sorted(self._counters.items())
|
||||||
|
},
|
||||||
|
"gauges": {
|
||||||
|
name: [
|
||||||
|
{"labels": dict(key), "value": value}
|
||||||
|
for key, value in sorted(series.items())
|
||||||
|
]
|
||||||
|
for name, series in sorted(self._gauges.items())
|
||||||
|
},
|
||||||
|
"histograms": {
|
||||||
|
name: [
|
||||||
|
{
|
||||||
|
"labels": dict(key),
|
||||||
|
"count": data.count,
|
||||||
|
"sum": data.total,
|
||||||
|
"min": data.minimum,
|
||||||
|
"max": data.maximum,
|
||||||
|
"avg": data.total / data.count if data.count else None,
|
||||||
|
}
|
||||||
|
for key, data in sorted(series.items())
|
||||||
|
]
|
||||||
|
for name, series in sorted(self._histograms.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._counters.clear()
|
||||||
|
self._gauges.clear()
|
||||||
|
self._histograms.clear()
|
||||||
|
|
||||||
|
|
||||||
|
metrics = MetricsRegistry()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from app.models.auth_session import AuthSession
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.conversation import Conversation, Message
|
||||||
|
from app.models.department import Department
|
||||||
|
from app.models.document import (
|
||||||
|
EMBEDDING_DIM,
|
||||||
|
Chunk,
|
||||||
|
DocPermission,
|
||||||
|
Document,
|
||||||
|
DocumentEvent,
|
||||||
|
ReviewRequest,
|
||||||
|
)
|
||||||
|
from app.models.enums import (
|
||||||
|
AccessReason,
|
||||||
|
ConversationMode,
|
||||||
|
ConversationStatus,
|
||||||
|
DocumentEventAction,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
JobStatus,
|
||||||
|
MessageRole,
|
||||||
|
PermissionLevel,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from app.models.job import Job
|
||||||
|
from app.models.llm_setting import LLMSetting
|
||||||
|
from app.models.prompt_setting import PromptSetting
|
||||||
|
from app.models.template import Template
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EMBEDDING_DIM",
|
||||||
|
"AuthSession",
|
||||||
|
"Base",
|
||||||
|
"Chunk",
|
||||||
|
"Conversation",
|
||||||
|
"ConversationMode",
|
||||||
|
"ConversationStatus",
|
||||||
|
"Department",
|
||||||
|
"DocPermission",
|
||||||
|
"Document",
|
||||||
|
"DocumentEvent",
|
||||||
|
"ReviewRequest",
|
||||||
|
"DocumentEventAction",
|
||||||
|
"DocumentStatus",
|
||||||
|
"DocumentVisibility",
|
||||||
|
"Job",
|
||||||
|
"LLMSetting",
|
||||||
|
"JobStatus",
|
||||||
|
"Message",
|
||||||
|
"MessageRole",
|
||||||
|
"AccessReason",
|
||||||
|
"PermissionLevel",
|
||||||
|
"PromptSetting",
|
||||||
|
"Template",
|
||||||
|
"User",
|
||||||
|
"UserRole",
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
"""Server-side login session; the id doubles as the cookie token."""
|
||||||
|
|
||||||
|
__tablename__ = "auth_sessions"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(lazy="joined")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
# Fetch server-generated defaults (created_at/updated_at) via RETURNING
|
||||||
|
# at flush time — otherwise the async session would need a lazy refresh
|
||||||
|
# on attribute access, which raises MissingGreenlet outside a greenlet.
|
||||||
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Enum, ForeignKey, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import ConversationMode, ConversationStatus, MessageRole
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
"""Chat thread. The only core mode is query (RAG Q&A); EE adds insight.
|
||||||
|
|
||||||
|
Capture is no longer a conversation — it writes a Document directly (see
|
||||||
|
app/authoring/) — so this table holds no per-turn engine state any more.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
mode: Mapped[ConversationMode] = mapped_column(
|
||||||
|
Enum(ConversationMode, native_enum=False, length=32)
|
||||||
|
)
|
||||||
|
status: Mapped[ConversationStatus] = mapped_column(
|
||||||
|
Enum(ConversationStatus, native_enum=False, length=32),
|
||||||
|
default=ConversationStatus.active,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="conversation",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Message.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Message(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
role: Mapped[MessageRole] = mapped_column(
|
||||||
|
Enum(MessageRole, native_enum=False, length=32)
|
||||||
|
)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
# Assistant turns snapshot their citations here ({"sources": [...]}) so
|
||||||
|
# they survive reload and re-indexing — chunks are disposable, the
|
||||||
|
# rendered citation is not.
|
||||||
|
meta: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSONB, default=dict, server_default="{}"
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation: Mapped[Conversation] = relationship(back_populates="messages")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user