Compare commits
1
Commits
97dbff309c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d3a43191 |
@@ -1,72 +0,0 @@
|
|||||||
# 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
@@ -1,18 +0,0 @@
|
|||||||
.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
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.venv
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
tests
|
|
||||||
Dockerfile
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.12
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Generic single-database configuration with an async dbapi.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""${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"}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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"}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""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)
|
|
||||||
)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""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)]
|
|
||||||
)
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""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 []
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"""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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""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"])
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""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
|
|
||||||
]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""The one router constructor the conversations modules share."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
|
|
||||||
def conversations_router() -> APIRouter:
|
|
||||||
return APIRouter(prefix="/conversations", tags=["conversations"])
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""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")}
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
"""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(),
|
|
||||||
)
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""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"])
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""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"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""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"
|
|
||||||
)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""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)]
|
|
||||||
)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""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),
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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",
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""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`.
|
|
||||||
"""
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""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 ""
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""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}"},
|
|
||||||
]
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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},
|
|
||||||
)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
"""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"})
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"""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]
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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()
|
|
||||||
)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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