From 97dbff309ce6e8b5cb7cbfc2bbe07eeefa2d171c Mon Sep 17 00:00:00 2001 From: ProfessorNova <114916947+ProfessorNova@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:36:17 +0200 Subject: [PATCH] Pablan, as it stands Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b --- .env.example | 72 + .gitignore | 18 + CLAUDE.md | 201 + LICENSE | 105 + Makefile | 62 + README.md | 80 + backend/.dockerignore | 5 + backend/.python-version | 1 + backend/Dockerfile | 14 + backend/README.md | 0 backend/alembic.ini | 150 + backend/alembic/README | 1 + backend/alembic/env.py | 69 + backend/alembic/script.py.mako | 28 + .../versions/15bc390bcabb_message_meta.py | 38 + .../versions/50f054decf55_dismissed_hints.py | 38 + .../versions/563ae5ac1d0d_initial_schema.py | 405 ++ .../717591478a2a_builtin_help_documents.py | 32 + .../versions/72ef34f36387_llm_settings.py | 50 + .../8c31d0a4e7b2_user_locale_drop_hints.py | 45 + ...f4a71c60d38_templates_are_never_builtin.py | 42 + .../a71e3c92fd45_llm_settings_provenance.py | 52 + .../versions/b8e14d7c05a3_review_requests.py | 87 + .../c4f7a1b2e9d3_document_reviewer.py | 42 + .../versions/d5a9c1e3b7f2_document_events.py | 94 + .../versions/f3c8d5a92b47_prompt_settings.py | 51 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 29 + backend/app/api/account.py | 136 + backend/app/api/admin/__init__.py | 18 + backend/app/api/admin/departments.py | 108 + backend/app/api/admin/llm.py | 265 + backend/app/api/admin/observability.py | 19 + backend/app/api/admin/prompts.py | 87 + backend/app/api/admin/routing.py | 16 + backend/app/api/admin/users.py | 196 + backend/app/api/auth.py | 87 + backend/app/api/authoring/__init__.py | 18 + backend/app/api/authoring/grounding.py | 74 + backend/app/api/authoring/refine.py | 173 + backend/app/api/authoring/routing.py | 12 + backend/app/api/authoring/suggest.py | 101 + backend/app/api/conversations/__init__.py | 19 + backend/app/api/conversations/access.py | 35 + backend/app/api/conversations/crud.py | 101 + backend/app/api/conversations/routing.py | 7 + backend/app/api/conversations/schemas.py | 55 + backend/app/api/conversations/turns.py | 226 + backend/app/api/conversations/view.py | 43 + backend/app/api/departments.py | 32 + backend/app/api/documents/__init__.py | 27 + backend/app/api/documents/access.py | 175 + backend/app/api/documents/browse.py | 293 + backend/app/api/documents/crud.py | 236 + backend/app/api/documents/history.py | 112 + backend/app/api/documents/routing.py | 13 + backend/app/api/documents/schemas.py | 198 + backend/app/api/documents/sharing.py | 86 + backend/app/api/documents/view.py | 158 + backend/app/api/documents/workflow.py | 174 + backend/app/api/people.py | 72 + backend/app/api/sse.py | 13 + backend/app/api/templates/__init__.py | 19 + backend/app/api/templates/blueprints.py | 67 + backend/app/api/templates/browse.py | 45 + backend/app/api/templates/catalog.py | 79 + backend/app/api/templates/edit.py | 135 + backend/app/api/templates/routing.py | 21 + backend/app/api/templates/schemas.py | 61 + backend/app/api/templates/view.py | 33 + backend/app/auth/__init__.py | 0 backend/app/auth/deps.py | 40 + backend/app/auth/passwords.py | 23 + backend/app/auth/sessions.py | 68 + backend/app/authoring/__init__.py | 8 + backend/app/authoring/context.py | 67 + backend/app/authoring/document.py | 23 + backend/app/authoring/history.py | 40 + backend/app/authoring/prompts.py | 81 + backend/app/authoring/schema.py | 67 + backend/app/authoring/sections.py | 131 + backend/app/config.py | 64 + backend/app/db.py | 17 + backend/app/errors.py | 20 + backend/app/help_import.py | 97 + backend/app/ingestion/__init__.py | 0 backend/app/ingestion/handlers.py | 115 + backend/app/ingestion/queue.py | 179 + backend/app/llm/__init__.py | 0 backend/app/llm/client.py | 347 ++ backend/app/llm/errors.py | 101 + backend/app/llm/gate.py | 150 + backend/app/llm/overrides.py | 143 + backend/app/log.py | 103 + backend/app/main.py | 59 + backend/app/metrics.py | 92 + backend/app/models/__init__.py | 58 + backend/app/models/auth_session.py | 21 + backend/app/models/base.py | 25 + backend/app/models/conversation.py | 56 + backend/app/models/department.py | 10 + backend/app/models/document.py | 199 + backend/app/models/enums.py | 87 + backend/app/models/job.py | 27 + backend/app/models/llm_setting.py | 36 + backend/app/models/prompt_setting.py | 23 + backend/app/models/template.py | 18 + backend/app/models/user.py | 29 + backend/app/modes/__init__.py | 6 + backend/app/modes/base.py | 96 + backend/app/modes/prompts.py | 31 + backend/app/modes/query.py | 213 + backend/app/modes/registry.py | 18 + backend/app/prompts/__init__.py | 0 backend/app/prompts/defaults.py | 89 + backend/app/prompts/overrides.py | 55 + backend/app/rag/__init__.py | 0 backend/app/rag/chunking.py | 107 + backend/app/rag/indexing.py | 86 + backend/app/rag/permissions.py | 109 + backend/app/rag/retrieval.py | 277 + backend/app/rag/similarity.py | 183 + backend/app/seed.py | 364 ++ backend/app/template_catalog.py | 181 + backend/app/template_import.py | 62 + backend/pyproject.toml | 42 + backend/scripts/check-no-ui-strings.py | 70 + backend/tests/conftest.py | 176 + backend/tests/embedding_stub.py | 24 + backend/tests/evals/test_refine_eval.py | 88 + backend/tests/evals/test_retrieval_eval.py | 101 + .../tests/evals/test_self_knowledge_eval.py | 153 + .../tests/evals/test_topic_retrieval_eval.py | 111 + backend/tests/fake_openai.py | 151 + backend/tests/fixtures/__init__.py | 0 .../tests/fixtures/conversation_snippets.yaml | 41 + .../fixtures/corpus/angebotskalkulation.md | 35 + .../tests/fixtures/corpus/crm-leitfaden.md | 37 + .../fixtures/corpus/datenschutz-grundlagen.md | 33 + .../tests/fixtures/corpus/edi-rechnungen.md | 38 + .../fixtures/corpus/fehlercodes-sps-s7.md | 38 + .../fixtures/corpus/hydraulik-presse-hp20.md | 40 + .../corpus/it-onboarding-arbeitsplatz.md | 34 + .../fixtures/corpus/messevorbereitung.md | 32 + .../corpus/netzwerk-produktions-it.md | 32 + .../offboarding-krause-instandhaltung.md | 40 + .../corpus/qualitaetspruefung-wareneingang.md | 34 + .../tests/fixtures/corpus/rabattrichtlinie.md | 34 + backend/tests/fixtures/corpus/reisekosten.md | 34 + .../fixtures/corpus/reklamationsprozess.md | 41 + .../fixtures/corpus/schmierstoffe-wartung.md | 44 + .../fixtures/corpus/urlaubsantrag-prozess.md | 35 + .../fixtures/corpus/wartungsplan-cnc-f350.md | 56 + backend/tests/fixtures/golden_queries.yaml | 72 + backend/tests/fixtures/loader.py | 58 + backend/tests/test_account.py | 203 + backend/tests/test_admin_api.py | 83 + backend/tests/test_admin_crud.py | 339 ++ backend/tests/test_auth.py | 125 + backend/tests/test_authoring_sections.py | 70 + backend/tests/test_chunking.py | 57 + backend/tests/test_conversations_api.py | 368 ++ backend/tests/test_documents_api.py | 1098 ++++ backend/tests/test_excerpt.py | 82 + backend/tests/test_help_import.py | 114 + backend/tests/test_indexing.py | 152 + backend/tests/test_llm_client.py | 182 + backend/tests/test_llm_gate.py | 196 + backend/tests/test_llm_settings.py | 286 + backend/tests/test_models.py | 74 + backend/tests/test_observability.py | 130 + backend/tests/test_people.py | 44 + backend/tests/test_prompt_settings.py | 52 + backend/tests/test_query_mode.py | 44 + backend/tests/test_queue.py | 266 + backend/tests/test_refine_grounding.py | 227 + backend/tests/test_retrieval.py | 324 ++ backend/tests/test_similarity.py | 320 + backend/tests/test_template_catalog.py | 128 + backend/tests/test_templates_api.py | 274 + backend/uv.lock | 1219 ++++ deploy/README.md | 8 + deploy/caddy/Caddyfile | 14 + docker-compose.dev.yml | 26 + docker-compose.yml | 86 + docs/api-protocol.md | 415 ++ docs/architecture.md | 465 ++ docs/authoring-templates.md | 226 + docs/data-model.md | 289 + docs/decisions.md | 311 + docs/diagrams/README.md | 24 + docs/diagrams/auth-sequence.svg | 158 + docs/diagrams/components.svg | 179 + docs/diagrams/data-model.svg | 381 ++ docs/diagrams/queue-sequence.svg | 126 + docs/diagrams/retrieval-sequence.svg | 157 + docs/i18n.md | 214 + docs/licensing.md | 50 + docs/notes.md | 322 + docs/roadmap.md | 508 ++ ee/LICENSE | 0 frontend/.dockerignore | 7 + frontend/.gitignore | 28 + frontend/.npmrc | 4 + frontend/.prettierignore | 17 + frontend/.vscode/extensions.json | 8 + frontend/.vscode/settings.json | 5 + frontend/Dockerfile | 17 + frontend/README.md | 42 + frontend/e2e/account.spec.ts | 129 + frontend/e2e/admin.spec.ts | 217 + frontend/e2e/auth.spec.ts | 107 + frontend/e2e/capture.spec.ts | 91 + frontend/e2e/chat.spec.ts | 246 + frontend/e2e/documents.spec.ts | 183 + frontend/e2e/global-setup.ts | 3 + frontend/e2e/global-teardown.ts | 3 + frontend/e2e/helpers.ts | 71 + frontend/e2e/landing.spec.ts | 58 + frontend/e2e/permissions.spec.ts | 67 + frontend/e2e/residue.ts | 58 + frontend/e2e/reviews.spec.ts | 105 + frontend/eslint.config.js | 41 + frontend/messages/de.json | 501 ++ frontend/messages/en.json | 501 ++ frontend/package-lock.json | 5163 +++++++++++++++++ frontend/package.json | 60 + frontend/playwright.config.ts | 30 + frontend/prettier.config.js | 12 + frontend/project.inlang/settings.json | 9 + frontend/scripts/check-messages.py | 105 + frontend/scripts/contrast-check.py | 153 + frontend/src/app.css | 193 + frontend/src/app.d.ts | 17 + frontend/src/app.html | 26 + frontend/src/hooks.server.ts | 42 + frontend/src/hooks.ts | 11 + .../src/lib/admin/DepartmentManager.svelte | 203 + frontend/src/lib/admin/EndpointCheck.svelte | 59 + frontend/src/lib/admin/LlmSettings.svelte | 292 + frontend/src/lib/admin/PromptSettings.svelte | 155 + frontend/src/lib/admin/TemplateBuilder.svelte | 235 + frontend/src/lib/admin/TemplateCatalog.svelte | 76 + frontend/src/lib/admin/TemplateManager.svelte | 243 + .../src/lib/admin/TemplateSections.svelte | 114 + frontend/src/lib/admin/TemplateYaml.svelte | 63 + frontend/src/lib/admin/UserManager.svelte | 412 ++ frontend/src/lib/api/client.ts | 11 + frontend/src/lib/api/errors.ts | 63 + frontend/src/lib/api/refine.ts | 55 + frontend/src/lib/api/schema.d.ts | 3581 ++++++++++++ frontend/src/lib/api/stream.ts | 92 + frontend/src/lib/assets/favicon.svg | 8 + frontend/src/lib/chat/AssistantTurn.svelte | 106 + frontend/src/lib/chat/ChatView.svelte | 206 + frontend/src/lib/chat/Composer.svelte | 75 + frontend/src/lib/chat/ContextInspector.svelte | 48 + frontend/src/lib/chat/DocumentPanel.svelte | 118 + frontend/src/lib/chat/FallbackResults.svelte | 60 + frontend/src/lib/chat/SourceBadge.svelte | 55 + frontend/src/lib/chat/conversations.svelte.ts | 29 + frontend/src/lib/chat/sources.ts | 38 + frontend/src/lib/chat/state.svelte.ts | 219 + frontend/src/lib/components/Badge.svelte | 32 + frontend/src/lib/components/Button.svelte | 42 + frontend/src/lib/components/Card.svelte | 14 + .../src/lib/components/ConfirmDialog.svelte | 52 + frontend/src/lib/components/Dialog.svelte | 56 + frontend/src/lib/components/FormField.svelte | 27 + frontend/src/lib/components/IconAction.svelte | 37 + frontend/src/lib/components/Input.svelte | 18 + frontend/src/lib/components/Markdown.svelte | 87 + frontend/src/lib/components/Menu.svelte | 58 + frontend/src/lib/components/Popover.svelte | 44 + frontend/src/lib/components/Select.svelte | 43 + .../src/lib/components/StreamingText.svelte | 39 + frontend/src/lib/components/Tabs.svelte | 44 + frontend/src/lib/components/Tooltip.svelte | 63 + .../src/lib/documents/AccessPopover.svelte | 207 + .../src/lib/documents/CaptureSuccess.svelte | 112 + .../src/lib/documents/DocumentCard.svelte | 65 + .../src/lib/documents/DocumentFilters.svelte | 213 + .../src/lib/documents/DocumentHistory.svelte | 133 + frontend/src/lib/documents/OpenWork.svelte | 113 + frontend/src/lib/documents/ReviewPanel.svelte | 129 + .../lib/documents/ReviewRequestForm.svelte | 85 + frontend/src/lib/documents/SaveDialog.svelte | 154 + .../lib/documents/VersionDiffDialog.svelte | 72 + .../src/lib/documents/WritingEditor.svelte | 464 ++ .../lib/documents/editor/inlineSuggestion.ts | 172 + frontend/src/lib/documents/editorTheme.ts | 28 + frontend/src/lib/documents/list.svelte.ts | 121 + frontend/src/lib/documents/presentation.ts | 126 + frontend/src/lib/documents/sections.ts | 56 + frontend/src/lib/documents/view.svelte.ts | 48 + frontend/src/lib/i18n/locale.svelte.ts | 86 + frontend/src/lib/i18n/strategy.server.ts | 21 + frontend/src/lib/index.ts | 1 + frontend/src/lib/markdown.ts | 34 + frontend/src/lib/nav/SettingsDialog.svelte | 267 + frontend/src/lib/nav/Sidebar.svelte | 194 + frontend/src/lib/server/api.ts | 29 + frontend/src/lib/theme.svelte.ts | 45 + frontend/src/routes/(app)/+layout.server.ts | 10 + frontend/src/routes/(app)/+layout.svelte | 18 + frontend/src/routes/(app)/+page.svelte | 178 + .../routes/(app)/account/profile/+page.svelte | 165 + .../src/routes/(app)/admin/+page.server.ts | 9 + frontend/src/routes/(app)/admin/+page.svelte | 70 + frontend/src/routes/(app)/chat/+page.svelte | 16 + .../routes/(app)/chat/[id]/+page.server.ts | 22 + .../src/routes/(app)/chat/[id]/+page.svelte | 10 + .../src/routes/(app)/documents/+page.svelte | 97 + .../routes/(app)/documents/[id]/+page.svelte | 287 + .../(app)/documents/[id]/edit/+page.svelte | 55 + .../routes/(app)/documents/new/+page.svelte | 137 + frontend/src/routes/(app)/people/+page.svelte | 105 + .../src/routes/(app)/people/[id]/+page.svelte | 78 + frontend/src/routes/+layout.server.ts | 5 + frontend/src/routes/+layout.svelte | 25 + frontend/src/routes/+layout.ts | 6 + frontend/src/routes/login/+page.server.ts | 9 + frontend/src/routes/login/+page.svelte | 79 + frontend/static/robots.txt | 3 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 43 + help/administration.de.md | 61 + help/dokumente-und-sichtbarkeit.de.md | 73 + help/fragen-und-antworten.de.md | 70 + help/kolleginnen-und-profil.de.md | 43 + help/pablan-ueberblick.de.md | 53 + help/wissen-festhalten.de.md | 110 + templates/anlage.de.yaml | 59 + templates/anlage.en.yaml | 54 + templates/entscheidung.de.yaml | 51 + templates/entscheidung.en.yaml | 46 + templates/notiz.de.yaml | 37 + templates/notiz.en.yaml | 34 + templates/person.de.yaml | 52 + templates/person.en.yaml | 47 + templates/projekt-debrief.de.yaml | 51 + templates/projekt-debrief.en.yaml | 47 + templates/prozess.de.yaml | 57 + templates/prozess.en.yaml | 54 + templates/stoerung.de.yaml | 58 + templates/stoerung.en.yaml | 52 + 346 files changed, 43430 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/.python-version create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/15bc390bcabb_message_meta.py create mode 100644 backend/alembic/versions/50f054decf55_dismissed_hints.py create mode 100644 backend/alembic/versions/563ae5ac1d0d_initial_schema.py create mode 100644 backend/alembic/versions/717591478a2a_builtin_help_documents.py create mode 100644 backend/alembic/versions/72ef34f36387_llm_settings.py create mode 100644 backend/alembic/versions/8c31d0a4e7b2_user_locale_drop_hints.py create mode 100644 backend/alembic/versions/9f4a71c60d38_templates_are_never_builtin.py create mode 100644 backend/alembic/versions/a71e3c92fd45_llm_settings_provenance.py create mode 100644 backend/alembic/versions/b8e14d7c05a3_review_requests.py create mode 100644 backend/alembic/versions/c4f7a1b2e9d3_document_reviewer.py create mode 100644 backend/alembic/versions/d5a9c1e3b7f2_document_events.py create mode 100644 backend/alembic/versions/f3c8d5a92b47_prompt_settings.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/account.py create mode 100644 backend/app/api/admin/__init__.py create mode 100644 backend/app/api/admin/departments.py create mode 100644 backend/app/api/admin/llm.py create mode 100644 backend/app/api/admin/observability.py create mode 100644 backend/app/api/admin/prompts.py create mode 100644 backend/app/api/admin/routing.py create mode 100644 backend/app/api/admin/users.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/authoring/__init__.py create mode 100644 backend/app/api/authoring/grounding.py create mode 100644 backend/app/api/authoring/refine.py create mode 100644 backend/app/api/authoring/routing.py create mode 100644 backend/app/api/authoring/suggest.py create mode 100644 backend/app/api/conversations/__init__.py create mode 100644 backend/app/api/conversations/access.py create mode 100644 backend/app/api/conversations/crud.py create mode 100644 backend/app/api/conversations/routing.py create mode 100644 backend/app/api/conversations/schemas.py create mode 100644 backend/app/api/conversations/turns.py create mode 100644 backend/app/api/conversations/view.py create mode 100644 backend/app/api/departments.py create mode 100644 backend/app/api/documents/__init__.py create mode 100644 backend/app/api/documents/access.py create mode 100644 backend/app/api/documents/browse.py create mode 100644 backend/app/api/documents/crud.py create mode 100644 backend/app/api/documents/history.py create mode 100644 backend/app/api/documents/routing.py create mode 100644 backend/app/api/documents/schemas.py create mode 100644 backend/app/api/documents/sharing.py create mode 100644 backend/app/api/documents/view.py create mode 100644 backend/app/api/documents/workflow.py create mode 100644 backend/app/api/people.py create mode 100644 backend/app/api/sse.py create mode 100644 backend/app/api/templates/__init__.py create mode 100644 backend/app/api/templates/blueprints.py create mode 100644 backend/app/api/templates/browse.py create mode 100644 backend/app/api/templates/catalog.py create mode 100644 backend/app/api/templates/edit.py create mode 100644 backend/app/api/templates/routing.py create mode 100644 backend/app/api/templates/schemas.py create mode 100644 backend/app/api/templates/view.py create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/deps.py create mode 100644 backend/app/auth/passwords.py create mode 100644 backend/app/auth/sessions.py create mode 100644 backend/app/authoring/__init__.py create mode 100644 backend/app/authoring/context.py create mode 100644 backend/app/authoring/document.py create mode 100644 backend/app/authoring/history.py create mode 100644 backend/app/authoring/prompts.py create mode 100644 backend/app/authoring/schema.py create mode 100644 backend/app/authoring/sections.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/errors.py create mode 100644 backend/app/help_import.py create mode 100644 backend/app/ingestion/__init__.py create mode 100644 backend/app/ingestion/handlers.py create mode 100644 backend/app/ingestion/queue.py create mode 100644 backend/app/llm/__init__.py create mode 100644 backend/app/llm/client.py create mode 100644 backend/app/llm/errors.py create mode 100644 backend/app/llm/gate.py create mode 100644 backend/app/llm/overrides.py create mode 100644 backend/app/log.py create mode 100644 backend/app/main.py create mode 100644 backend/app/metrics.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/auth_session.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/conversation.py create mode 100644 backend/app/models/department.py create mode 100644 backend/app/models/document.py create mode 100644 backend/app/models/enums.py create mode 100644 backend/app/models/job.py create mode 100644 backend/app/models/llm_setting.py create mode 100644 backend/app/models/prompt_setting.py create mode 100644 backend/app/models/template.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/modes/__init__.py create mode 100644 backend/app/modes/base.py create mode 100644 backend/app/modes/prompts.py create mode 100644 backend/app/modes/query.py create mode 100644 backend/app/modes/registry.py create mode 100644 backend/app/prompts/__init__.py create mode 100644 backend/app/prompts/defaults.py create mode 100644 backend/app/prompts/overrides.py create mode 100644 backend/app/rag/__init__.py create mode 100644 backend/app/rag/chunking.py create mode 100644 backend/app/rag/indexing.py create mode 100644 backend/app/rag/permissions.py create mode 100644 backend/app/rag/retrieval.py create mode 100644 backend/app/rag/similarity.py create mode 100644 backend/app/seed.py create mode 100644 backend/app/template_catalog.py create mode 100644 backend/app/template_import.py create mode 100644 backend/pyproject.toml create mode 100644 backend/scripts/check-no-ui-strings.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/embedding_stub.py create mode 100644 backend/tests/evals/test_refine_eval.py create mode 100644 backend/tests/evals/test_retrieval_eval.py create mode 100644 backend/tests/evals/test_self_knowledge_eval.py create mode 100644 backend/tests/evals/test_topic_retrieval_eval.py create mode 100644 backend/tests/fake_openai.py create mode 100644 backend/tests/fixtures/__init__.py create mode 100644 backend/tests/fixtures/conversation_snippets.yaml create mode 100644 backend/tests/fixtures/corpus/angebotskalkulation.md create mode 100644 backend/tests/fixtures/corpus/crm-leitfaden.md create mode 100644 backend/tests/fixtures/corpus/datenschutz-grundlagen.md create mode 100644 backend/tests/fixtures/corpus/edi-rechnungen.md create mode 100644 backend/tests/fixtures/corpus/fehlercodes-sps-s7.md create mode 100644 backend/tests/fixtures/corpus/hydraulik-presse-hp20.md create mode 100644 backend/tests/fixtures/corpus/it-onboarding-arbeitsplatz.md create mode 100644 backend/tests/fixtures/corpus/messevorbereitung.md create mode 100644 backend/tests/fixtures/corpus/netzwerk-produktions-it.md create mode 100644 backend/tests/fixtures/corpus/offboarding-krause-instandhaltung.md create mode 100644 backend/tests/fixtures/corpus/qualitaetspruefung-wareneingang.md create mode 100644 backend/tests/fixtures/corpus/rabattrichtlinie.md create mode 100644 backend/tests/fixtures/corpus/reisekosten.md create mode 100644 backend/tests/fixtures/corpus/reklamationsprozess.md create mode 100644 backend/tests/fixtures/corpus/schmierstoffe-wartung.md create mode 100644 backend/tests/fixtures/corpus/urlaubsantrag-prozess.md create mode 100644 backend/tests/fixtures/corpus/wartungsplan-cnc-f350.md create mode 100644 backend/tests/fixtures/golden_queries.yaml create mode 100644 backend/tests/fixtures/loader.py create mode 100644 backend/tests/test_account.py create mode 100644 backend/tests/test_admin_api.py create mode 100644 backend/tests/test_admin_crud.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_authoring_sections.py create mode 100644 backend/tests/test_chunking.py create mode 100644 backend/tests/test_conversations_api.py create mode 100644 backend/tests/test_documents_api.py create mode 100644 backend/tests/test_excerpt.py create mode 100644 backend/tests/test_help_import.py create mode 100644 backend/tests/test_indexing.py create mode 100644 backend/tests/test_llm_client.py create mode 100644 backend/tests/test_llm_gate.py create mode 100644 backend/tests/test_llm_settings.py create mode 100644 backend/tests/test_models.py create mode 100644 backend/tests/test_observability.py create mode 100644 backend/tests/test_people.py create mode 100644 backend/tests/test_prompt_settings.py create mode 100644 backend/tests/test_query_mode.py create mode 100644 backend/tests/test_queue.py create mode 100644 backend/tests/test_refine_grounding.py create mode 100644 backend/tests/test_retrieval.py create mode 100644 backend/tests/test_similarity.py create mode 100644 backend/tests/test_template_catalog.py create mode 100644 backend/tests/test_templates_api.py create mode 100644 backend/uv.lock create mode 100644 deploy/README.md create mode 100644 deploy/caddy/Caddyfile create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 docs/api-protocol.md create mode 100644 docs/architecture.md create mode 100644 docs/authoring-templates.md create mode 100644 docs/data-model.md create mode 100644 docs/decisions.md create mode 100644 docs/diagrams/README.md create mode 100644 docs/diagrams/auth-sequence.svg create mode 100644 docs/diagrams/components.svg create mode 100644 docs/diagrams/data-model.svg create mode 100644 docs/diagrams/queue-sequence.svg create mode 100644 docs/diagrams/retrieval-sequence.svg create mode 100644 docs/i18n.md create mode 100644 docs/licensing.md create mode 100644 docs/notes.md create mode 100644 docs/roadmap.md create mode 100644 ee/LICENSE create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/.npmrc create mode 100644 frontend/.prettierignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/.vscode/settings.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/e2e/account.spec.ts create mode 100644 frontend/e2e/admin.spec.ts create mode 100644 frontend/e2e/auth.spec.ts create mode 100644 frontend/e2e/capture.spec.ts create mode 100644 frontend/e2e/chat.spec.ts create mode 100644 frontend/e2e/documents.spec.ts create mode 100644 frontend/e2e/global-setup.ts create mode 100644 frontend/e2e/global-teardown.ts create mode 100644 frontend/e2e/helpers.ts create mode 100644 frontend/e2e/landing.spec.ts create mode 100644 frontend/e2e/permissions.spec.ts create mode 100644 frontend/e2e/residue.ts create mode 100644 frontend/e2e/reviews.spec.ts create mode 100644 frontend/eslint.config.js create mode 100644 frontend/messages/de.json create mode 100644 frontend/messages/en.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/prettier.config.js create mode 100644 frontend/project.inlang/settings.json create mode 100644 frontend/scripts/check-messages.py create mode 100644 frontend/scripts/contrast-check.py create mode 100644 frontend/src/app.css create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/hooks.server.ts create mode 100644 frontend/src/hooks.ts create mode 100644 frontend/src/lib/admin/DepartmentManager.svelte create mode 100644 frontend/src/lib/admin/EndpointCheck.svelte create mode 100644 frontend/src/lib/admin/LlmSettings.svelte create mode 100644 frontend/src/lib/admin/PromptSettings.svelte create mode 100644 frontend/src/lib/admin/TemplateBuilder.svelte create mode 100644 frontend/src/lib/admin/TemplateCatalog.svelte create mode 100644 frontend/src/lib/admin/TemplateManager.svelte create mode 100644 frontend/src/lib/admin/TemplateSections.svelte create mode 100644 frontend/src/lib/admin/TemplateYaml.svelte create mode 100644 frontend/src/lib/admin/UserManager.svelte create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/errors.ts create mode 100644 frontend/src/lib/api/refine.ts create mode 100644 frontend/src/lib/api/schema.d.ts create mode 100644 frontend/src/lib/api/stream.ts create mode 100644 frontend/src/lib/assets/favicon.svg create mode 100644 frontend/src/lib/chat/AssistantTurn.svelte create mode 100644 frontend/src/lib/chat/ChatView.svelte create mode 100644 frontend/src/lib/chat/Composer.svelte create mode 100644 frontend/src/lib/chat/ContextInspector.svelte create mode 100644 frontend/src/lib/chat/DocumentPanel.svelte create mode 100644 frontend/src/lib/chat/FallbackResults.svelte create mode 100644 frontend/src/lib/chat/SourceBadge.svelte create mode 100644 frontend/src/lib/chat/conversations.svelte.ts create mode 100644 frontend/src/lib/chat/sources.ts create mode 100644 frontend/src/lib/chat/state.svelte.ts create mode 100644 frontend/src/lib/components/Badge.svelte create mode 100644 frontend/src/lib/components/Button.svelte create mode 100644 frontend/src/lib/components/Card.svelte create mode 100644 frontend/src/lib/components/ConfirmDialog.svelte create mode 100644 frontend/src/lib/components/Dialog.svelte create mode 100644 frontend/src/lib/components/FormField.svelte create mode 100644 frontend/src/lib/components/IconAction.svelte create mode 100644 frontend/src/lib/components/Input.svelte create mode 100644 frontend/src/lib/components/Markdown.svelte create mode 100644 frontend/src/lib/components/Menu.svelte create mode 100644 frontend/src/lib/components/Popover.svelte create mode 100644 frontend/src/lib/components/Select.svelte create mode 100644 frontend/src/lib/components/StreamingText.svelte create mode 100644 frontend/src/lib/components/Tabs.svelte create mode 100644 frontend/src/lib/components/Tooltip.svelte create mode 100644 frontend/src/lib/documents/AccessPopover.svelte create mode 100644 frontend/src/lib/documents/CaptureSuccess.svelte create mode 100644 frontend/src/lib/documents/DocumentCard.svelte create mode 100644 frontend/src/lib/documents/DocumentFilters.svelte create mode 100644 frontend/src/lib/documents/DocumentHistory.svelte create mode 100644 frontend/src/lib/documents/OpenWork.svelte create mode 100644 frontend/src/lib/documents/ReviewPanel.svelte create mode 100644 frontend/src/lib/documents/ReviewRequestForm.svelte create mode 100644 frontend/src/lib/documents/SaveDialog.svelte create mode 100644 frontend/src/lib/documents/VersionDiffDialog.svelte create mode 100644 frontend/src/lib/documents/WritingEditor.svelte create mode 100644 frontend/src/lib/documents/editor/inlineSuggestion.ts create mode 100644 frontend/src/lib/documents/editorTheme.ts create mode 100644 frontend/src/lib/documents/list.svelte.ts create mode 100644 frontend/src/lib/documents/presentation.ts create mode 100644 frontend/src/lib/documents/sections.ts create mode 100644 frontend/src/lib/documents/view.svelte.ts create mode 100644 frontend/src/lib/i18n/locale.svelte.ts create mode 100644 frontend/src/lib/i18n/strategy.server.ts create mode 100644 frontend/src/lib/index.ts create mode 100644 frontend/src/lib/markdown.ts create mode 100644 frontend/src/lib/nav/SettingsDialog.svelte create mode 100644 frontend/src/lib/nav/Sidebar.svelte create mode 100644 frontend/src/lib/server/api.ts create mode 100644 frontend/src/lib/theme.svelte.ts create mode 100644 frontend/src/routes/(app)/+layout.server.ts create mode 100644 frontend/src/routes/(app)/+layout.svelte create mode 100644 frontend/src/routes/(app)/+page.svelte create mode 100644 frontend/src/routes/(app)/account/profile/+page.svelte create mode 100644 frontend/src/routes/(app)/admin/+page.server.ts create mode 100644 frontend/src/routes/(app)/admin/+page.svelte create mode 100644 frontend/src/routes/(app)/chat/+page.svelte create mode 100644 frontend/src/routes/(app)/chat/[id]/+page.server.ts create mode 100644 frontend/src/routes/(app)/chat/[id]/+page.svelte create mode 100644 frontend/src/routes/(app)/documents/+page.svelte create mode 100644 frontend/src/routes/(app)/documents/[id]/+page.svelte create mode 100644 frontend/src/routes/(app)/documents/[id]/edit/+page.svelte create mode 100644 frontend/src/routes/(app)/documents/new/+page.svelte create mode 100644 frontend/src/routes/(app)/people/+page.svelte create mode 100644 frontend/src/routes/(app)/people/[id]/+page.svelte create mode 100644 frontend/src/routes/+layout.server.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+layout.ts create mode 100644 frontend/src/routes/login/+page.server.ts create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/static/robots.txt create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 help/administration.de.md create mode 100644 help/dokumente-und-sichtbarkeit.de.md create mode 100644 help/fragen-und-antworten.de.md create mode 100644 help/kolleginnen-und-profil.de.md create mode 100644 help/pablan-ueberblick.de.md create mode 100644 help/wissen-festhalten.de.md create mode 100644 templates/anlage.de.yaml create mode 100644 templates/anlage.en.yaml create mode 100644 templates/entscheidung.de.yaml create mode 100644 templates/entscheidung.en.yaml create mode 100644 templates/notiz.de.yaml create mode 100644 templates/notiz.en.yaml create mode 100644 templates/person.de.yaml create mode 100644 templates/person.en.yaml create mode 100644 templates/projekt-debrief.de.yaml create mode 100644 templates/projekt-debrief.en.yaml create mode 100644 templates/prozess.de.yaml create mode 100644 templates/prozess.en.yaml create mode 100644 templates/stoerung.de.yaml create mode 100644 templates/stoerung.en.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8b1ab0 --- /dev/null +++ b/.env.example @@ -0,0 +1,72 @@ +# Pablan configuration — copy to .env and adjust. +# Read by docker compose (variable interpolation) and by the backend settings. + +# --- Environment --- +# "development" or "production". The customer stack (docker-compose.yml) +# defaults to production regardless of this file; native dev reads it as-is. +# Seeding dev data is refused when set to production. +PABLAN_ENV=development +# Secure cookies require https. Dev access happens over plain http via +# WireGuard IPs (not localhost), where browsers silently drop Secure cookies — +# so dev sets false. NEVER set false in production (Caddy terminates https). +PABLAN_COOKIE_SECURE=false + +# --- PostgreSQL --- +POSTGRES_USER=pablan +POSTGRES_PASSWORD=change-me +POSTGRES_DB=pablan +# Dev (make dev): Postgres runs in Docker on localhost:5432. +# In the customer stack (docker-compose.yml) the backend gets this URL with +# host "postgres" instead — set automatically, no need to change it there. +PABLAN_DATABASE_URL=postgresql+asyncpg://pablan:change-me@localhost:5432/pablan + +# --- LLM load --- +# How many requests Pablan lets ONE endpoint see at once. Match it to the +# server's own parallelism (llama.cpp: --parallel); beyond it, requests wait +# here rather than piling up where Pablan cannot bound them. +PABLAN_LLM_MAX_PARALLEL=4 +# How long a request waits for a free slot before the user is told the model +# is busy, and how many may be waiting at all. +PABLAN_LLM_QUEUE_WAIT_SECONDS=20 +PABLAN_LLM_MAX_QUEUED=24 + +# --- LLM endpoints (OpenAI-compatible; three independent model roles) --- +# chat: conversation turns — quality matters (Gemma-class 12B+ locally, cloud in prod) +PABLAN_CHAT_BASE_URL=http://localhost:8001/v1 +PABLAN_CHAT_API_KEY=none +PABLAN_CHAT_MODEL=unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL + +# utility: bookkeeping, summarization, entity extraction — cheap + fast +# (same local server as chat by default) +PABLAN_UTILITY_BASE_URL=http://localhost:8001/v1 +PABLAN_UTILITY_API_KEY=none +PABLAN_UTILITY_MODEL=unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL + +# embedding: multilingual embeddings — German retrieval quality is first-class. +# Expects an OpenAI-compatible server (e.g. llama.cpp with bge-m3) serving +# /v1/embeddings at this base URL. +PABLAN_EMBEDDING_BASE_URL=http://localhost:8002/v1 +PABLAN_EMBEDDING_API_KEY=none +PABLAN_EMBEDDING_MODEL=gpustack/bge-m3-GGUF:Q8_0 + +# --- Privacy / retention --- +# Query-mode conversations are auto-deleted after this many days +# (retention_cleanup job). +PABLAN_QUERY_RETENTION_DAYS=90 + +# --- Logging --- +# PABLAN_LOG_LEVEL=INFO +# Content debug logging (prompts, LLM responses). NEVER enable in production — +# logs must stay free of prompts, user messages and document text. +# PABLAN_DEBUG_LOG_PROMPTS=false + +# --- Tuning (defaults are fine) --- +# PABLAN_LLM_TIMEOUT_SECONDS=120 +# PABLAN_JOB_POLL_SECONDS=1.0 + +# --- Enterprise (ee/) --- +# PABLAN_EE_LICENSE_KEY= + +# --- Customer deployment (docker-compose.yml) --- +# Domain Caddy serves with automatic TLS. Use "localhost" for a local smoke test. +PABLAN_DOMAIN=pablan.example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50fe173 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.env +.venv/ +__pycache__/ +*.pyc +node_modules/ +.svelte-kit/ +build/ +dist/ +test-results/ +playwright-report/ +.claude/settings.local.json +.playwright-mcp/ +/.vscode/ + +# Screenshots taken during visual checks. They belong in a message, not +# in the repository. +*.png +!frontend/static/**/*.png diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9fc6748 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,201 @@ +# CLAUDE.md — Pablan + +Self-hosted knowledge management for SMEs. Employees write knowledge documents +in a split-screen Markdown editor while an LLM refines the section they are +working on; the same LLM answers questions over the resulting documents via +RAG. Everything is stored as Markdown and runs on the customer's +infrastructure. Team of 2 developers — bias toward simplicity, no speculative +abstractions. + +Design docs live in `docs/` (English). Read the relevant doc before larger changes. +**Claude maintains the documentation**: whenever a decision, schema, API, or +workflow changes, update the affected file in `docs/` in the same change — stale +docs are treated as bugs. This includes the diagram sources in +`docs/diagrams/` (hand-authored SVG, theme-aware; the SVG is the artifact, +no diagram toolchain). +`docs/roadmap.md` is the feature-based backlog: every feature area as an epic with +done (`[x]`) and open (`[ ]`) stories, ordered so it reads as a rebuild manual. It +is the only forward-looking doc — its open epics may describe not-yet-built work; +everything else in `docs/` describes the as-is state only. `docs/notes.md` holds the +durable implementation learnings (calibrations, gotchas, why-it-is-this-way). When a +feature ships, tick its story in the roadmap and fold any lasting learning into +`docs/notes.md`, in the same change. The root `README.md` gives a short high-level overview +(what Pablan is, stack, quickstart) and links into `docs/`; it is also +Claude-maintained and must stay short and current. + +**`help/` is in-product documentation and follows the same rule**: those +Markdown files are the built-in help pages users read *inside* Pablan +(imported into `documents` on every start, `is_builtin`, not editable in +the UI). Any change to a user-facing workflow, screen or concept updates +the affected help page in the same change — an outdated help page is a bug +like a stale `docs/` page, except users see this one. `docs/` explains the +system to developers; `help/` explains the product to its users, in German +(product content, like templates). + +## Stack (fixed decisions — do not substitute) + +- **Backend**: Python 3.12+, FastAPI (async), managed with `uv`. Lint+format: `ruff`. +- **DB**: PostgreSQL + pgvector. ORM: SQLAlchemy 2.0 (async, typed). Migrations: Alembic. + No dedicated vector DB, no Prisma, no Redis. +- **Frontend**: SvelteKit (Svelte 5 runes) + TypeScript + Tailwind. Prettier + ESLint. + Headless behavior via **Bits UI**; we write our own styled components on top. + No shadcn imports, no component library dependencies beyond Bits UI. +- **LLM**: OpenAI-compatible endpoints only (llama.cpp server locally, cloud APIs in prod). + No LangChain / LlamaIndex — thin custom client in `backend/app/llm/client.py`. +- **Deployment**: Docker Compose (postgres, backend, frontend, reverse proxy). + Reverse proxy config in `deploy/` (Caddy, the only supported proxy for now). + +## Repo layout + +- `backend/` — FastAPI app (`app/models`, `app/auth`, `app/api`, `app/modes`, + `app/authoring`, `app/llm`, `app/rag`, `app/ingestion`) +- `frontend/` — SvelteKit, pure UI / API client. No DB access, no auth logic beyond + cookie passthrough in `hooks.server.ts`. +- `templates/` — built-in authoring templates: Markdown skeletons (YAML). + Product content, not code. +- `ee/` — proprietary Enterprise modules. **Core code must NEVER import from ee/.** + ee registers itself via `app/ee_hooks.py` and the mode/frontend registries. +- `docs/` — English design documentation, kept in sync with code by Claude. +- License: FSL-1.1 for core, separate proprietary license in `ee/LICENSE`. + +## Commands + +- `make dev` — full dev stack with hot reload (postgres in docker, backend + + frontend native) +- `make down` — stop the dev stack +- `make migrate` — apply Alembic migrations +- `make seed` — seed dev data +- `make types` — regenerate `frontend/src/lib/api/schema.d.ts` from OpenAPI. + Run after ANY backend API change. +- `make lint` — ruff + prettier + eslint + design-token contrast check + (CI runs the same) +- `make eval` — LLM eval suite in `backend/tests/evals` against the configured endpoint +- `make e2e` — Playwright end-to-end tests against the dev stack + +## Architecture rules (invariants — enforce in every change) + +1. **Markdown is the source of truth.** Documents live as Markdown in Postgres. + Chunks/embeddings are disposable derivatives; any pipeline change must allow + full re-indexing from documents. +2. **Permissions filter BEFORE the LLM.** All retrieval goes through + `rag/retrieval.search(query, user=...)` — there is no search without a user. + Never pass chunks to a prompt that the requesting user could not read. +3. **All LLM traffic goes through `llm/client.py`** (`chat_stream`, `chat_json`, + `embed`) with model roles `chat` / `utility` / `embedding`, each independently + configurable (base_url, api_key, model). Never call an LLM HTTP API elsewhere. + Its failure vocabulary lives in `llm/errors.py`: `LLMError.code` classifies + every endpoint failure once, and the frontend phrases it. +4. **Structured outputs use `chat_json`** with a Pydantic schema passed as + `response_format` (JSON schema). Never parse free-form LLM text into data. +5. **Modes implement the `Mode` protocol** (`modes/base.py`) yielding `ModeEvent`s; + the conversations router converts events to SSE. Modes know no HTTP; routers + know no mode logic. New modes register in `modes/registry.py`. Query (RAG Q&A) + is the only core mode; EE adds insight. Capture is NOT a mode — see rule 6. +6. **Capture is writing-first, not a conversation.** The user authors a + `Document` directly (Markdown is the source of truth, rule 1); it starts in + `draft` status — author-only (`rag/permissions.readable_documents_filter`) and + never indexed until published (searchable requires `published`), so a draft + never reaches another user or an LLM prompt (rule 2). Publishing is the + author's own one-click action; the three statuses (draft, published, + archived) say where a document stands, never whether its CONTENT is + trusted. That is a `ReviewRequest` — "please check this", which can hang on + a draft or on a document published months ago, grants the person asked the + right to edit until they answer, and marks the document everywhere it + appears including chat sources. Section refinement + (`POST /api/documents/{id}/refine`, package `app/authoring/`) regenerates ONLY + the section at the cursor (FIM-style: the rest of the document is prefix/suffix + context), so large documents stay cheap and small local models (Gemma-class) + stay reliable. The active-section boundary is computed server-side, shared with + `rag/chunking`. No hidden engine state — the document is the state. +7. **Prompts are rendered natural language**, never raw YAML/JSON dumps. +8. **Auth = server-side sessions** (argon2 password hashes, `auth_sessions` table, + httpOnly cookie). No JWT. Naming: chat threads are `conversations`, + login sessions are `auth_sessions` — never mix these up. +9. **Background work goes through the `jobs` table** (`ingestion/queue.py`, + `FOR UPDATE SKIP LOCKED` loop). No new queue infrastructure. +10. **Frontend renders LLM/document Markdown only through the sanitizing + renderer** (DOMPurify). Treat all model output and document content as untrusted. +11. **API contract flows one way**: FastAPI OpenAPI → `openapi-typescript` → + typed `openapi-fetch` client. Never hand-write API response types. +12. **NEVER log content** — no prompts, no LLM responses, no user messages, + no document text. Log metadata only (model role, duration, token counts, + error codes, entity IDs, correlation id). This applies to every log line, + including exceptions (no content in error messages). Content debug + logging only behind `PABLAN_DEBUG_LOG_PROMPTS=true`, documented as + never-in-production. +13. **Auth boundaries (login/logout) are full document navigations, never + client-side.** Module-level client state (the conversation list, chat + state, the resolved locale — all runes singletons) is guaranteed dead at + the session boundary because the page is reloaded. Do NOT convert these + to client navigation (`goto`/`invalidateAll`): a client nav keeps the + previous user's singletons alive and leaks their data (conversation + titles are an information disclosure). This is immune to stores added + later; a per-store reset is not. + +## Conventions + +- Code, comments, identifiers, `docs/`, README, seed data, test strings: + English. Template content and the test fixture corpus in + `tests/fixtures/`: German — product content for the German market. +- **UI copy goes through Paraglide messages** (`frontend/messages/`), + source language **de** (informal "du"), **en** written in the same + change. A hardcoded UI string is a bug; a missing `en` message and an + em/en dash in any message both fail `make lint` + (`frontend/scripts/check-messages.py`). See `docs/i18n.md` for the key + naming convention and how the locale is resolved. +- **The backend never renders UI-language strings.** API errors are + `{detail, code}` and the frontend translates by `code`; SSE `state` + events carry counts and markers, and the frontend phrases them. +- Full type hints in Python; `ruff` rules include `I` (isort) and `B` (bugbear). +- Svelte 5 runes only (`$state`, `$derived`, `$props`) — no legacy stores for new code. +- Keep the base component set small; reuse `lib/components/` primitives. + No one-off colors or spacing — see Design tokens below. +- DB primary keys: UUID. Timestamps via the shared mixin in `models/base.py`. +- `docs/diagrams/data-model.svg` is the ER diagram; any model or migration + change updates the diagram and the prose in `docs/data-model.md` in the + same change. +- Retention/privacy defaults matter (GDPR): users can delete their own + conversations; retention cleanup runs as a scheduled job; insights features + must only ever see aggregated data, never per-user raw messages. + +## Design tokens + +- All colors are defined ONCE as semantic CSS variables in `frontend/src/app.css` + and mapped into the Tailwind theme: `--color-primary`, `--color-secondary`, + `--color-accent`, plus role tokens for surfaces, text, borders, and states + (success/warning/danger), each with the shades needed for hover/muted variants. +- Current palette values live ONLY in `frontend/src/app.css` — CLAUDE.md + never names colors. The palette must be swappable by editing only the + token definitions — components reference tokens exclusively (`bg-primary`, + `text-accent`), NEVER raw hex values or Tailwind default colors. +- The accent token is for highlights, active states, and CTAs — never for + body text or large surfaces. Check WCAG AA contrast for any token pairing + (`frontend/scripts/contrast-check.py`, enforced by `make lint`); every + token needs a working value in both light and dark mode. +- Spacing, radii, and typography sizes also come from the theme scale — no + arbitrary values (`p-[13px]`) in components. + +## Testing + +- Fast unit tests colocated in `backend/tests/`; retrieval logic gets SQL-level + tests against a real Postgres (docker). +- `tests/evals/` holds the refinement/query eval set — extend it whenever prompt + or engine behavior changes, and run `make eval` with both a local model and + a cloud model before merging prompt changes. +- E2E: Playwright in `frontend/e2e/` against the dev stack (`make e2e`). + Cover the critical paths: login, chat streaming, the writing editor (section + refinement + accept), publishing, review requests, permission boundaries + (user A must not see user B's restricted docs). +- **Visual verification**: when changing UI, use the Playwright MCP server to + open the affected pages, take screenshots (light AND dark mode), and inspect + the result before considering the work done. Frontend changes are not + finished on "it compiles". + +## Dev environment notes + +- Local LLM: llama.cpp server (Gemma-class 12B/26B) via OpenAI-compatible API; + quality reference: Claude Sonnet via API. Both configured purely through `.env` + (`PABLAN_CHAT_*`, `PABLAN_UTILITY_*`, `PABLAN_EMBEDDING_*`). +- Embeddings: multilingual model (e.g. bge-m3) — German retrieval quality is a + first-class requirement; hybrid search (pgvector + Postgres `german` full-text + with RRF) is the default, not an option. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dec5c30 --- /dev/null +++ b/LICENSE @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, ALv2 Future License + +## Abbreviation + +FSL-1.1-ALv2 + +## Notice + +Copyright 2026 Pablan + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cecb9ae --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +COMPOSE_DEV := docker compose -f docker-compose.dev.yml + +.DEFAULT_GOAL := help + +.PHONY: help dev dev-backend dev-frontend down migrate seed types lint eval e2e + +help: ## list available targets + @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-10s %s\n", $$1, $$2}' + +dev: ## full dev stack with hot reload (postgres in docker, backend + frontend native) + @for port in 8000 5173; do \ + if ss -ltn "sport = :$$port" 2>/dev/null | tail -n +2 | grep -q .; then \ + echo "ERROR: port $$port is already in use — stale 'uvicorn app.main:app' or 'vite dev'?"; \ + ss -ltnp "sport = :$$port" 2>/dev/null | tail -n +2; \ + echo "Kill the shown process and retry."; \ + exit 1; \ + fi; \ + done + $(COMPOSE_DEV) up -d --wait + $(MAKE) -j2 dev-backend dev-frontend + +dev-backend: + cd backend && uv run uvicorn app.main:app --reload --port 8000 + +dev-frontend: + cd frontend && npm run dev + +down: ## stop the dev stack + $(COMPOSE_DEV) down + +migrate: ## apply alembic migrations + cd backend && uv run alembic upgrade head + +seed: ## seed dev data + cd backend && uv run python -m app.seed + +types: ## regenerate frontend/src/lib/api/schema.d.ts from the FastAPI OpenAPI schema + cd backend && uv run python -c "import json, sys; from app.main import app; json.dump(app.openapi(), sys.stdout)" > ../frontend/openapi.json + cd frontend && npx openapi-typescript openapi.json -o src/lib/api/schema.d.ts + rm frontend/openapi.json + +lint: ## ruff + prettier + eslint + design-token contrast check (CI runs the same) + cd backend && uv run ruff check . && uv run ruff format --check . + @# Recompiling Paraglide rewrites every module under src/lib/paraglide/. A + @# running `make dev` is watching exactly those files and ends up serving a + @# stale graph (500s until vite restarts) -- and it compiles them through + @# its own plugin anyway, so skipping is not a gap. + @if ss -ltn 'sport = :5173' 2>/dev/null | tail -n +2 | grep -q .; then \ + echo "dev server on :5173 -- keeping its compiled messages, checking files only"; \ + cd frontend && npm run lint:files; \ + else \ + cd frontend && npm run lint; \ + fi + python3 frontend/scripts/contrast-check.py + python3 frontend/scripts/check-messages.py + python3 backend/scripts/check-no-ui-strings.py + +eval: ## LLM eval suite against the configured endpoints + cd backend && uv run pytest tests/evals -m eval -s + +e2e: ## playwright end-to-end tests against the dev stack + cd frontend && npm run test:e2e diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e583a4 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Pablan + +Self-hosted knowledge management for SMEs. Employees capture knowledge by +writing Markdown directly into a document, with an LLM editor that matures the +section at the cursor as they write (onboarding, offboarding, project debriefs +and more, started from templates); the results are stored as Markdown documents +and questions about the knowledge base are answered via RAG. Everything runs on +your own infrastructure. The interface ships in German and English. + +The problem it solves: knowledge leaves the company when employees do, and +departments solve problems in isolation. Pablan makes writing tacit knowledge +down quick and makes it searchable, with permission filtering **before** the +LLM sees anything, drafts that stay private until their author publishes them, +and GDPR-friendly retention defaults. + +## Stack + +- **Backend** — Python 3.12, FastAPI (async), SQLAlchemy 2.0 + Alembic, managed with [uv](https://docs.astral.sh/uv/) +- **Database** — PostgreSQL + pgvector: relational data, documents, embeddings, full-text (hybrid search), jobs and sessions in one DB +- **Frontend** — SvelteKit (Svelte 5) + TypeScript + Tailwind, own components on Bits UI +- **LLM** — any OpenAI-compatible endpoint (llama.cpp locally, cloud APIs in prod); three independently configured model roles: chat / utility / embedding +- **Deployment** — Docker Compose: postgres, backend, frontend, Caddy + +## Development + +Prerequisites: Docker, uv, Node 22+. + +```sh +cp .env.example .env # set DB password and LLM endpoints +make dev # postgres in docker, backend + frontend native with hot reload +make migrate # apply DB migrations +make seed # seed dev data +``` + +App: · 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). diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1e7033d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +.venv +__pycache__ +*.pyc +tests +Dockerfile diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..87fdb5b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy + +# Install dependencies first so source changes don't bust this layer. +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY . . +RUN uv sync --frozen --no-dev + +EXPOSE 8000 +CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4d4f1c2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /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 diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..36b5d7a --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,69 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.config import get_settings +from app.models import Base + +config = context.config + +if config.config_file_name is not None: + # Never disable already-created application loggers (e.g. when tests or + # tooling run migrations programmatically after app modules are imported). + fileConfig(config.config_file_name, disable_existing_loggers=False) + +target_metadata = Base.metadata + +# URL priority: alembic.ini / programmatic override (tests) → app settings. +if not config.get_main_option("sqlalchemy.url"): + config.set_main_option( + "sqlalchemy.url", get_settings().database_url.replace("%", "%%") + ) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode: emit SQL without a DB connection.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/15bc390bcabb_message_meta.py b/backend/alembic/versions/15bc390bcabb_message_meta.py new file mode 100644 index 0000000..fb3d35e --- /dev/null +++ b/backend/alembic/versions/15bc390bcabb_message_meta.py @@ -0,0 +1,38 @@ +"""message meta + +Revision ID: 15bc390bcabb +Revises: 563ae5ac1d0d +Create Date: 2026-07-20 09:05:10.023514 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "15bc390bcabb" +down_revision: Union[str, Sequence[str], None] = "563ae5ac1d0d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "messages", + sa.Column( + "meta", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("messages", "meta") diff --git a/backend/alembic/versions/50f054decf55_dismissed_hints.py b/backend/alembic/versions/50f054decf55_dismissed_hints.py new file mode 100644 index 0000000..9d657fd --- /dev/null +++ b/backend/alembic/versions/50f054decf55_dismissed_hints.py @@ -0,0 +1,38 @@ +"""dismissed hints + +Revision ID: 50f054decf55 +Revises: 72ef34f36387 +Create Date: 2026-07-20 13:14:22.851339 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "50f054decf55" +down_revision: Union[str, Sequence[str], None] = "72ef34f36387" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "users", + sa.Column( + "dismissed_hints", + postgresql.JSONB(astext_type=sa.Text()), + server_default="[]", + nullable=False, + ), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("users", "dismissed_hints") diff --git a/backend/alembic/versions/563ae5ac1d0d_initial_schema.py b/backend/alembic/versions/563ae5ac1d0d_initial_schema.py new file mode 100644 index 0000000..1ff6b16 --- /dev/null +++ b/backend/alembic/versions/563ae5ac1d0d_initial_schema.py @@ -0,0 +1,405 @@ +"""initial schema + +Revision ID: 563ae5ac1d0d +Revises: +Create Date: 2026-07-18 14:41:47.620604 + +""" + +from typing import Sequence, Union + +import pgvector.sqlalchemy +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "563ae5ac1d0d" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "departments", + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "jobs", + sa.Column("type", sa.String(length=100), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "status", + sa.Enum( + "pending", + "running", + "done", + "failed", + name="jobstatus", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column( + "run_after", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("attempts", sa.Integer(), nullable=False), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_jobs_status_run_after", "jobs", ["status", "run_after"], unique=False + ) + op.create_table( + "templates", + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("version", sa.String(length=20), nullable=False), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("is_builtin", sa.Boolean(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "users", + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column( + "role", + sa.Enum("member", "admin", name="userrole", native_enum=False, length=32), + nullable=False, + ), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("department_id", sa.Uuid(), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["department_id"], ["departments.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "auth_sessions", + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"], unique=False + ) + op.create_table( + "documents", + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column( + "status", + sa.Enum( + "draft", + "published", + "archived", + name="documentstatus", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column( + "visibility", + sa.Enum( + "public", + "department", + "restricted", + name="documentvisibility", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column("content_md", sa.Text(), nullable=False), + sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("author_id", sa.Uuid(), nullable=True), + sa.Column("department_id", sa.Uuid(), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["author_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["department_id"], ["departments.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "chunks", + sa.Column("document_id", sa.Uuid(), nullable=False), + sa.Column("chunk_index", sa.Integer(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column( + "embedding", pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=False + ), + sa.Column( + "tsv", + postgresql.TSVECTOR(), + sa.Computed( + "to_tsvector('german'::regconfig, " + "content || ' ' || coalesce(meta->>'heading_path', ''))", + persisted=True, + ), + nullable=True, + ), + sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("document_id", "chunk_index"), + ) + op.create_index( + op.f("ix_chunks_document_id"), "chunks", ["document_id"], unique=False + ) + op.create_index( + "ix_chunks_embedding_hnsw", + "chunks", + ["embedding"], + unique=False, + postgresql_using="hnsw", + postgresql_ops={"embedding": "vector_cosine_ops"}, + ) + op.create_index( + "ix_chunks_tsv", "chunks", ["tsv"], unique=False, postgresql_using="gin" + ) + op.create_table( + "conversations", + sa.Column( + "mode", + sa.Enum( + "query", + "insight", + name="conversationmode", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column( + "status", + sa.Enum( + "active", + "completed", + "abandoned", + name="conversationstatus", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_conversations_user_id"), "conversations", ["user_id"], unique=False + ) + op.create_table( + "doc_permissions", + sa.Column("document_id", sa.Uuid(), nullable=False), + sa.Column("department_id", sa.Uuid(), nullable=False), + sa.Column( + "level", + sa.Enum("read", name="permissionlevel", native_enum=False, length=32), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["department_id"], ["departments.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("document_id", "department_id"), + ) + op.create_table( + "messages", + sa.Column("conversation_id", sa.Uuid(), nullable=False), + sa.Column( + "role", + sa.Enum( + "user", + "assistant", + "system", + name="messagerole", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["conversation_id"], ["conversations.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_messages_conversation_id"), + "messages", + ["conversation_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_messages_conversation_id"), table_name="messages") + op.drop_table("messages") + op.drop_table("doc_permissions") + op.drop_index(op.f("ix_conversations_user_id"), table_name="conversations") + op.drop_table("conversations") + op.drop_index("ix_chunks_tsv", table_name="chunks", postgresql_using="gin") + op.drop_index( + "ix_chunks_embedding_hnsw", + table_name="chunks", + postgresql_using="hnsw", + postgresql_ops={"embedding": "vector_cosine_ops"}, + ) + op.drop_index(op.f("ix_chunks_document_id"), table_name="chunks") + op.drop_table("chunks") + op.drop_table("documents") + op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions") + op.drop_table("auth_sessions") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + op.drop_table("templates") + op.drop_index("ix_jobs_status_run_after", table_name="jobs") + op.drop_table("jobs") + op.drop_table("departments") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/717591478a2a_builtin_help_documents.py b/backend/alembic/versions/717591478a2a_builtin_help_documents.py new file mode 100644 index 0000000..e12a099 --- /dev/null +++ b/backend/alembic/versions/717591478a2a_builtin_help_documents.py @@ -0,0 +1,32 @@ +"""builtin help documents + +Revision ID: 717591478a2a +Revises: 15bc390bcabb +Create Date: 2026-07-20 11:28:26.085209 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "717591478a2a" +down_revision: Union[str, Sequence[str], None] = "15bc390bcabb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "documents", + sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("documents", "is_builtin") diff --git a/backend/alembic/versions/72ef34f36387_llm_settings.py b/backend/alembic/versions/72ef34f36387_llm_settings.py new file mode 100644 index 0000000..f6d009a --- /dev/null +++ b/backend/alembic/versions/72ef34f36387_llm_settings.py @@ -0,0 +1,50 @@ +"""llm settings + +Revision ID: 72ef34f36387 +Revises: 717591478a2a +Create Date: 2026-07-20 12:58:00.212399 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "72ef34f36387" +down_revision: Union[str, Sequence[str], None] = "717591478a2a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "llm_settings", + sa.Column("role", sa.String(length=32), nullable=False), + sa.Column("base_url", sa.String(length=500), nullable=True), + sa.Column("model", sa.String(length=200), nullable=True), + sa.Column("api_key", sa.Text(), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("role"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("llm_settings") diff --git a/backend/alembic/versions/8c31d0a4e7b2_user_locale_drop_hints.py b/backend/alembic/versions/8c31d0a4e7b2_user_locale_drop_hints.py new file mode 100644 index 0000000..c43d0db --- /dev/null +++ b/backend/alembic/versions/8c31d0a4e7b2_user_locale_drop_hints.py @@ -0,0 +1,45 @@ +"""user locale, drop dismissed hints + +Revision ID: 8c31d0a4e7b2 +Revises: 50f054decf55 +Create Date: 2026-07-20 16:02:10.114872 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8c31d0a4e7b2" +down_revision: Union[str, Sequence[str], None] = "50f054decf55" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # The first-contact hints were removed: a dismissable callout that has to + # be explained ("what hints?") is not guidance. Guidance that matters is + # now static text at the place it belongs. + op.drop_column("users", "dismissed_hints") + # NULL = follow the browser's Accept-Language; a value pins the interface + # language for this person on every device. + op.add_column("users", sa.Column("locale", sa.String(length=5), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("users", "locale") + op.add_column( + "users", + sa.Column( + "dismissed_hints", + postgresql.JSONB(astext_type=sa.Text()), + server_default="[]", + nullable=False, + ), + ) diff --git a/backend/alembic/versions/9f4a71c60d38_templates_are_never_builtin.py b/backend/alembic/versions/9f4a71c60d38_templates_are_never_builtin.py new file mode 100644 index 0000000..9d366cd --- /dev/null +++ b/backend/alembic/versions/9f4a71c60d38_templates_are_never_builtin.py @@ -0,0 +1,42 @@ +"""templates are never builtin + +Revision ID: 9f4a71c60d38 +Revises: 8c31d0a4e7b2 +Create Date: 2026-07-20 16:41:03.529117 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9f4a71c60d38" +down_revision: Union[str, Sequence[str], None] = "8c31d0a4e7b2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # templates/ became a catalog of blueprints that an admin adds from, + # rather than content re-imported over the customer's rows on every + # start. Nothing in this table is read-only any more, so the flag that + # marked it has no meaning left. (documents.is_builtin is untouched — + # the help pages really are the product's own.) + op.drop_column("templates", "is_builtin") + + +def downgrade() -> None: + """Downgrade schema.""" + op.add_column( + "templates", + sa.Column( + "is_builtin", + sa.Boolean(), + server_default=sa.false(), + nullable=False, + ), + ) diff --git a/backend/alembic/versions/a71e3c92fd45_llm_settings_provenance.py b/backend/alembic/versions/a71e3c92fd45_llm_settings_provenance.py new file mode 100644 index 0000000..45bbb0c --- /dev/null +++ b/backend/alembic/versions/a71e3c92fd45_llm_settings_provenance.py @@ -0,0 +1,52 @@ +"""llm settings field provenance + +Revision ID: a71e3c92fd45 +Revises: 9f4a71c60d38 +Create Date: 2026-07-20 18:07:44.902113 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a71e3c92fd45" +down_revision: Union[str, Sequence[str], None] = "9f4a71c60d38" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_FIELDS = ("base_url", "model", "api_key") + + +def upgrade() -> None: + """Upgrade schema.""" + # The table changed meaning: it used to hold sparse per-field overrides + # on top of the environment, and now holds the full configuration, + # seeded from the environment once at first start. + for field in _FIELDS: + op.add_column( + "llm_settings", + sa.Column( + f"{field}_from_env", + sa.Boolean(), + server_default=sa.false(), + nullable=False, + ), + ) + + # The table changed meaning: it used to hold sparse per-field overrides + # on top of the environment, and now holds the full configuration, + # seeded from the environment at startup. Pre-existing rows are dev + # leftovers — nothing is in production yet, so they are dropped rather + # than translated, and `bootstrap_llm_settings()` recreates them from + # `.env` on the next start. + op.execute("DELETE FROM llm_settings") + + +def downgrade() -> None: + """Downgrade schema.""" + for field in _FIELDS: + op.drop_column("llm_settings", f"{field}_from_env") diff --git a/backend/alembic/versions/b8e14d7c05a3_review_requests.py b/backend/alembic/versions/b8e14d7c05a3_review_requests.py new file mode 100644 index 0000000..c295fd9 --- /dev/null +++ b/backend/alembic/versions/b8e14d7c05a3_review_requests.py @@ -0,0 +1,87 @@ +"""Review requests: an open question about a document, not a status + +Publishing becomes the author's own action, so `pending_approval` disappears +and the delegated-approver column with it. What replaces both is a request +that can sit on a draft OR on a published document: "please check this", with +the question attached. + +Revision ID: b8e14d7c05a3 +Revises: f3c8d5a92b47 +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "b8e14d7c05a3" +down_revision: Union[str, Sequence[str], None] = "f3c8d5a92b47" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "review_requests", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("document_id", sa.Uuid(), nullable=False), + sa.Column("requester_id", sa.Uuid(), nullable=True), + sa.Column("reviewer_id", sa.Uuid(), nullable=True), + sa.Column("question", sa.Text(), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_by_id", sa.Uuid(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requester_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["resolved_by_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_review_requests_document_id", "review_requests", ["document_id"] + ) + op.create_index( + "ix_review_requests_reviewer_id", "review_requests", ["reviewer_id"] + ) + + # A document waiting for approval is simply an unpublished draft now; the + # ask that used to be implied by the status is an explicit request. + op.execute( + "UPDATE documents SET status = 'draft' WHERE status = 'pending_approval'" + ) + op.drop_column("documents", "reviewer_id") + + # The audit trail keeps its shape: approving WAS publishing, and + # submitting has no counterpart in a world where the author publishes. + op.execute( + "UPDATE document_events SET action = 'published' WHERE action = 'approved'" + ) + op.execute("DELETE FROM document_events WHERE action = 'submitted'") + + +def downgrade() -> None: + op.add_column("documents", sa.Column("reviewer_id", sa.Uuid(), nullable=True)) + op.create_foreign_key( + "documents_reviewer_id_fkey", + "documents", + "users", + ["reviewer_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_index("ix_review_requests_reviewer_id", table_name="review_requests") + op.drop_index("ix_review_requests_document_id", table_name="review_requests") + op.drop_table("review_requests") diff --git a/backend/alembic/versions/c4f7a1b2e9d3_document_reviewer.py b/backend/alembic/versions/c4f7a1b2e9d3_document_reviewer.py new file mode 100644 index 0000000..61b5cee --- /dev/null +++ b/backend/alembic/versions/c4f7a1b2e9d3_document_reviewer.py @@ -0,0 +1,42 @@ +"""document reviewer + +An author can delegate approval: `documents.reviewer_id` names the user asked +to review a pending document. Nullable (self-approval leaves it null), SET NULL +so a document survives the reviewer leaving. + +Revision ID: c4f7a1b2e9d3 +Revises: a71e3c92fd45 +Create Date: 2026-07-22 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c4f7a1b2e9d3" +down_revision: Union[str, Sequence[str], None] = "a71e3c92fd45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column("documents", sa.Column("reviewer_id", sa.Uuid(), nullable=True)) + op.create_foreign_key( + "documents_reviewer_id_fkey", + "documents", + "users", + ["reviewer_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint("documents_reviewer_id_fkey", "documents", type_="foreignkey") + op.drop_column("documents", "reviewer_id") diff --git a/backend/alembic/versions/d5a9c1e3b7f2_document_events.py b/backend/alembic/versions/d5a9c1e3b7f2_document_events.py new file mode 100644 index 0000000..9e114d6 --- /dev/null +++ b/backend/alembic/versions/d5a9c1e3b7f2_document_events.py @@ -0,0 +1,94 @@ +"""document events audit trail + +An append-only history of who changed or reviewed a document, and when. +Content-bearing events (created / edited) snapshot the Markdown source of truth +so a past version can be viewed or diffed; the disposable chunks are never +snapshotted. `actor_id` is SET NULL so the trail outlives its actor's account; +events cascade with their document. + +Revision ID: d5a9c1e3b7f2 +Revises: c4f7a1b2e9d3 +Create Date: 2026-07-22 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d5a9c1e3b7f2" +down_revision: Union[str, Sequence[str], None] = "c4f7a1b2e9d3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "document_events", + sa.Column("document_id", sa.Uuid(), nullable=False), + sa.Column("actor_id", sa.Uuid(), nullable=True), + sa.Column( + "action", + sa.Enum( + "created", + "edited", + "published", + "archived", + "visibility_changed", + "review_requested", + "review_resolved", + name="documenteventaction", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.Column("content_md", sa.Text(), nullable=True), + sa.Column("title", sa.String(length=500), nullable=True), + sa.Column( + "visibility", + sa.Enum( + "public", + "department", + "restricted", + name="documentvisibility", + native_enum=False, + length=32, + ), + nullable=True, + ), + sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["actor_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_document_events_document_id"), + "document_events", + ["document_id"], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_document_events_document_id"), table_name="document_events") + op.drop_table("document_events") diff --git a/backend/alembic/versions/f3c8d5a92b47_prompt_settings.py b/backend/alembic/versions/f3c8d5a92b47_prompt_settings.py new file mode 100644 index 0000000..1e29ed1 --- /dev/null +++ b/backend/alembic/versions/f3c8d5a92b47_prompt_settings.py @@ -0,0 +1,51 @@ +"""prompt settings + +Admin overrides for shipped system prompts. A row exists only when an admin has +changed a prompt from its code default; `content` is the full replacement text. + +Revision ID: f3c8d5a92b47 +Revises: e7b2c4a1f6d9 +Create Date: 2026-07-22 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f3c8d5a92b47" +down_revision: Union[str, Sequence[str], None] = "d5a9c1e3b7f2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "prompt_settings", + sa.Column("key", sa.String(length=64), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("prompt_settings") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..6b7131b --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter + +from app.api import ( + account, + admin, + auth, + authoring, + conversations, + departments, + documents, + people, + templates, +) + +api_router = APIRouter(prefix="/api") +api_router.include_router(auth.router) +api_router.include_router(account.router) +api_router.include_router(admin.router) +api_router.include_router(conversations.router) +api_router.include_router(departments.router) +api_router.include_router(documents.router) +api_router.include_router(authoring.router) +api_router.include_router(people.router) +api_router.include_router(templates.router) + + +@api_router.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/app/api/account.py b/backend/app/api/account.py new file mode 100644 index 0000000..85116cc --- /dev/null +++ b/backend/app/api/account.py @@ -0,0 +1,136 @@ +"""Self-service account actions. + +Separate from `auth.py` (login/logout/me) and from `admin.py`: this is what +a user may change about themselves. +""" + +import logging +import uuid +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import get_current_auth_session, get_current_user +from app.auth.passwords import hash_password, verify_password +from app.auth.sessions import revoke_user_sessions +from app.db import get_db +from app.errors import ApiError +from app.models import AuthSession, Document, DocumentStatus, Template, User + +router = APIRouter(prefix="/account", tags=["account"]) +logger = logging.getLogger("pablan.account") + + +class LocalePreference(BaseModel): + """The languages the interface ships in — the frontend bundles must + cover exactly these.""" + + # null = follow the browser's Accept-Language again. + locale: Literal["de", "en"] | None = None + + +class PasswordChange(BaseModel): + current_password: str = Field(min_length=1, max_length=200) + new_password: str = Field(min_length=8, max_length=200) + + +@router.post("/password", status_code=204) +async def change_password( + body: PasswordChange, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AuthSession, Depends(get_current_auth_session)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Change your own password, proving the current one first. + + Every other session of this user is revoked — a + password change is how someone reacts to a suspected compromise, so + other devices must lose access. The session doing the change survives, + otherwise the user is thrown out of the app they are standing in. + """ + if not verify_password(user.password_hash, body.current_password): + raise ApiError( + 403, "Current password is incorrect.", "invalid_current_password" + ) + + user.password_hash = hash_password(body.new_password) + await revoke_user_sessions(db, user.id, keep_session_id=session.id) + await db.commit() + # Metadata only — never the password, not even its length. + logger.info("password changed", extra={"event": "password_change"}) + + +@router.put("/locale", status_code=204) +async def set_locale( + body: LocalePreference, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Pin the interface language, or clear it to follow the browser again. + + The backend only stores the choice — it never renders UI-language + strings (see docs/architecture.md); the frontend does the translating. + """ + user.locale = body.locale + await db.commit() + + +# The starter catalog's blueprint about a PERSON (role, specialities, who to +# ask) rather than a topic. What "the document about you" is made from, named +# once here so the frontend does not have to know a blueprint id. +PERSONAL_BLUEPRINT = "person" + + +class PersonalDocument(BaseModel): + """The caller's own document about themselves. + + Either they wrote one — then it is opened and edited like any other + document — or they have not, and `template_id` says what to start it from. + Both are null when the blueprint is not in this instance and nothing was + written yet; the frontend falls back to the ordinary template picker. + """ + + document_id: uuid.UUID | None = None + title: str | None = None + status: DocumentStatus | None = None + template_id: uuid.UUID | None = None + + +@router.get("/document") +async def personal_document( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> PersonalDocument: + """What the profile page needs to show: your document about yourself, or + the way to start it. Self-scoped, and authorship is the whole rule — a + document someone else wrote about you is not this.""" + document = ( + await db.execute( + select(Document) + .where( + Document.author_id == user.id, + Document.meta["template"].astext == PERSONAL_BLUEPRINT, + ) + # The newest, if a second one was ever started. + .order_by(Document.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + template_id = ( + await db.execute( + select(Template.id).where( + Template.config["id"].astext == PERSONAL_BLUEPRINT + ) + ) + ).scalar_one_or_none() + if document is None: + return PersonalDocument(template_id=template_id) + return PersonalDocument( + document_id=document.id, + title=document.title, + status=document.status, + template_id=template_id, + ) diff --git a/backend/app/api/admin/__init__.py b/backend/app/api/admin/__init__.py new file mode 100644 index 0000000..4e7f66a --- /dev/null +++ b/backend/app/api/admin/__init__.py @@ -0,0 +1,18 @@ +"""The admin API: everything only an administrator may do. + +Split by what is being administered — model endpoints, prompts, users, +departments, and the metrics snapshot. The admin gate is not repeated per +endpoint: it sits on the shared router in `routing.py`, so a new route in any +of these modules is admin-only whether or not its author thought about it. +""" + +from fastapi import APIRouter + +from app.api.admin import departments, llm, observability, prompts, users + +router = APIRouter() +router.include_router(llm.router) +router.include_router(prompts.router) +router.include_router(users.router) +router.include_router(departments.router) +router.include_router(observability.router) diff --git a/backend/app/api/admin/departments.py b/backend/app/api/admin/departments.py new file mode 100644 index 0000000..9595efb --- /dev/null +++ b/backend/app/api/admin/departments.py @@ -0,0 +1,108 @@ +"""Departments. + +Deliberately unpaged: an SME has a handful of them. The interesting rule is +deletion — a department's read grants CASCADE away with it, and that access +loss is invisible, so it has to be confirmed rather than discovered later. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.admin.routing import admin_router +from app.db import get_db +from app.errors import ApiError +from app.models import Department, DocPermission, Document, User + +router = admin_router() + +NAME_TAKEN = ("Department name already exists.", "name_taken") + + +class DepartmentCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + + +class AdminDepartmentOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + + +@router.post("/departments") +async def create_department( + body: DepartmentCreate, + db: Annotated[AsyncSession, Depends(get_db)], +) -> AdminDepartmentOut: + department = Department(name=body.name.strip()) + db.add(department) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise ApiError(409, *NAME_TAKEN) from None + return AdminDepartmentOut.model_validate(department) + + +@router.patch("/departments/{department_id}") +async def rename_department( + department_id: uuid.UUID, + body: DepartmentCreate, + db: Annotated[AsyncSession, Depends(get_db)], +) -> AdminDepartmentOut: + department = await db.get(Department, department_id) + if department is None: + raise ApiError(404, "Department not found.", "not_found") + department.name = body.name.strip() + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise ApiError(409, *NAME_TAKEN) from None + return AdminDepartmentOut.model_validate(department) + + +async def _still_in_use(db: AsyncSession, department_id: uuid.UUID) -> bool: + """Members, owned documents or read grants — anything whose access changes + when this department disappears.""" + for count in ( + select(func.count(User.id)).where(User.department_id == department_id), + select(func.count(Document.id)).where(Document.department_id == department_id), + select(func.count()) + .select_from(DocPermission) + .where(DocPermission.department_id == department_id), + ): + if (await db.execute(count)).scalar_one(): + return True + return False + + +@router.delete("/departments/{department_id}", status_code=204) +async def delete_department( + department_id: uuid.UUID, + db: Annotated[AsyncSession, Depends(get_db)], + confirm: bool = False, +) -> None: + """Delete a department. Members and owned documents survive with their + `department_id` set to NULL, but this department's `doc_permissions` grants + CASCADE away — silently dropping the shared read access they gave. Because + that access loss is invisible, deleting a department that still has members, + owned documents or grants requires `?confirm=true` (409 `department_in_use` + otherwise).""" + department = await db.get(Department, department_id) + if department is None: + raise ApiError(404, "Department not found.", "not_found") + if not confirm and await _still_in_use(db, department_id): + raise ApiError( + 409, + "This department is still in use; deleting it drops that access.", + "department_in_use", + ) + await db.delete(department) + await db.commit() diff --git a/backend/app/api/admin/llm.py b/backend/app/api/admin/llm.py new file mode 100644 index 0000000..3274fee --- /dev/null +++ b/backend/app/api/admin/llm.py @@ -0,0 +1,265 @@ +"""Model endpoints: test them, configure them, ask what they serve. + +Configuration is bootstrapped from `.env` at first start and lives in the DB +afterwards, so this is where an admin changes an endpoint without a restart. +Every call runs server-side: the API key must never reach the browser, and the +browser must never reach the model endpoint. +""" + +import asyncio +import time +from typing import Annotated + +from fastapi import Depends +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.admin.routing import admin_router +from app.db import get_db +from app.llm.client import ( + Role, + chat_stream, + embed, + list_models, + probe, + rebuild_clients, + role_config, +) +from app.llm.errors import LLMError +from app.llm.overrides import env_defaults, load_config +from app.models import LLMSetting + +router = admin_router() + +ROLES: tuple[Role, ...] = ("chat", "utility", "embedding") + + +class LLMSettingUpdate(BaseModel): + # None means "leave as is"; the reset_* flags restore the value the + # environment currently states (same sentinel pattern as + # UserUpdate.clear_department). + base_url: str | None = Field(default=None, max_length=500) + model: str | None = Field(default=None, max_length=200) + api_key: str | None = Field(default=None, max_length=500) + reset_base_url: bool | None = None + reset_model: bool | None = None + reset_api_key: bool | None = None + + +class LLMRoleStatus(BaseModel): + role: Role + ok: bool + base_url: str + model: str + latency_ms: int | None + # Why it failed: `code` is phrased by the frontend, `error` is the + # sanitized technical detail (exception class + role) for the admin. + code: str | None + error: str | None + + +class LLMTestResponse(BaseModel): + roles: list[LLMRoleStatus] + + +class LLMTestRequest(BaseModel): + """Optional candidate config: test an endpoint BEFORE saving it.""" + + role: Role | None = None + base_url: str | None = None + model: str | None = None + api_key: str | None = None + + +async def _ping_role( + role: Role, candidate: LLMSettingUpdate | None = None +) -> LLMRoleStatus: + """Ping a role — either its effective config, or a candidate an admin is + about to save.""" + base_url, _, model = role_config(role) + if candidate is not None: + base_url = candidate.base_url or base_url + model = candidate.model or model + + started = time.monotonic() + try: + if candidate is None: + if role == "embedding": + await embed(["ping"]) + else: + # Drain the (max_tokens=1) stream so the call records as + # "ok", not "aborted". + async for _ in chat_stream( + [{"role": "user", "content": "ping"}], role=role, max_tokens=1 + ): + pass + else: + await probe( + role, + base_url=candidate.base_url, + api_key=candidate.api_key, + model=candidate.model, + ) + return LLMRoleStatus( + role=role, + ok=True, + base_url=base_url, + model=model, + latency_ms=round((time.monotonic() - started) * 1000), + code=None, + error=None, + ) + except LLMError as exc: + return LLMRoleStatus( + role=role, + ok=False, + base_url=base_url, + model=model, + latency_ms=None, + code=exc.code, + error=str(exc), + ) + + +@router.post("/llm/test") +async def llm_test(body: LLMTestRequest | None = None) -> LLMTestResponse: + """First-line support tool: pings all three roles, or one candidate + configuration without persisting anything.""" + if body is not None and body.role is not None: + candidate = LLMSettingUpdate( + base_url=body.base_url, model=body.model, api_key=body.api_key + ) + return LLMTestResponse(roles=[await _ping_role(body.role, candidate)]) + results = await asyncio.gather(*(_ping_role(role) for role in ROLES)) + return LLMTestResponse(roles=list(results)) + + +class LLMSettingOut(BaseModel): + """Stored config for one role. + + The api_key is NEVER returned — only whether one is set, and where each + field's value came from. `*_from_env` drives the per-field + "taken from .env" / "changed here" label and the reset action; it is + provenance, not a fallback. + """ + + role: Role + base_url: str + model: str + base_url_from_env: bool + model_from_env: bool + api_key_set: bool + api_key_from_env: bool + + +async def _row_for(db: AsyncSession, role: Role) -> LLMSetting: + row = ( + await db.execute(select(LLMSetting).where(LLMSetting.role == role)) + ).scalar_one_or_none() + if row is None: + # Only reachable if bootstrap never ran (a test, or a role added + # after install). Seed it from the environment, same as bootstrap. + defaults = env_defaults(role) + row = LLMSetting( + role=role, + base_url=defaults.base_url or None, + model=defaults.model or None, + api_key=defaults.api_key or None, + ) + db.add(row) + await db.flush() + return row + + +def _setting_out(row: LLMSetting) -> LLMSettingOut: + return LLMSettingOut( + role=row.role, # type: ignore[arg-type] + base_url=row.base_url or "", + model=row.model or "", + base_url_from_env=row.base_url_from_env, + model_from_env=row.model_from_env, + api_key_set=bool(row.api_key), + api_key_from_env=row.api_key_from_env, + ) + + +@router.get("/llm/settings") +async def llm_settings( + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[LLMSettingOut]: + return [_setting_out(await _row_for(db, role)) for role in ROLES] + + +@router.put("/llm/settings/{role}") +async def update_llm_setting( + role: Role, + body: LLMSettingUpdate, + db: Annotated[AsyncSession, Depends(get_db)], +) -> LLMSettingOut: + """Change a role's stored config and apply it without a restart. + + Writing a field marks it as changed here; resetting it writes back what + `.env` currently says and marks it as coming from the environment + again. + """ + row = await _row_for(db, role) + defaults = env_defaults(role) + + if body.reset_base_url: + row.base_url, row.base_url_from_env = defaults.base_url or None, True + elif body.base_url is not None: + row.base_url, row.base_url_from_env = body.base_url or None, False + if body.reset_model: + row.model, row.model_from_env = defaults.model or None, True + elif body.model is not None: + row.model, row.model_from_env = body.model or None, False + if body.reset_api_key: + row.api_key, row.api_key_from_env = defaults.api_key or None, True + elif body.api_key: + row.api_key, row.api_key_from_env = body.api_key, False + + await db.commit() + await load_config(db) + # The cached OpenAI clients hold the old base_url and key. + rebuild_clients() + return _setting_out(row) + + +class LLMModelsRequest(BaseModel): + """Optional candidate endpoint, so an admin can list the models of a + URL they have typed but not saved.""" + + base_url: str | None = Field(default=None, max_length=500) + api_key: str | None = Field(default=None, max_length=500) + + +class LLMModelsResponse(BaseModel): + models: list[str] + # False when the endpoint does not implement GET /v1/models — the UI + # keeps its free-text field instead of showing an error. + supported: bool + error: str | None = None + + +@router.post("/llm/models/{role}") +async def llm_models( + role: Role, body: LLMModelsRequest | None = None +) -> LLMModelsResponse: + """List what the endpoint serves, so the model field can be a dropdown.""" + try: + models = await list_models( + role, + base_url=(body.base_url if body else None) or None, + api_key=(body.api_key if body else None) or None, + ) + except LLMError as exc: + # A 404 means "this server has no /v1/models", which is common + # enough to be a normal outcome rather than a failure to report. + supported = exc.status_code != 404 + return LLMModelsResponse( + models=[], + supported=supported, + error=exc.cause_type if supported else None, + ) + return LLMModelsResponse(models=models, supported=True) diff --git a/backend/app/api/admin/observability.py b/backend/app/api/admin/observability.py new file mode 100644 index 0000000..dc7255e --- /dev/null +++ b/backend/app/api/admin/observability.py @@ -0,0 +1,19 @@ +"""What the running process has counted so far.""" + +from typing import Any + +from app.api.admin.routing import admin_router +from app.metrics import metrics + +router = admin_router() + + +@router.get("/metrics") +async def metrics_snapshot() -> dict[str, Any]: + """The in-process metrics registry as JSON. + + Per process by design (the app runs one worker), and admin-only: the + counters name models and durations, which is operational detail rather + than something to expose publicly. + """ + return metrics.snapshot() diff --git a/backend/app/api/admin/prompts.py b/backend/app/api/admin/prompts.py new file mode 100644 index 0000000..0aefa34 --- /dev/null +++ b/backend/app/api/admin/prompts.py @@ -0,0 +1,87 @@ +"""Editing the shipped system prompts. + +Every prompt has a code default; a row exists only where an admin changed one, +and resetting deletes that row rather than storing a copy of the default. The +UI labels the keys from its own messages, so nothing user-facing is worded here. +""" + +from typing import Annotated + +from fastapi import Depends +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.admin.routing import admin_router +from app.db import get_db +from app.errors import ApiError +from app.models import PromptSetting +from app.prompts.defaults import DEFAULTS, PROMPT_KEYS +from app.prompts.overrides import get_prompt, is_overridden +from app.prompts.overrides import load_config as load_prompt_config + +router = admin_router() + + +class PromptSettingOut(BaseModel): + key: str + # The effective text: an admin override if present, else the code default. + content: str + # True when no override exists, i.e. the shipped default is in force. + is_default: bool + + +class PromptSettingUpdate(BaseModel): + # New override text, or `reset` to drop the override back to the default. + content: str | None = None + reset: bool | None = None + + +@router.get("/prompts") +async def prompt_settings( + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[PromptSettingOut]: + """Every editable system prompt with its effective text and whether it is + still the shipped default.""" + rows = {row.key: row for row in (await db.execute(select(PromptSetting))).scalars()} + return [ + PromptSettingOut( + key=key, + content=rows[key].content if key in rows else DEFAULTS[key], + is_default=key not in rows, + ) + for key in PROMPT_KEYS + ] + + +@router.put("/prompts/{key}") +async def update_prompt_setting( + key: str, + body: PromptSettingUpdate, + db: Annotated[AsyncSession, Depends(get_db)], +) -> PromptSettingOut: + """Override a system prompt (applied without a restart) or reset it to the + shipped default. Resetting deletes the override row.""" + if key not in DEFAULTS: + raise ApiError(404, "Unknown prompt.", "not_found") + row = ( + await db.execute(select(PromptSetting).where(PromptSetting.key == key)) + ).scalar_one_or_none() + + if body.reset: + if row is not None: + await db.delete(row) + elif body.content is not None: + content = body.content.strip() + if not content: + raise ApiError(422, "A prompt cannot be empty.", "empty_prompt") + if row is None: + db.add(PromptSetting(key=key, content=content)) + else: + row.content = content + + await db.commit() + await load_prompt_config(db) + return PromptSettingOut( + key=key, content=get_prompt(key), is_default=not is_overridden(key) + ) diff --git a/backend/app/api/admin/routing.py b/backend/app/api/admin/routing.py new file mode 100644 index 0000000..439fa8b --- /dev/null +++ b/backend/app/api/admin/routing.py @@ -0,0 +1,16 @@ +"""The one router constructor the admin modules share. + +The admin gate lives here rather than on each endpoint: a route added to any +of these modules is admin-only by construction, and there is no way to forget +the dependency. +""" + +from fastapi import APIRouter, Depends + +from app.auth.deps import require_admin + + +def admin_router() -> APIRouter: + return APIRouter( + prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)] + ) diff --git a/backend/app/api/admin/users.py b/backend/app/api/admin/users.py new file mode 100644 index 0000000..6bcc2cc --- /dev/null +++ b/backend/app/api/admin/users.py @@ -0,0 +1,196 @@ +"""User accounts. + +An admin creates people, corrects their details, and offboards them. The two +guardrails here exist because an admin who locks themselves out has no second +admin to call: you cannot change your own role, and you cannot delete yourself. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.admin.routing import admin_router +from app.auth.deps import get_current_user +from app.auth.passwords import hash_password +from app.auth.sessions import revoke_user_sessions +from app.db import get_db +from app.errors import ApiError +from app.models import Department, User, UserRole + +router = admin_router() + +EMAIL_TAKEN = ("Email address already in use.", "email_taken") + + +class AdminUserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + name: str + role: UserRole + department_id: uuid.UUID | None + + +class AdminUserPage(BaseModel): + items: list[AdminUserOut] + total: int + per_page: int + + +class UserCreate(BaseModel): + email: str = Field(min_length=3, max_length=320) + name: str = Field(min_length=1, max_length=200) + role: UserRole = UserRole.member + department_id: uuid.UUID | None = None + password: str = Field(min_length=8, max_length=200) + + +class UserUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + # Correcting a typo in an address should not mean deleting the person + # and losing everything hanging off their id. Plain `str` like + # UserCreate: `EmailStr` would pull in email-validator, a dependency we + # deliberately avoid, and the format is already guarded by the browser's + # type="email" and the column's unique constraint. + email: str | None = Field(default=None, min_length=3, max_length=320) + role: UserRole | None = None + department_id: uuid.UUID | None = None + clear_department: bool | None = None + # Setting a password IS the reset mechanism. + password: str | None = Field(default=None, min_length=8, max_length=200) + + +def _normalize_email(email: str) -> str: + """The same normalisation on create and update, or an edited address would + stop matching what login looks up.""" + return email.strip().lower() + + +async def _department_must_exist( + db: AsyncSession, department_id: uuid.UUID | None +) -> None: + if department_id is not None and await db.get(Department, department_id) is None: + raise ApiError(404, "Department not found.", "not_found") + + +@router.get("/users") +async def list_users( + db: Annotated[AsyncSession, Depends(get_db)], + search: str | None = Query(None, max_length=200), + page: int = Query(1, ge=1), + per_page: int = Query(25, ge=1, le=100), +) -> AdminUserPage: + """Paged, because the admin screen is the one place that scales with + headcount: a company with two hundred employees would otherwise get two + hundred rows and no way to find anyone. + + Departments deliberately stay unpaged: an SME has a handful, and a + pager over five rows is furniture. + """ + filters = [] + if search: + needle = f"%{search.strip()}%" + filters.append(or_(User.email.ilike(needle), User.name.ilike(needle))) + + total = (await db.execute(select(func.count(User.id)).where(*filters))).scalar_one() + rows = ( + ( + await db.execute( + select(User) + .where(*filters) + .order_by(User.email) + .offset((page - 1) * per_page) + .limit(per_page) + ) + ) + .scalars() + .all() + ) + return AdminUserPage( + items=[AdminUserOut.model_validate(row) for row in rows], + total=total, + per_page=per_page, + ) + + +@router.post("/users") +async def create_user( + body: UserCreate, + db: Annotated[AsyncSession, Depends(get_db)], +) -> AdminUserOut: + await _department_must_exist(db, body.department_id) + user = User( + email=_normalize_email(body.email), + name=body.name, + role=body.role, + department_id=body.department_id, + password_hash=hash_password(body.password), + ) + db.add(user) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise ApiError(409, *EMAIL_TAKEN) from None + return AdminUserOut.model_validate(user) + + +@router.patch("/users/{user_id}") +async def update_user( + user_id: uuid.UUID, + body: UserUpdate, + admin: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> AdminUserOut: + user = await db.get(User, user_id) + if user is None: + raise ApiError(404, "User not found.", "not_found") + if user.id == admin.id and body.role is not None and body.role != user.role: + raise ApiError(409, "You cannot change your own role.", "self_modification") + + if body.name is not None: + user.name = body.name + if body.email is not None: + user.email = _normalize_email(body.email) + if body.role is not None: + user.role = body.role + if body.clear_department: + user.department_id = None + elif body.department_id is not None: + await _department_must_exist(db, body.department_id) + user.department_id = body.department_id + if body.password is not None: + user.password_hash = hash_password(body.password) + # A password change revokes every session of that user — including + # the current one if an admin changes their own password. + await revoke_user_sessions(db, user.id) + + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise ApiError(409, *EMAIL_TAKEN) from None + return AdminUserOut.model_validate(user) + + +@router.delete("/users/{user_id}", status_code=204) +async def delete_user( + user_id: uuid.UUID, + admin: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Offboarding: sessions and conversations cascade, documents survive + with author set to NULL.""" + if user_id == admin.id: + raise ApiError(409, "You cannot delete your own account.", "self_modification") + user = await db.get(User, user_id) + if user is None: + raise ApiError(404, "User not found.", "not_found") + await db.delete(user) + await db.commit() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..4f8342e --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,87 @@ +import uuid +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import get_current_user +from app.auth.passwords import burn_verification_time, verify_password +from app.auth.sessions import ( + COOKIE_NAME, + clear_session_cookie, + create_auth_session, + set_session_cookie, +) +from app.db import get_db +from app.errors import ApiError +from app.models import AuthSession, User, UserRole + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + email: str + password: str + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + name: str + role: UserRole + department_id: uuid.UUID | None + # Pinned interface language, or null to follow the browser. Flows to the + # UI via /me, so no page needs its own preference fetch. Typed as the + # closed set the API accepts, so the generated client is precise too. + locale: Literal["de", "en"] | None = None + + +@router.post("/login") +async def login( + body: LoginRequest, + response: Response, + db: Annotated[AsyncSession, Depends(get_db)], +) -> UserOut: + email = body.email.strip().lower() + user = ( + await db.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if user is None: + burn_verification_time() + raise ApiError(401, "Invalid email or password.", "invalid_credentials") + if not verify_password(user.password_hash, body.password): + raise ApiError(401, "Invalid email or password.", "invalid_credentials") + + session = await create_auth_session(db, user) + await db.commit() + set_session_cookie(response, session) + return UserOut.model_validate(user) + + +@router.post("/logout", status_code=204) +async def logout( + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + raw = request.cookies.get(COOKIE_NAME) + if raw is not None: + try: + session_id = uuid.UUID(raw) + except ValueError: + session_id = None + if session_id is not None: + session = await db.get(AuthSession, session_id) + if session is not None: + await db.delete(session) + await db.commit() + clear_session_cookie(response) + + +@router.get("/me") +async def me(user: Annotated[User, Depends(get_current_user)]) -> UserOut: + return UserOut.model_validate(user) diff --git a/backend/app/api/authoring/__init__.py b/backend/app/api/authoring/__init__.py new file mode 100644 index 0000000..618e968 --- /dev/null +++ b/backend/app/api/authoring/__init__.py @@ -0,0 +1,18 @@ +"""The authoring API: the model's help while someone writes. + +Three endpoints under `/documents`, all owner-scoped: refine the section at the +cursor (streamed), suggest a title for a finished draft, and suggest which +existing document a capture should extend. What they share is `grounding` — the +permission-filtered look at what the company already wrote. + +`suggest-similar` has a static path and is registered before the refine module +so it cannot be read as a document id. +""" + +from fastapi import APIRouter + +from app.api.authoring import refine, suggest + +router = APIRouter() +router.include_router(suggest.router) +router.include_router(refine.router) diff --git a/backend/app/api/authoring/grounding.py b/backend/app/api/authoring/grounding.py new file mode 100644 index 0000000..0d4ea6e --- /dev/null +++ b/backend/app/api/authoring/grounding.py @@ -0,0 +1,74 @@ +"""What the company already wrote about this. + +Before refining a section, the server looks for related published material the +author may read and hands it to the prompt as a reference — so a suggestion +stays consistent with the rest of the knowledge base instead of inventing a +parallel version of it. The same chunks are surfaced to the editor's "?" +inspector, so the author can see where a suggestion drew from. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.llm.errors import LLMError +from app.models import User +from app.rag.chunking import HEADING_RE +from app.rag.similarity import ( + CAPTURE_CONTEXT_MAX_DISTANCE, + SimilarChunk, + similar_chunks, +) + +# A section needs at least this much of its own text (beyond the heading) +# before it is worth searching the knowledge base to ground the refinement: +# a bare heading matches nothing useful and only adds noise. +MIN_CHARS = 40 +TOP_K = 3 +# Cap each grounding excerpt so a few long ones cannot crowd out the section. +EXCERPT_CHARS = 600 + + +def leading_heading(section_text: str) -> str | None: + """The heading text a section starts with, to match a template hint.""" + first = section_text.lstrip().splitlines()[0] if section_text.strip() else "" + match = HEADING_RE.match(first) + return match.group(2).strip() if match else None + + +def reference(title: str, heading_path: str, content: str) -> str: + """One retrieved chunk, rendered for the prompt: where it comes from, then + a bounded excerpt.""" + excerpt = content.strip() + if len(excerpt) > EXCERPT_CHARS: + excerpt = excerpt[:EXCERPT_CHARS].rstrip() + " ..." + where = f'"{title}" ({heading_path})' if heading_path else f'"{title}"' + return f"From {where}:\n{excerpt}" + + +async def for_section( + db: AsyncSession, section_text: str, user: User, document_id: uuid.UUID +) -> list[SimilarChunk]: + """Related knowledge for one section, permission-filtered by construction. + + Returns nothing when the section is still too thin to match on, when the + current document is the only match, or when the embedding endpoint is + unavailable — the refinement then simply proceeds without grounding. + """ + query = section_text.strip() + _, _, after_heading = query.partition("\n") + body = after_heading.strip() if leading_heading(query) is not None else query + if len(body) < MIN_CHARS: + return [] + try: + return await similar_chunks( + db, + query, + user=user, + top_k=TOP_K, + max_distance=CAPTURE_CONTEXT_MAX_DISTANCE, + exclude_builtin=True, + exclude_document_id=document_id, + ) + except LLMError: + return [] diff --git a/backend/app/api/authoring/refine.py b/backend/app/api/authoring/refine.py new file mode 100644 index 0000000..98128f8 --- /dev/null +++ b/backend/app/api/authoring/refine.py @@ -0,0 +1,173 @@ +"""Section refinement for the writing editor. + +The user writes Markdown; after a pause the client asks the model to refine the +section the cursor is in. The whole document is context, but the model +regenerates ONLY that section (FIM-style), streamed back as SSE so the +suggestion appears progressively and can be aborted the moment the user resumes +typing. + +The request body and the streamed response carry document text. That is fine on +this owner-scoped endpoint — the same trust boundary as +`GET /api/documents/{id}` — but nothing here logs content: metadata only. +""" + +import asyncio +import logging +import time +import uuid +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import Depends +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.authoring import grounding +from app.api.authoring.routing import authoring_router +from app.api.documents import readable_document, require_editor +from app.api.sse import sse +from app.auth.deps import get_current_user +from app.authoring.prompts import render_refine_prompt +from app.authoring.schema import AuthoringTemplate +from app.authoring.sections import ActiveSection, active_section, slice_lines +from app.db import get_db +from app.llm.client import NO_THINKING, chat_stream +from app.llm.errors import LLMError +from app.models import Template, User + +router = authoring_router() +logger = logging.getLogger("pablan.authoring") + + +class RefineRequest(BaseModel): + content_md: str = Field(max_length=100_000) + cursor_line: int = Field(ge=1) + + +async def _template_for( + db: AsyncSession, template_config_id: str | None +) -> AuthoringTemplate | None: + """The blueprint a document was started from, if it still exists and still + parses — it carries the persona, the temperature and the per-section hints + that shape a refinement.""" + if not template_config_id: + return None + row = ( + await db.execute( + select(Template).where(Template.config["id"].astext == template_config_id) + ) + ).scalar_one_or_none() + if row is None: + return None + try: + return AuthoringTemplate.model_validate(row.config) + except ValidationError: + return None + + +@router.post("/{document_id}/refine") +async def refine_section( + document_id: uuid.UUID, + body: RefineRequest, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> StreamingResponse: + """Stream a matured version of the section at the cursor. + + SSE frames: one `section` frame with the exact line range the suggestion + replaces, then `token` frames, then `done` (or `error`).""" + document = await readable_document(db, document_id, user) + require_editor(document, user) + + section = active_section(body.content_md, body.cursor_line) + prefix, section_text, suffix = slice_lines( + body.content_md, section.start_line, section.end_line + ) + meta = document.meta or {} + + persona: str | None = None + hint: str | None = None + temperature = 0.4 + template = await _template_for(db, meta.get("template")) + if template is not None: + persona = template.persona + temperature = template.model.temperature + heading = grounding.leading_heading(section_text) + if heading: + hint = template.hint_for(heading) + + # Related, already-published knowledge the author may read. Rendered into + # the prompt as grounding, AND surfaced to the editor's "?" inspector so the + # author can see where a suggestion drew from. + chunks = await grounding.for_section(db, section_text, user, document.id) + messages = render_refine_prompt( + section_text, + prefix=prefix, + suffix=suffix, + persona=persona, + hint=hint, + # Background from the chat this capture came from, if any. Like the + # document text, it travels only on this owner-scoped call and is + # never logged. + context=meta.get("context"), + knowledge=[ + grounding.reference(chunk.title, chunk.heading_path, chunk.content) + for chunk in chunks + ], + ) + references = [ + {"title": chunk.title, "heading_path": chunk.heading_path} for chunk in chunks + ] + + return StreamingResponse( + _stream_refine(messages, section, temperature, str(document.id), references), + media_type="text/event-stream", + headers={"cache-control": "no-cache", "x-accel-buffering": "no"}, + ) + + +async def _stream_refine( + messages: list[dict[str, str]], + section: ActiveSection, + temperature: float, + document_id: str, + references: list[dict[str, str]], +) -> AsyncIterator[str]: + started = time.monotonic() + outcome = "ok" + token_events = 0 + # First: which lines "Accept" will overwrite, so the client can bind the + # suggestion to an exact range even as the model streams. + yield sse( + "section", {"start_line": section.start_line, "end_line": section.end_line} + ) + # What the suggestion is grounding on (the author's own readable material) — + # titles + heading paths only, for the "?" inspector. Content-safe. + if references: + yield sse("grounding", {"references": references}) + try: + async for token in chat_stream( + messages, role="chat", temperature=temperature, extra_body=NO_THINKING + ): + token_events += 1 + yield sse("token", {"text": token}) + yield sse("done", {}) + except LLMError as exc: + outcome = "error" + yield sse("error", {"code": exc.code}) + except (asyncio.CancelledError, GeneratorExit): + outcome = "aborted" + raise + finally: + logger.info( + "refine finished", + extra={ + "event": "refine", + "outcome": outcome, + "duration_ms": round((time.monotonic() - started) * 1000), + "token_events": token_events, + "document_id": document_id, + }, + ) diff --git a/backend/app/api/authoring/routing.py b/backend/app/api/authoring/routing.py new file mode 100644 index 0000000..86fe211 --- /dev/null +++ b/backend/app/api/authoring/routing.py @@ -0,0 +1,12 @@ +"""The one router constructor the authoring modules share. + +The prefix is `/documents`: authoring acts ON a document the caller owns, so +its endpoints live under the document they belong to rather than in a +namespace of their own. +""" + +from fastapi import APIRouter + + +def authoring_router() -> APIRouter: + return APIRouter(prefix="/documents", tags=["authoring"]) diff --git a/backend/app/api/authoring/suggest.py b/backend/app/api/authoring/suggest.py new file mode 100644 index 0000000..512bf84 --- /dev/null +++ b/backend/app/api/authoring/suggest.py @@ -0,0 +1,101 @@ +"""Two small suggestions the editor asks for: a title, and what to extend. + +Both are one-shot calls rather than streams, and both are advisory: the author +keeps the generic title or starts a new document if the suggestion does not fit. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.authoring.routing import authoring_router +from app.api.documents import readable_document, require_editor +from app.auth.deps import get_current_user +from app.authoring.context import summarize_conversation +from app.authoring.prompts import render_title_prompt +from app.db import get_db +from app.errors import ApiError +from app.llm.client import NO_THINKING, chat_json +from app.llm.errors import LLMError +from app.models import Conversation, User +from app.rag.similarity import CAPTURE_CONTEXT_MAX_DISTANCE, similar_documents + +router = authoring_router() + + +class TitleSuggestion(BaseModel): + title: str + + +@router.post("/{document_id}/suggest-title") +async def suggest_title( + document_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> TitleSuggestion: + """Suggest a concise title from the document's content (review step for a + new document). Owner-scoped; content in, title out, nothing logged.""" + document = await readable_document(db, document_id, user) + require_editor(document, user) + if not document.content_md.strip(): + return TitleSuggestion(title=document.title) + try: + return await chat_json( + render_title_prompt(document.content_md), + TitleSuggestion, + extra_body=NO_THINKING, + ) + except LLMError as exc: + raise ApiError(503, "The model endpoint did not answer.", exc.code) from None + + +class SuggestSimilarRequest(BaseModel): + conversation_id: uuid.UUID + + +class SimilarDocumentOut(BaseModel): + document_id: uuid.UUID + title: str + + +@router.post("/suggest-similar") +async def suggest_similar( + body: SuggestSimilarRequest, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[SimilarDocumentOut]: + """Existing documents that match the conversation a capture is starting + from — found over an LLM TOPIC SUMMARY of the chat (not the raw last + message), permission-filtered, help pages excluded. Empty list when the + conversation is unknown, empty, or nothing is close enough.""" + conversation = ( + await db.execute( + select(Conversation) + .where( + Conversation.id == body.conversation_id, + Conversation.user_id == user.id, + ) + .options(selectinload(Conversation.messages)) + ) + ).scalar_one_or_none() + if conversation is None: + return [] + topic = await summarize_conversation(conversation) + if not topic: + return [] + matches = await similar_documents( + db, + topic, + user=user, + max_distance=CAPTURE_CONTEXT_MAX_DISTANCE, + exclude_builtin=True, + ) + return [ + SimilarDocumentOut(document_id=match.document_id, title=match.title) + for match in matches + ] diff --git a/backend/app/api/conversations/__init__.py b/backend/app/api/conversations/__init__.py new file mode 100644 index 0000000..6f65771 --- /dev/null +++ b/backend/app/api/conversations/__init__.py @@ -0,0 +1,19 @@ +"""The conversations API: the chat itself. + +Two halves. `crud` manages conversations as objects a user owns; `turns` is the +one place that speaks SSE, turning a mode's events into frames and persisting +what was streamed. The ownership gate sits in `access`, the wire shapes in +`schemas`, and the row-to-shape mapping in `view`. +""" + +from fastapi import APIRouter + +from app.api.conversations import crud, turns +from app.api.conversations.turns import stream_turn + +router = APIRouter() +router.include_router(crud.router) +router.include_router(turns.router) + +# Exported for the tests that drive a turn without going through HTTP. +__all__ = ["router", "stream_turn"] diff --git a/backend/app/api/conversations/access.py b/backend/app/api/conversations/access.py new file mode 100644 index 0000000..b270c0f --- /dev/null +++ b/backend/app/api/conversations/access.py @@ -0,0 +1,35 @@ +"""Whose conversation this is. + +A conversation is private to the user who started it — there is no sharing and +no admin view. One gate, used by every endpoint in the package, so the rule +cannot quietly differ between reading and writing. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ApiError +from app.models import Conversation, User + + +async def own_conversation( + db: AsyncSession, + conversation_id: uuid.UUID, + user: User, + *, + with_messages: bool = False, +) -> Conversation: + stmt = select(Conversation).where( + Conversation.id == conversation_id, Conversation.user_id == user.id + ) + if with_messages: + stmt = stmt.options(selectinload(Conversation.messages)) + conversation = (await db.execute(stmt)).scalar_one_or_none() + if conversation is None: + # 404 rather than 403: someone else's conversation must not be + # confirmed to exist. + raise ApiError(404, "Conversation not found.", "not_found") + return conversation diff --git a/backend/app/api/conversations/crud.py b/backend/app/api/conversations/crud.py new file mode 100644 index 0000000..76c43a1 --- /dev/null +++ b/backend/app/api/conversations/crud.py @@ -0,0 +1,101 @@ +"""Starting, listing, reading and deleting conversations. + +Everything here is owner-scoped. Deleting is a GDPR surface, not a convenience: +a user must be able to remove their own transcripts, and the messages go with +them by cascade. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.conversations.access import own_conversation +from app.api.conversations.routing import conversations_router +from app.api.conversations.schemas import ( + ConversationCreate, + ConversationDetail, + ConversationSummary, +) +from app.api.conversations.view import message_out, title +from app.auth.deps import get_current_user +from app.db import get_db +from app.errors import ApiError +from app.models import Conversation, Message, User +from app.modes import get_mode + +router = conversations_router() + + +@router.post("") +async def create_conversation( + body: ConversationCreate, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ConversationSummary: + if get_mode(body.mode.value) is None: + raise ApiError( + 400, f"Mode '{body.mode.value}' is not available.", "unknown_mode" + ) + conversation = Conversation(mode=body.mode, user_id=user.id) + db.add(conversation) + await db.commit() + return ConversationSummary.model_validate(conversation) + + +@router.get("") +async def list_conversations( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[ConversationSummary]: + """The sidebar list, newest activity first. The title is the first message, + fetched as a correlated subquery so one statement answers the whole list.""" + first_message = ( + select(Message.content) + .where(Message.conversation_id == Conversation.id) + .order_by(Message.created_at) + .limit(1) + .correlate(Conversation) + .scalar_subquery() + ) + rows = await db.execute( + select(Conversation, first_message) + .where(Conversation.user_id == user.id) + .order_by(Conversation.updated_at.desc()) + ) + return [ + ConversationSummary.model_validate(conversation).model_copy( + update={"title": title(first)} + ) + for conversation, first in rows + ] + + +@router.get("/{conversation_id}") +async def get_conversation( + conversation_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ConversationDetail: + conversation = await own_conversation(db, conversation_id, user, with_messages=True) + first = conversation.messages[0].content if conversation.messages else None + return ConversationDetail.model_validate(conversation).model_copy( + update={ + "title": title(first), + "messages": [message_out(message) for message in conversation.messages], + } + ) + + +@router.delete("/{conversation_id}", status_code=204) +async def delete_conversation( + conversation_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """GDPR: users delete their own conversations; messages cascade.""" + conversation = await own_conversation(db, conversation_id, user) + await db.delete(conversation) + await db.commit() diff --git a/backend/app/api/conversations/routing.py b/backend/app/api/conversations/routing.py new file mode 100644 index 0000000..4da697f --- /dev/null +++ b/backend/app/api/conversations/routing.py @@ -0,0 +1,7 @@ +"""The one router constructor the conversations modules share.""" + +from fastapi import APIRouter + + +def conversations_router() -> APIRouter: + return APIRouter(prefix="/conversations", tags=["conversations"]) diff --git a/backend/app/api/conversations/schemas.py b/backend/app/api/conversations/schemas.py new file mode 100644 index 0000000..185ebad --- /dev/null +++ b/backend/app/api/conversations/schemas.py @@ -0,0 +1,55 @@ +"""Request and response shapes for conversations and their turns.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.models import ConversationMode, MessageRole + + +class ConversationCreate(BaseModel): + mode: ConversationMode + + +class ConversationSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + title: str | None = None + + +class MessageSource(BaseModel): + document_id: uuid.UUID + title: str + heading_path: str + excerpt: str = "" + # True when the passage was passed to the model; False for passages that + # were retrieved but dropped as too weak (a no-answer turn). Old messages + # predate the flag, so it defaults to True (they were all cited). + used: bool = True + # The cited document has an unanswered request to check it. Snapshotted + # with the citation, so a reload shows what was true when it was answered. + review_pending: bool = False + + +class MessageOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + role: MessageRole + content: str + created_at: datetime + sources: list[MessageSource] = [] + # Set when no model answered this turn and `sources` is a plain full-text + # result list instead: the `llm_*` code that caused it, which the frontend + # phrases. Null on every normal turn. + fallback: str | None = None + + +class ConversationDetail(ConversationSummary): + messages: list[MessageOut] = [] + + +class SendMessage(BaseModel): + content: str = Field(min_length=1, max_length=8000) diff --git a/backend/app/api/conversations/turns.py b/backend/app/api/conversations/turns.py new file mode 100644 index 0000000..132325d --- /dev/null +++ b/backend/app/api/conversations/turns.py @@ -0,0 +1,226 @@ +"""One turn: a question in, an answer streamed out, both persisted. + +This is the only place that knows about SSE. A mode yields `ModeEvent`s and +knows nothing about HTTP; here they become frames on the wire. Persistence is +deliberately asymmetric: the user message is committed BEFORE streaming starts +so it survives anything the endpoint does, while the assistant message is +written at the end — complete, partial after an abort, or source-list-only when +no model could answer. +""" + +import asyncio +import logging +import time +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Annotated, Any + +from fastapi import Depends +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.api.conversations.access import own_conversation +from app.api.conversations.routing import conversations_router +from app.api.conversations.schemas import SendMessage +from app.api.sse import sse +from app.auth.deps import get_current_user +from app.db import get_db +from app.errors import ApiError +from app.llm.errors import LLMError +from app.log import conversation_id as conversation_id_var +from app.models import Conversation, Message, MessageRole, User +from app.modes import get_mode +from app.modes.base import Degraded, Done, Error, Mode, Sources, StateChanged, Token + +router = conversations_router() +logger = logging.getLogger("pablan.conversations") + + +@router.post("/{conversation_id}/messages") +async def send_message( + conversation_id: uuid.UUID, + body: SendMessage, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> StreamingResponse: + conversation = await own_conversation(db, conversation_id, user, with_messages=True) + mode = get_mode(conversation.mode.value) + if mode is None: + raise ApiError( + 400, f"Mode '{conversation.mode.value}' is not available.", "unknown_mode" + ) + + # The user message is committed before streaming starts — it survives + # whatever happens to the LLM call. + db.add( + Message( + conversation_id=conversation.id, + role=MessageRole.user, + content=body.content, + ) + ) + conversation.updated_at = datetime.now(UTC) + await db.commit() + + return StreamingResponse( + stream_turn(conversation, body.content, mode, db), + media_type="text/event-stream", + headers={"cache-control": "no-cache", "x-accel-buffering": "no"}, + ) + + +def _source_payload(chunks: Any) -> list[dict[str, Any]]: + """The wire shape of a citation — the same dicts are snapshotted onto the + message, so a reload shows exactly what was streamed.""" + return [ + { + "document_id": str(chunk.document_id), + "title": chunk.title, + "heading_path": chunk.heading_path, + "excerpt": chunk.excerpt, + "used": chunk.used, + "review_pending": chunk.review_pending, + } + for chunk in chunks + ] + + +async def stream_turn( + conversation: Conversation, content: str, mode: Mode, db: AsyncSession +) -> AsyncIterator[str]: + """Convert ModeEvents to SSE frames; persist the assistant reply — + complete on normal end, partial on client abort or endpoint failure.""" + context_token = conversation_id_var.set(str(conversation.id)) + started = time.monotonic() + parts: list[str] = [] + sources: list[dict[str, Any]] = [] + # Set when the mode gave up on the model: the turn still has a reply (the + # retrieved documents), so it is persisted and replayed like any other. + fallback: str | None = None + outcome = "ok" + try: + try: + async for event in mode.handle_turn(conversation, content, db): + match event: + case Token(text=text): + parts.append(text) + yield sse("token", {"text": text}) + case Sources(chunks=chunks): + sources = _source_payload(chunks) + yield sse("sources", {"chunks": sources}) + case StateChanged(phase=phase, count=count): + yield sse("state", {"phase": phase, "count": count}) + case Error(code=code): + outcome = "error" + yield sse("error", {"code": code}) + case Degraded(code=code): + outcome = "degraded" + fallback = code + yield sse("fallback", {"code": code}) + case Done(): + pass # the router emits the final done after persisting + except LLMError as exc: + # Every endpoint failure inside a mode ends the turn the same way, + # wherever it happened. Retrieval embeds before the model is ever + # called, so an escaping error would reach the browser as a + # truncated stream ("connection lost") instead of the reason. + outcome = "error" + logger.warning( + "turn failed", + extra={ + "event": "turn_error", + "mode": mode.name, + "code": exc.code, + "cause_type": exc.cause_type, + "status_code": exc.status_code, + }, + ) + yield sse("error", {"code": exc.code}) + except (asyncio.CancelledError, GeneratorExit): + # Client aborted (stop button): keep what was already streamed. + outcome = "aborted" + if parts: + await asyncio.shield( + _persist_partial(db.bind, conversation.id, "".join(parts), sources) + ) + raise + # Whatever arrived before the end is the reply, complete or not — for a + # fallback turn that is the source list alone. + if parts or fallback: + message_id = await _persist_assistant( + db, conversation, "".join(parts), sources, fallback=fallback + ) + yield sse("done", {"message_id": str(message_id)}) + finally: + conversation_id_var.reset(context_token) + logger.info( + "turn finished", + extra={ + "event": "turn", + "mode": mode.name, + "outcome": outcome, + "duration_ms": round((time.monotonic() - started) * 1000), + "token_events": len(parts), + }, + ) + + +def _assistant_meta( + sources: list[dict[str, Any]], fallback: str | None +) -> dict[str, Any]: + meta: dict[str, Any] = {} + if sources: + meta["sources"] = sources + if fallback: + # Why there is no generated text, kept so a reload replays the turn as + # what it was rather than as an empty reply. + meta["fallback"] = fallback + return meta + + +async def _persist_assistant( + db: AsyncSession, + conversation: Conversation, + content: str, + sources: list[dict[str, Any]], + *, + fallback: str | None = None, +) -> uuid.UUID: + message = Message( + conversation_id=conversation.id, + role=MessageRole.assistant, + content=content, + meta=_assistant_meta(sources, fallback), + ) + db.add(message) + conversation.updated_at = datetime.now(UTC) + await db.commit() + return message.id + + +async def _persist_partial( + bind: Any, + conversation_id: uuid.UUID, + content: str, + sources: list[dict[str, Any]], +) -> None: + """Write what was streamed before the client hung up. + + On a FRESH session on the same engine as the request session: the request + session is being torn down mid-cancel, so it cannot be used to commit, and + binding to the same engine keeps this working under the test overrides. + """ + async with async_sessionmaker(bind, expire_on_commit=False)() as db: + db.add( + Message( + conversation_id=conversation_id, + role=MessageRole.assistant, + content=content, + meta=_assistant_meta(sources, None), + ) + ) + conversation = await db.get(Conversation, conversation_id) + if conversation is not None: + conversation.updated_at = datetime.now(UTC) + await db.commit() diff --git a/backend/app/api/conversations/view.py b/backend/app/api/conversations/view.py new file mode 100644 index 0000000..d69573d --- /dev/null +++ b/backend/app/api/conversations/view.py @@ -0,0 +1,43 @@ +"""Rows to API shapes. + +A conversation has no title column: the first message is the title, derived +here so the list and the detail can never disagree about what a conversation +is called. +""" + +from app.api.conversations.schemas import MessageOut, MessageSource +from app.models import Message +from app.modes.query import excerpt as clean_excerpt + +TITLE_LENGTH = 80 + + +def title(first_message: str | None) -> str | None: + if not first_message: + return None + flattened = " ".join(first_message.split()) + if len(flattened) <= TITLE_LENGTH: + return flattened + return flattened[: TITLE_LENGTH - 1] + "…" + + +def message_out(message: Message) -> MessageOut: + """Message + its citation snapshot from `meta` (assistant turns only). + + The excerpt is re-cleaned on the way out, not just on the way in. It is + a presentation detail frozen at answer time, so an improvement to the + cleaning would otherwise only reach conversations created afterwards, + and every existing citation would keep showing raw Markdown forever. + Cleaning is idempotent, so text stored by a newer backend passes + through untouched. + """ + meta = message.meta or {} + sources = [ + MessageSource.model_validate(item).model_copy( + update={"excerpt": clean_excerpt(item.get("excerpt", ""))} + ) + for item in meta.get("sources", []) + ] + return MessageOut.model_validate(message).model_copy( + update={"sources": sources, "fallback": meta.get("fallback")} + ) diff --git a/backend/app/api/departments.py b/backend/app/api/departments.py new file mode 100644 index 0000000..0b9a842 --- /dev/null +++ b/backend/app/api/departments.py @@ -0,0 +1,32 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import get_current_user +from app.db import get_db +from app.models import Department, User + +router = APIRouter(prefix="/departments", tags=["departments"]) + + +class DepartmentOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + + +@router.get("") +async def list_departments( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[DepartmentOut]: + """Department names for filters and pickers — not secret, any user.""" + rows = ( + (await db.execute(select(Department).order_by(Department.name))).scalars().all() + ) + return [DepartmentOut.model_validate(row) for row in rows] diff --git a/backend/app/api/documents/__init__.py b/backend/app/api/documents/__init__.py new file mode 100644 index 0000000..ef506c6 --- /dev/null +++ b/backend/app/api/documents/__init__.py @@ -0,0 +1,27 @@ +"""The documents API. + +Split by what a caller is doing, not by HTTP verb: browsing, the life of one +document, its audit trail, the approval workflow, and department sharing. The +shared gates live in `access.py` and the shared response shapes in `view.py`, +so a rule like "an author keeps access to their own document" exists once. + +**Route order matters.** FastAPI matches in registration order, so `browse` +goes first: after `/{document_id}` exists, a request for `/search` would be +parsed as a document id. +""" + +from fastapi import APIRouter + +from app.api.documents import browse, crud, history, sharing, workflow +from app.api.documents.access import readable_document, require_editor + +router = APIRouter() +router.include_router(browse.router) +router.include_router(crud.router) +router.include_router(history.router) +router.include_router(workflow.router) +router.include_router(sharing.router) + +# The authoring API works on documents the caller may change, so it shares +# this package's gate rather than growing a second one. +__all__ = ["readable_document", "require_editor", "router"] diff --git a/backend/app/api/documents/access.py b/backend/app/api/documents/access.py new file mode 100644 index 0000000..d879261 --- /dev/null +++ b/backend/app/api/documents/access.py @@ -0,0 +1,175 @@ +"""Who may read, edit and publish a document. + +The read gate itself lives in `rag/permissions` as SQL, because there is one +place where "which documents may this user see" is decided. What lives here is +everything the HTTP layer needs around it: loading one document through that +gate, the write gates on top of it, and the Python mirror that can judge a +change BEFORE it is committed. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ApiError +from app.models import ( + AccessReason, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.permissions import readable_documents_filter + +BUILTIN_READONLY = ( + "Built-in help documents are maintained with the product.", + "builtin_readonly", +) + + +async def readable_document( + db: AsyncSession, document_id: uuid.UUID, user: User +) -> Document: + """One document, through the same filter the search uses.""" + document = ( + await db.execute( + select(Document).where( + Document.id == document_id, readable_documents_filter(user) + ) + ) + ).scalar_one_or_none() + if document is None: + # 404 for unreadable docs: existence must not leak. + raise ApiError(404, "Document not found.", "not_found") + return document + + +def is_open_reviewer(document: Document, user: User) -> bool: + """Someone asked this user to check the document and has not been answered. + + Being asked is what grants the right to change it: a reviewer who spots a + wrong number should fix it, not file a second question about it. + """ + return any(review.reviewer_id == user.id for review in document.open_reviews) + + +def can_edit(document: Document, user: User) -> bool: + """Mirror of `require_editor` — the UI must predict the gate, never guess + it.""" + if document.is_builtin: + return False + return ( + document.author_id == user.id + or user.role == UserRole.admin + or is_open_reviewer(document, user) + ) + + +def require_editor(document: Document, user: User) -> None: + if document.is_builtin: + # Help pages ship with the product and are re-imported on start; + # an edit here would silently vanish on the next deploy. + raise ApiError(409, *BUILTIN_READONLY) + if not can_edit(document, user): + raise ApiError(403, "Not allowed to modify this document.", "forbidden") + + +def require_author_or_admin(document: Document, user: User) -> None: + """Stricter than `require_editor`: for the decisions that belong to the + document's owner, like deleting it or handing out a review request.""" + if document.is_builtin: + raise ApiError(409, *BUILTIN_READONLY) + if document.author_id != user.id and user.role != UserRole.admin: + raise ApiError(403, "Not allowed to modify this document.", "forbidden") + + +def access_reason(document: Document, user: User) -> AccessReason: + """Most specific reason first: being the author explains access better + than the visibility level does. + + Mirrors `readable_documents_filter`, where the visibility rules only apply + to a PUBLISHED document — an unpublished one is visible to its author and + to whoever was asked to check it, and to nobody else. So `review` is the + reason whenever nothing more durable carries the access, which is exactly + the case where the access ends with the answer. + """ + if document.author_id == user.id: + return AccessReason.author + published = document.status == DocumentStatus.published + if published and document.visibility == DocumentVisibility.public: + return AccessReason.public + if ( + published + and document.visibility == DocumentVisibility.department + and document.department_id is not None + and document.department_id == user.department_id + ): + return AccessReason.department + if is_open_reviewer(document, user): + return AccessReason.review + # Everything else that survived the permission filter came via a grant. + return AccessReason.granted + + +def user_can_read( + user: User, + *, + author_id: uuid.UUID | None, + visibility: DocumentVisibility, + department_id: uuid.UUID | None, + granted_department_ids: set[uuid.UUID], +) -> bool: + """The Python mirror of `readable_documents_filter` for one document's + proposed state — so a change can be checked BEFORE it is committed. Admins + get no read-everything bypass (same as the filter). + + Kept next to its only caller so the two cannot drift apart unnoticed; the + SQL it mirrors is one import away. + """ + if author_id is not None and author_id == user.id: + return True + if visibility == DocumentVisibility.public: + return True + if ( + visibility == DocumentVisibility.department + and department_id is not None + and department_id == user.department_id + ): + return True + return ( + user.department_id is not None and user.department_id in granted_department_ids + ) + + +def guard_self_lockout( + user: User, + *, + author_id: uuid.UUID | None, + visibility: DocumentVisibility, + department_id: uuid.UUID | None, + granted_department_ids: set[uuid.UUID], + confirm: bool, +) -> None: + """Refuse (or, for a confirming admin, allow) a change that would remove the + editing user's own read access. An author keeps access as author, so this + only ever bites an admin editing a document they do not own.""" + if user_can_read( + user, + author_id=author_id, + visibility=visibility, + department_id=department_id, + granted_department_ids=granted_department_ids, + ): + return + if user.role != UserRole.admin: + # A non-author non-admin cannot reach this state through the API; a + # defensive block rather than a silent lockout. + raise ApiError(409, "This change would remove your own access.", "self_lockout") + if not confirm: + raise ApiError( + 409, + "You will lose access to this document after this change.", + "self_lockout_warning", + ) diff --git a/backend/app/api/documents/browse.py b/backend/app/api/documents/browse.py new file mode 100644 index 0000000..b0aed2e --- /dev/null +++ b/backend/app/api/documents/browse.py @@ -0,0 +1,293 @@ +"""Finding documents: the paged list, ranked search, the ZIP export, and the +company-wide counts. + +Every route here has a static path, so this router is included FIRST: after +`/{document_id}` is registered, "search" would be parsed as a document id. +""" + +import io +import re +import uuid +import zipfile +from typing import Annotated + +import yaml +from fastapi import Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy import exists, func, or_, select, true +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.documents.routing import documents_router +from app.api.documents.schemas import ( + DocumentPage, + DocumentSearchHit, + DocumentSort, + DocumentStats, +) +from app.api.documents.view import document_fields, summary +from app.auth.deps import get_current_user +from app.db import get_db +from app.models import ( + Department, + DocPermission, + Document, + DocumentStatus, + User, +) +from app.rag.permissions import open_review_for, readable_documents_filter + +# aliased: `search` is also a query parameter on the list endpoint +from app.rag.retrieval import search as hybrid_search + +router = documents_router() + +# Chunks retrieved before grouping, and the most documents a search returns. +SEARCH_CANDIDATES = 20 +SEARCH_LIMIT = 20 + + +@router.get("") +async def list_documents( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], + department: uuid.UUID | None = None, + status: DocumentStatus | None = None, + assigned_to_me: bool = False, + search: str | None = Query(None, max_length=200), + sort: DocumentSort = DocumentSort.updated, + page: int = Query(1, ge=1), + per_page: int = Query(30, ge=1, le=100), +) -> DocumentPage: + """Browse readable documents. + + Paginated server-side: the list is the one screen that grows without + bound as a knowledge base fills up. Search has its own endpoint and is + ranked rather than paged. + """ + filters = [readable_documents_filter(user)] + if department is not None: + # A department filter matches the owning department OR a shared grant, + # so a document shared with a department shows up under it too. + filters.append( + or_( + Document.department_id == department, + exists( + select(DocPermission.document_id).where( + DocPermission.document_id == Document.id, + DocPermission.department_id == department, + ) + ), + ) + ) + if status is not None: + filters.append(Document.status == status) + if assigned_to_me: + # "Waiting for me": documents someone asked THIS user to check. + filters.append(open_review_for(user)) + if search: + filters.append(Document.title.ilike(f"%{search}%")) + + total = ( + await db.execute(select(func.count(Document.id)).where(*filters)) + ).scalar_one() + + order = ( + Document.created_at.desc() + if sort is DocumentSort.created + else Document.updated_at.desc() + ) + documents = ( + ( + await db.execute( + select(Document) + .where(*filters) + # Built-in help is reference material and belongs after the + # team's own documents — sorted in SQL so it holds across page + # boundaries, which a client-side sort could not manage. + # `Document.id` breaks ties. Without it the order is only + # partial: the corpus is seeded in one transaction, so many + # rows share a timestamp to the microsecond, and Postgres is + # free to return them in a different order per query. Two pages + # then overlap and a document is shown twice while another is + # never reachable. + .order_by(Document.is_builtin.asc(), order, Document.id) + .offset((page - 1) * per_page) + .limit(per_page) + ) + ) + .scalars() + .all() + ) + return DocumentPage( + items=[summary(document, user) for document in documents], + total=total, + per_page=per_page, + ) + + +@router.get("/search") +async def search_documents( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], + q: Annotated[str, Query(min_length=1, max_length=200)], +) -> list[DocumentSearchHit]: + """Find documents through the same hybrid retrieval the chat uses. + + Permission-safe by construction: `search()` requires a user and applies + the shared filter. Drafts and pending documents are readable + but never indexed, so a title fallback covers them — the one asymmetry + between this endpoint and chat retrieval. + """ + results = await hybrid_search(db, q, user=user, top_k=SEARCH_CANDIDATES) + + # Group chunks per document, keeping the best-scoring chunk's heading. + best_heading: dict[uuid.UUID, str] = {} + for result in results: + best_heading.setdefault(result.document_id, result.heading_path) + + hits: list[DocumentSearchHit] = [] + if best_heading: + documents = ( + ( + await db.execute( + select(Document).where( + Document.id.in_(best_heading), + readable_documents_filter(user), + ) + ) + ) + .scalars() + .all() + ) + by_id = {document.id: document for document in documents} + # Preserve retrieval order — relevance, not insertion order. + for document_id, heading in best_heading.items(): + document = by_id.get(document_id) + if document is not None: + hits.append( + DocumentSearchHit( + **document_fields(document, user), + heading_path=heading, + ) + ) + + # Title fallback for everything retrieval cannot see. + remaining = SEARCH_LIMIT - len(hits) + if remaining > 0: + by_title = ( + ( + await db.execute( + select(Document) + .where( + readable_documents_filter(user), + Document.title.ilike(f"%{q}%"), + Document.id.notin_(best_heading) if best_heading else true(), + ) + .order_by(Document.updated_at.desc()) + .limit(remaining) + ) + ) + .scalars() + .all() + ) + hits.extend( + DocumentSearchHit(**document_fields(document, user)) + for document in by_title + ) + return hits[:SEARCH_LIMIT] + + +def _export_name(document: Document, used: set[str]) -> str: + """A stable, de-duplicated `.md` filename for a document in the export.""" + slug = (document.meta or {}).get("slug") + base = slug or re.sub(r"[^a-z0-9]+", "-", document.title.lower()).strip("-") + base = base or str(document.id) + name = f"{base}.md" + counter = 2 + while name in used: + name = f"{base}-{counter}.md" + counter += 1 + used.add(name) + return name + + +@router.get("/export") +async def export_documents( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> StreamingResponse: + """The readable knowledge base as a ZIP of Markdown files with YAML + frontmatter. Permission-filtered by construction (`readable_documents_filter` + — an admin exports what they can read, anyone else the same); built-in help + pages are excluded (product content, not the company's knowledge). stdlib + only, streamed, no temp files.""" + documents = ( + ( + await db.execute( + select(Document) + .where(readable_documents_filter(user), Document.is_builtin.is_(False)) + .order_by(Document.title) + ) + ) + .scalars() + .all() + ) + + # Resolve department names once for the frontmatter: the owning department + # plus any shared grants, so an export records the full reach of a document. + dept_names = dict((await db.execute(select(Department.id, Department.name))).all()) + shared: dict[uuid.UUID, list[str]] = {} + for doc_id, dept_id in ( + await db.execute(select(DocPermission.document_id, DocPermission.department_id)) + ).all(): + shared.setdefault(doc_id, []).append(dept_names.get(dept_id, "")) + + buffer = io.BytesIO() + used: set[str] = set() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for document in documents: + departments = [ + *( + [dept_names[document.department_id]] + if document.department_id in dept_names + else [] + ), + *sorted(shared.get(document.id, [])), + ] + frontmatter = yaml.safe_dump( + { + "title": document.title, + "status": str(document.status), + "visibility": str(document.visibility), + "departments": departments, + }, + allow_unicode=True, + sort_keys=False, + ) + body = f"---\n{frontmatter}---\n\n{document.content_md.rstrip()}\n" + archive.writestr(_export_name(document, used), body) + + buffer.seek(0) + return StreamingResponse( + iter([buffer.getvalue()]), + media_type="application/zip", + headers={"content-disposition": 'attachment; filename="pablan-export.zip"'}, + ) + + +@router.get("/stats") +async def document_stats( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentStats: + """Is this a fresh install or a filled one? Read by the landing page's + first-run guide.""" + published = Document.status == DocumentStatus.published + return DocumentStats( + documents_total=( + await db.execute(select(func.count(Document.id)).where(published)) + ).scalar_one(), + departments_total=( + await db.execute(select(func.count(Department.id))) + ).scalar_one(), + ) diff --git a/backend/app/api/documents/crud.py b/backend/app/api/documents/crud.py new file mode 100644 index 0000000..4ced474 --- /dev/null +++ b/backend/app/api/documents/crud.py @@ -0,0 +1,236 @@ +"""The life of one document: open it, read it, change it, delete it. + +Publishing does NOT live here — a draft becomes public through +`workflow.py`, so a content edit can never make a private draft readable by +accident. Archiving does, because it is the mirror of the `status` a PATCH +already carries. +""" + +import uuid +from typing import Annotated, Any + +from fastapi import Depends +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.documents.access import ( + guard_self_lockout, + readable_document, + require_author_or_admin, + require_editor, +) +from app.api.documents.routing import documents_router +from app.api.documents.schemas import DocumentCreate, DocumentDetail, DocumentUpdate +from app.api.documents.view import detail, full_detail, granted_department_ids +from app.auth.deps import get_current_user +from app.authoring.context import summarize_conversation +from app.authoring.document import render_skeleton, render_title +from app.authoring.history import record_event +from app.authoring.schema import AuthoringTemplate +from app.db import get_db +from app.errors import ApiError +from app.ingestion.handlers import INDEX_DOCUMENT +from app.ingestion.queue import enqueue +from app.models import ( + Conversation, + Document, + DocumentEventAction, + DocumentStatus, + DocumentVisibility, + Template, + User, +) + +router = documents_router() + + +@router.post("", status_code=201) +async def create_document( + body: DocumentCreate, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + """Open a new document to write in. + + A `draft` is the author's private working copy: `readable_documents_filter` + shows it to no one else (bar a colleague asked to check it) and only + `published` documents are indexed, so a draft never reaches another user + or an LLM prompt.""" + title = body.title + content_md = "" + visibility = body.visibility or DocumentVisibility.department + meta: dict[str, Any] = {} + + if body.template_id is not None: + row = await db.get(Template, body.template_id) + if row is None: + raise ApiError(404, "Template not found.", "not_found") + try: + template = AuthoringTemplate.model_validate(row.config) + except ValidationError: + raise ApiError( + 422, "Template is not a valid authoring template.", "invalid_template" + ) from None + content_md = render_skeleton(template) + meta = {"template": template.id} + if title is None: + title = render_title(template, user) + if body.visibility is None: + visibility = DocumentVisibility(template.metadata.visibility) + + if not title: + raise ApiError(422, "A title or a template is required.", "title_required") + + if body.conversation_id is not None: + meta.update(await _conversation_context(db, body.conversation_id, user)) + + document = Document( + title=title, + status=DocumentStatus.draft, + visibility=visibility, + content_md=content_md, + meta=meta, + author_id=user.id, + department_id=user.department_id, + # Marks the collection loaded — a brand-new document has no requests, + # and the serializer reads them without a session to lazy-load in. + reviews=[], + ) + db.add(document) + # Flush so the event can reference document.id (the PK default is applied + # at flush, not at construction). + await db.flush() + record_event(db, document, user, DocumentEventAction.created, snapshot=True) + await db.commit() + return detail(document, user) + + +async def _conversation_context( + db: AsyncSession, conversation_id: uuid.UUID, user: User +) -> dict[str, Any]: + """What the chat this capture started from was about, as background for + section refinement. Owner-scoped; a foreign or unknown conversation simply + contributes nothing.""" + conversation = ( + await db.execute( + select(Conversation) + .where( + Conversation.id == conversation_id, + Conversation.user_id == user.id, + ) + .options(selectinload(Conversation.messages)) + ) + ).scalar_one_or_none() + if conversation is None: + return {} + topic = await summarize_conversation(conversation) + if not topic: + return {} + return {"context": topic, "conversation_id": str(conversation.id)} + + +@router.get("/{document_id}") +async def get_document( + document_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + document = await readable_document(db, document_id, user) + return await full_detail(db, document, user) + + +@router.patch("/{document_id}") +async def update_document( + document_id: uuid.UUID, + body: DocumentUpdate, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + document = await readable_document(db, document_id, user) + require_editor(document, user) + + # Track the axes of change separately so the audit trail can name what + # happened (an edit vs. a visibility change vs. an archive), even though + # all three equally invalidate the denormalized chunk copy. + body_changed = False + if body.title is not None and body.title != document.title: + document.title = body.title + body_changed = True + if body.content_md is not None and body.content_md != document.content_md: + document.content_md = body.content_md + body_changed = True + + visibility_changed = False + if body.visibility is not None and body.visibility != document.visibility: + # Who may READ this is the owner's decision, like sharing and deleting: + # a colleague asked to check the text may correct it, not re-address it. + require_author_or_admin(document, user) + # A visibility change can remove the editing user's own access (only an + # admin editing a document they do not own — an author keeps access). + guard_self_lockout( + user, + author_id=document.author_id, + visibility=body.visibility, + department_id=document.department_id, + granted_department_ids=await granted_department_ids(db, document.id), + confirm=bool(body.confirm_lockout), + ) + document.visibility = body.visibility + visibility_changed = True # chunk meta carries a denormalized copy + + if body.conversation_id is not None: + # Extending a document out of a chat: same background as a fresh + # capture. Metadata only — no event, and no reindex, because nothing + # a chunk carries changed. + document.meta = { + **document.meta, + **await _conversation_context(db, body.conversation_id, user), + } + + archived = False + status_changed = False + if body.status is not None and body.status != document.status: + archivable = {DocumentStatus.published, DocumentStatus.archived} + if body.status not in archivable or document.status not in archivable: + # Publishing is its own endpoint: it indexes the document and is + # the author's decision, not a field on a content edit. + raise ApiError( + 409, + "Only published documents can be archived (and vice versa).", + "invalid_status", + ) + document.status = body.status + status_changed = True + archived = body.status == DocumentStatus.archived + + # Audit: an edit snapshots the new Markdown so the version can be diffed; a + # visibility change or archive is a pure transition (no content snapshot). + if body_changed: + record_event(db, document, user, DocumentEventAction.edited, snapshot=True) + if visibility_changed: + record_event(db, document, user, DocumentEventAction.visibility_changed) + if archived: + record_event(db, document, user, DocumentEventAction.archived) + + # Chunks are derivatives of the Markdown: published edits reindex, and + # archive/publish transitions add or remove the chunks. + content_changed = body_changed or visibility_changed + published = document.status == DocumentStatus.published + if (content_changed and published) or status_changed: + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) + await db.commit() + return detail(document, user) + + +@router.delete("/{document_id}", status_code=204) +async def delete_document( + document_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + document = await readable_document(db, document_id, user) + require_author_or_admin(document, user) + await db.delete(document) # chunks cascade + await db.commit() diff --git a/backend/app/api/documents/history.py b/backend/app/api/documents/history.py new file mode 100644 index 0000000..e6659e5 --- /dev/null +++ b/backend/app/api/documents/history.py @@ -0,0 +1,112 @@ +"""The audit trail: who changed a document, when, and what that change was. + +Snapshots are written AFTER their event, so an entry's content is the state it +produced. Showing "what did this one do" therefore needs the pair (this +snapshot and the one before it), which is why the version endpoint returns both. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import select, tuple_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.documents.access import readable_document +from app.api.documents.routing import documents_router +from app.api.documents.schemas import DocumentEventOut, DocumentVersion +from app.auth.deps import get_current_user +from app.db import get_db +from app.errors import ApiError +from app.models import DocumentEvent, User + +router = documents_router() + +# Newest first, with the id as tiebreaker: events written in one transaction +# share a timestamp, and only a total order can be paged or walked backwards. +_NEWEST_FIRST = (DocumentEvent.created_at.desc(), DocumentEvent.id.desc()) + + +@router.get("/{document_id}/history") +async def document_history( + document_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[DocumentEventOut]: + """The document's audit trail, newest first: who changed or reviewed it, + when, and whether a content snapshot exists to diff against. Same read gate + as the document itself, so history never leaks to a user who cannot read the + document.""" + document = await readable_document(db, document_id, user) + rows = ( + await db.execute( + select(DocumentEvent, User.name) + .join(User, DocumentEvent.actor_id == User.id, isouter=True) + .where(DocumentEvent.document_id == document.id) + .order_by(*_NEWEST_FIRST) + ) + ).all() + return [ + DocumentEventOut( + id=event.id, + action=event.action, + actor_id=event.actor_id, + actor_name=name, + visibility=event.visibility, + created_at=event.created_at, + has_snapshot=event.content_md is not None, + ) + for event, name in rows + ] + + +@router.get("/{document_id}/versions/{event_id}") +async def document_version( + document_id: uuid.UUID, + event_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentVersion: + """A single past version's frozen content plus the content it replaced, so + the caller can show what this event changed. Same read gate as the + document.""" + document = await readable_document(db, document_id, user) + row = ( + await db.execute( + select(DocumentEvent, User.name) + .join(User, DocumentEvent.actor_id == User.id, isouter=True) + .where( + DocumentEvent.id == event_id, + DocumentEvent.document_id == document.id, + ) + ) + ).first() + if row is None: + raise ApiError(404, "Version not found.", "not_found") + event, name = row + # The state this event started from: the closest earlier snapshot, in the + # same order the history list uses. + previous = ( + await db.execute( + select(DocumentEvent.content_md) + .where( + DocumentEvent.document_id == document.id, + DocumentEvent.content_md.is_not(None), + tuple_(DocumentEvent.created_at, DocumentEvent.id) + < tuple_(event.created_at, event.id), + ) + .order_by(*_NEWEST_FIRST) + .limit(1) + ) + ).scalar_one_or_none() + return DocumentVersion( + id=event.id, + action=event.action, + actor_id=event.actor_id, + actor_name=name, + created_at=event.created_at, + title=event.title, + content_md=event.content_md, + previous_content_md=previous, + visibility=event.visibility, + ) diff --git a/backend/app/api/documents/routing.py b/backend/app/api/documents/routing.py new file mode 100644 index 0000000..7203f0c --- /dev/null +++ b/backend/app/api/documents/routing.py @@ -0,0 +1,13 @@ +"""The one router constructor the package's modules share. + +Every module builds its own `APIRouter` and `__init__` mounts them in the +order that matters. They cannot be prefix-less sub-routers: FastAPI refuses a +route whose path and router prefix are BOTH empty, which the browse list ("") +would be, so the prefix lives here rather than five times over. +""" + +from fastapi import APIRouter + + +def documents_router() -> APIRouter: + return APIRouter(prefix="/documents", tags=["documents"]) diff --git a/backend/app/api/documents/schemas.py b/backend/app/api/documents/schemas.py new file mode 100644 index 0000000..b544525 --- /dev/null +++ b/backend/app/api/documents/schemas.py @@ -0,0 +1,198 @@ +"""Request and response shapes for the documents API. + +Kept in one module because the whole package answers with the same handful of +document shapes: a summary in lists, a detail on a single document, and the +few command bodies that change one. +""" + +import uuid +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +from app.models import ( + AccessReason, + DocumentEventAction, + DocumentStatus, + DocumentVisibility, +) + + +class DocumentSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + title: str + status: DocumentStatus + visibility: DocumentVisibility + department_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + # Why this user sees it and what they may do — so the UI can explain + # access instead of leaving the rules implicit. + access_reason: AccessReason + can_edit: bool + # Unanswered questions about this document. A published document with an + # open question is readable but not settled, and every surface that shows + # the document says so — including the sources under a chat answer. + open_reviews: int + # Shipped with the product: read-only, and never deletable. + is_builtin: bool + + +class DepartmentRef(BaseModel): + id: uuid.UUID + name: str + + +class ReviewOut(BaseModel): + """One request to check this document. Open while `resolved_at` is null.""" + + id: uuid.UUID + question: str | None + requester_name: str | None + reviewer_id: uuid.UUID | None + reviewer_name: str | None + created_at: datetime + resolved_at: datetime | None + resolved_by_name: str | None + # Whether the caller is the one being asked, so the UI can offer the + # answer rather than just showing the question. + is_mine: bool + + +class DocumentDetail(DocumentSummary): + content_md: str + # Every request on this document, oldest first, open and answered — the + # answered ones are the record of what was already checked. + reviews: list[ReviewOut] = [] + # Additional departments the document is shared with, on top of its owning + # `department_id` (the `doc_permissions` grants). Resolved where the endpoint + # looks it up (get_document, the departments endpoint). + shared_departments: list[DepartmentRef] = [] + + +class DocumentSort(StrEnum): + """How the browse list is ordered. Deliberately two options: "what + changed" and "what is new" are the two questions people actually ask of + a document list.""" + + updated = "updated" + created = "created" + + +class DocumentPage(BaseModel): + items: list[DocumentSummary] + # Total matching the filters, not the page — the UI needs it to know + # whether there is a next page at all. + total: int + per_page: int + + +class DocumentSearchHit(DocumentSummary): + """A search result: the document plus the section that matched. + + Empty `heading_path` means the match was on the title, not a section. + """ + + heading_path: str = "" + + +class DocumentStats(BaseModel): + """Company-wide counts, read by the landing page's first-run guide. + + Aggregates only — no titles, no per-user data. Deliberately not + permission-filtered: a bare count reveals nothing about content. + """ + + documents_total: int + departments_total: int + + +class DocumentCreate(BaseModel): + """Start a new document the user will write in the editor. + + With a `template_id` the draft opens on that template's Markdown skeleton + and title; without one it starts blank and `title` is required. The result + is a `draft` — author-only and never indexed until it is published.""" + + template_id: uuid.UUID | None = None + title: str | None = None + visibility: DocumentVisibility | None = None + # When the capture started from a chat: its subject is summarized and kept + # on the draft as background for section refinement. + conversation_id: uuid.UUID | None = None + + +class DocumentUpdate(BaseModel): + title: str | None = None + content_md: str | None = None + visibility: DocumentVisibility | None = None + # Only the archive transition is settable here; publishing has its own + # endpoint, because it indexes the document. + status: DocumentStatus | None = None + # An admin may knowingly make a change that removes their own access; an + # author never can (they keep access as author). See access.guard_self_lockout. + # Nullable (not `bool = False`) so it stays optional in the generated client. + confirm_lockout: bool | None = None + # Continuing an EXISTING document out of a chat: the same background the + # create path attaches, for the document that already covers the topic. + conversation_id: uuid.UUID | None = None + + +class DocumentDepartments(BaseModel): + """The full set of ADDITIONAL departments the document is shared with (on + top of the owning department) — replaces the existing grants.""" + + department_ids: list[uuid.UUID] + confirm_lockout: bool | None = None + + +class ReviewerCandidate(BaseModel): + """A user the author may ask to check a document — id + name only.""" + + id: uuid.UUID + name: str + + +class ReviewRequestBody(BaseModel): + """Ask someone to check this document, optionally about something specific + ("do the holiday numbers still hold?").""" + + reviewer_id: uuid.UUID + question: str | None = Field(default=None, max_length=2000) + + +class DocumentEventOut(BaseModel): + """One entry in a document's history timeline — metadata only.""" + + id: uuid.UUID + action: DocumentEventAction + actor_id: uuid.UUID | None + # Null once the actor's account is deleted (SET NULL on the event). + actor_name: str | None + visibility: DocumentVisibility | None + created_at: datetime + # A content snapshot exists for this event and can be fetched for diffing. + has_snapshot: bool + + +class DocumentVersion(BaseModel): + """A past version's frozen content, for viewing or diffing. + + A snapshot is taken *after* its event, so `content_md` is the state this + event produced and `previous_content_md` the state it started from — the + pair is what "what did this change do?" needs. `previous_content_md` is + null for the first snapshot, where everything was added. + """ + + id: uuid.UUID + action: DocumentEventAction + actor_id: uuid.UUID | None + actor_name: str | None + created_at: datetime + title: str | None + content_md: str | None + previous_content_md: str | None + visibility: DocumentVisibility | None diff --git a/backend/app/api/documents/sharing.py b/backend/app/api/documents/sharing.py new file mode 100644 index 0000000..bb3bda6 --- /dev/null +++ b/backend/app/api/documents/sharing.py @@ -0,0 +1,86 @@ +"""Sharing a document with departments beyond its own. + +Management, not new permission logic: the read filter's EXISTS branch already +unions `doc_permissions` in, so this endpoint only maintains those rows. Grants +are evaluated live against the table, which is why nothing is reindexed here. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.documents.access import ( + guard_self_lockout, + readable_document, + require_author_or_admin, +) +from app.api.documents.routing import documents_router +from app.api.documents.schemas import DocumentDepartments, DocumentDetail +from app.api.documents.view import full_detail, granted_department_ids +from app.auth.deps import get_current_user +from app.db import get_db +from app.errors import ApiError +from app.models import Department, DocPermission, PermissionLevel, User + +router = documents_router() + + +@router.put("/{document_id}/departments") +async def set_shared_departments( + document_id: uuid.UUID, + body: DocumentDepartments, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + """Replace the full set of ADDITIONAL departments this document is shared + with. Author or admin only.""" + document = await readable_document(db, document_id, user) + require_author_or_admin(document, user) + + requested = set(body.department_ids) + # A document is never "shared with" its own owning department. + requested.discard(document.department_id) + if requested: + found = set( + ( + await db.execute( + select(Department.id).where(Department.id.in_(requested)) + ) + ) + .scalars() + .all() + ) + if requested - found: + raise ApiError(404, "One or more departments do not exist.", "not_found") + + # Removing a grant can drop the editing admin's own department access. + guard_self_lockout( + user, + author_id=document.author_id, + visibility=document.visibility, + department_id=document.department_id, + granted_department_ids=requested, + confirm=bool(body.confirm_lockout), + ) + + existing = await granted_department_ids(db, document.id) + for dept_id in existing - requested: + await db.execute( + delete(DocPermission).where( + DocPermission.document_id == document.id, + DocPermission.department_id == dept_id, + ) + ) + for dept_id in requested - existing: + db.add( + DocPermission( + document_id=document.id, + department_id=dept_id, + level=PermissionLevel.read, + ) + ) + await db.commit() + return await full_detail(db, document, user) diff --git a/backend/app/api/documents/view.py b/backend/app/api/documents/view.py new file mode 100644 index 0000000..aa4272d --- /dev/null +++ b/backend/app/api/documents/view.py @@ -0,0 +1,158 @@ +"""Document rows to API shapes. + +Every endpoint in the package answers with `summary` or `detail`, so the +per-request fields (why this user sees it, what they may do, what is still +open) are computed in exactly one place. +""" + +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.documents.access import access_reason, can_edit +from app.api.documents.schemas import ( + DepartmentRef, + DocumentDetail, + DocumentSummary, + ReviewOut, +) +from app.models import Department, DocPermission, Document, ReviewRequest, User + + +def document_fields(document: Document, user: User) -> dict[str, Any]: + """Built explicitly rather than via model_validate: access_reason and + can_edit are per-request, so there is nothing to read them from.""" + return { + "id": document.id, + "title": document.title, + "status": document.status, + "visibility": document.visibility, + "department_id": document.department_id, + "created_at": document.created_at, + "updated_at": document.updated_at, + "access_reason": access_reason(document, user), + "can_edit": can_edit(document, user), + "open_reviews": len(document.open_reviews), + "is_builtin": document.is_builtin, + } + + +def summary(document: Document, user: User) -> DocumentSummary: + return DocumentSummary(**document_fields(document, user)) + + +def detail( + document: Document, + user: User, + *, + reviews: list[ReviewOut] | None = None, + shared_departments: list[DepartmentRef] | None = None, +) -> DocumentDetail: + return DocumentDetail( + **document_fields(document, user), + content_md=document.content_md, + reviews=reviews or [], + shared_departments=shared_departments or [], + ) + + +async def resolve_reviews( + db: AsyncSession, document: Document, user: User +) -> list[ReviewOut]: + """The document's requests with the names filled in. + + One query for every name involved, rather than three relationships loaded + with every document: the names are needed on the detail page only, while + the requests themselves ride along everywhere (they decide who may edit). + """ + if not document.reviews: + return [] + wanted = { + person_id + for review in document.reviews + for person_id in ( + review.requester_id, + review.reviewer_id, + review.resolved_by_id, + ) + if person_id is not None + } + names = dict( + (await db.execute(select(User.id, User.name).where(User.id.in_(wanted)))).all() + ) + return [ + ReviewOut( + id=review.id, + question=review.question, + requester_name=names.get(review.requester_id), + reviewer_id=review.reviewer_id, + reviewer_name=names.get(review.reviewer_id), + created_at=review.created_at, + resolved_at=review.resolved_at, + resolved_by_name=names.get(review.resolved_by_id), + is_mine=review.reviewer_id == user.id, + ) + for review in document.reviews + ] + + +async def resolve_shared_departments( + db: AsyncSession, document: Document +) -> list[DepartmentRef]: + """The additional departments this document is shared with (its + `doc_permissions` grants), resolved to names for display.""" + rows = ( + await db.execute( + select(Department.id, Department.name) + .join(DocPermission, DocPermission.department_id == Department.id) + .where(DocPermission.document_id == document.id) + .order_by(Department.name) + ) + ).all() + return [DepartmentRef(id=row.id, name=row.name) for row in rows] + + +async def granted_department_ids( + db: AsyncSession, document_id: uuid.UUID +) -> set[uuid.UUID]: + return set( + ( + await db.execute( + select(DocPermission.department_id).where( + DocPermission.document_id == document_id + ) + ) + ) + .scalars() + .all() + ) + + +async def full_detail( + db: AsyncSession, document: Document, user: User +) -> DocumentDetail: + """The detail with everything resolved — for the endpoints that answer + with a document the UI is about to render in full.""" + return detail( + document, + user, + reviews=await resolve_reviews(db, document, user), + shared_departments=await resolve_shared_departments(db, document), + ) + + +async def open_review_for( + db: AsyncSession, document: Document, reviewer_id: uuid.UUID +) -> ReviewRequest | None: + """An unanswered request on this document addressed to `reviewer_id`.""" + return ( + await db.execute( + select(ReviewRequest).where( + ReviewRequest.document_id == document.id, + ReviewRequest.reviewer_id == reviewer_id, + ReviewRequest.resolved_at.is_(None), + ) + ) + ).scalar_one_or_none() diff --git a/backend/app/api/documents/workflow.py b/backend/app/api/documents/workflow.py new file mode 100644 index 0000000..9f130cf --- /dev/null +++ b/backend/app/api/documents/workflow.py @@ -0,0 +1,174 @@ +"""From draft to published, and the questions that hang off a document. + +Two things that used to be one. **Publishing** is the author's own decision: a +draft is private until they say it is worth reading, one action, no waiting. +**A review request** is "please check this", and it is not a status — it can +sit on a draft the author is unsure about OR on a document that has been +published for months, and it marks the document wherever it appears until +someone answers it. + +Being asked is what grants the right to edit: a reviewer who spots a wrong +number should fix it rather than file a second question about it. +""" + +import uuid +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.documents.access import ( + readable_document, + require_author_or_admin, + require_editor, +) +from app.api.documents.routing import documents_router +from app.api.documents.schemas import ( + DocumentDetail, + ReviewerCandidate, + ReviewRequestBody, +) +from app.api.documents.view import full_detail +from app.auth.deps import get_current_user +from app.authoring.history import record_event +from app.db import get_db +from app.errors import ApiError +from app.ingestion.handlers import INDEX_DOCUMENT +from app.ingestion.queue import enqueue +from app.models import DocumentEventAction, DocumentStatus, ReviewRequest, User +from app.rag.permissions import document_reader_filter + +router = documents_router() + + +@router.post("/{document_id}/publish") +async def publish_document( + document_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + """Make a draft readable and searchable for everyone its visibility allows. + + The author's own call — an open question about the content does not block + it, it travels with the document instead (`open_reviews`), which is what + lets a colleague read it AND know it is not settled. + + Author or admin, deliberately not every editor: a colleague asked to check + a draft may fix what is wrong in it, but whether the company gets to read + it at all is not their call. + """ + document = await readable_document(db, document_id, user) + require_author_or_admin(document, user) + if document.status != DocumentStatus.draft: + raise ApiError(409, "Only a draft can be published.", "invalid_status") + + document.status = DocumentStatus.published + record_event(db, document, user, DocumentEventAction.published) + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) + await db.commit() + return await full_detail(db, document, user) + + +@router.get("/{document_id}/reviewers") +async def list_reviewers( + document_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[ReviewerCandidate]: + """Who can be asked: everyone who could read this document once published, + minus the author. Permission-safe and non-admin (unlike /admin/users), and + only id + name leave the server.""" + document = await readable_document(db, document_id, user) + require_editor(document, user) + rows = ( + await db.execute( + select(User.id, User.name) + .where(document_reader_filter(document), User.id != document.author_id) + .order_by(User.name) + ) + ).all() + return [ReviewerCandidate(id=row.id, name=row.name) for row in rows] + + +@router.post("/{document_id}/reviews") +async def request_review( + document_id: uuid.UUID, + body: ReviewRequestBody, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + """Ask a colleague to check this document, optionally about something + specific. The request grants them the right to read and edit it until it + is answered.""" + document = await readable_document(db, document_id, user) + require_author_or_admin(document, user) + if body.reviewer_id == user.id: + raise ApiError(422, "You cannot ask yourself.", "invalid_reviewer") + + allowed = ( + await db.execute( + select(User.id).where( + User.id == body.reviewer_id, + document_reader_filter(document), + ) + ) + ).scalar_one_or_none() + if allowed is None: + raise ApiError( + 422, "That user cannot review this document.", "invalid_reviewer" + ) + if any(review.reviewer_id == body.reviewer_id for review in document.open_reviews): + raise ApiError( + 409, "That colleague has already been asked.", "review_already_open" + ) + + db.add( + ReviewRequest( + document_id=document.id, + requester_id=user.id, + reviewer_id=body.reviewer_id, + question=(body.question or "").strip() or None, + ) + ) + record_event(db, document, user, DocumentEventAction.review_requested) + await db.commit() + # Reload the collection, not just the columns: the serializer reads the + # requests, and a lazy load there would be IO in a sync property. + await db.refresh(document, attribute_names=["reviews"]) + return await full_detail(db, document, user) + + +@router.post("/{document_id}/reviews/{review_id}/resolve") +async def resolve_review( + document_id: uuid.UUID, + review_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> DocumentDetail: + """Answer a request: the content was checked. + + The reviewer answers their own request; the author (or an admin) can close + one that has become moot, because a question nobody will answer should not + mark a document forever. + """ + document = await readable_document(db, document_id, user) + review = next( + (review for review in document.reviews if review.id == review_id), None + ) + if review is None: + raise ApiError(404, "Review request not found.", "not_found") + if review.resolved_at is not None: + raise ApiError(409, "This request is already answered.", "already_resolved") + if review.reviewer_id != user.id: + require_author_or_admin(document, user) + + review.resolved_at = datetime.now(UTC) + review.resolved_by_id = user.id + record_event(db, document, user, DocumentEventAction.review_resolved) + await db.commit() + # Reload the collection, not just the columns: the serializer reads the + # requests, and a lazy load there would be IO in a sync property. + await db.refresh(document, attribute_names=["reviews"]) + return await full_detail(db, document, user) diff --git a/backend/app/api/people.py b/backend/app/api/people.py new file mode 100644 index 0000000..a3c86be --- /dev/null +++ b/backend/app/api/people.py @@ -0,0 +1,72 @@ +"""The colleague directory. + +A member-visible directory any authenticated user may browse: colleagues' +`{id, name, role, department}` — no email, no password hash. The same +permission-safe, non-admin shape as `ReviewerCandidate` in `api/documents.py`, +deliberately separate from the admin-only `/admin/users`. +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import get_current_user +from app.db import get_db +from app.errors import ApiError +from app.models import Department, User, UserRole + +router = APIRouter(prefix="/people", tags=["people"]) + + +class PersonOut(BaseModel): + """A colleague as the directory shows them — never email or credentials.""" + + id: uuid.UUID + name: str + role: UserRole + department: str | None + + +def _select_people(): + return select( + User.id, + User.name, + User.role, + Department.name.label("department"), + ).join(Department, User.department_id == Department.id, isouter=True) + + +def _person(row) -> PersonOut: + return PersonOut( + id=row.id, + name=row.name, + role=row.role, + department=row.department, + ) + + +@router.get("") +async def list_people( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[PersonOut]: + """Every colleague, ordered by name. Visible to any authenticated user.""" + rows = (await db.execute(_select_people().order_by(User.name))).all() + return [_person(row) for row in rows] + + +@router.get("/{person_id}") +async def get_person( + person_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> PersonOut: + """One colleague's profile.""" + row = (await db.execute(_select_people().where(User.id == person_id))).first() + if row is None: + raise ApiError(404, "Person not found.", "not_found") + return _person(row) diff --git a/backend/app/api/sse.py b/backend/app/api/sse.py new file mode 100644 index 0000000..9380f88 --- /dev/null +++ b/backend/app/api/sse.py @@ -0,0 +1,13 @@ +"""Server-sent events, the one way this API streams. + +Two endpoints stream (a chat turn and a section refinement) and they frame the +same way, so the wire format lives here rather than in both. `event:` names the frame, `data:` carries a JSON object, and +a blank line ends it. +""" + +import json +from typing import Any + + +def sse(event: str, data: dict[str, Any]) -> str: + return f"event: {event}\ndata: {json.dumps(data)}\n\n" diff --git a/backend/app/api/templates/__init__.py b/backend/app/api/templates/__init__.py new file mode 100644 index 0000000..70cbf91 --- /dev/null +++ b/backend/app/api/templates/__init__.py @@ -0,0 +1,19 @@ +"""The templates API: the blueprints a document can be started from. + +Reading is for everyone (the picker needs it), changing is admin-only, and the +two live on separate routers so the gate is structural rather than repeated per +endpoint. `catalog` is what ships with the product, `edit` what the customer +made of it. + +**Route order matters.** The catalog's static paths are registered before +`/{template_id}`, or "catalog" would be parsed as a row id. +""" + +from fastapi import APIRouter + +from app.api.templates import browse, catalog, edit + +router = APIRouter() +router.include_router(catalog.router) +router.include_router(edit.router) +router.include_router(browse.router) diff --git a/backend/app/api/templates/blueprints.py b/backend/app/api/templates/blueprints.py new file mode 100644 index 0000000..1d220bc --- /dev/null +++ b/backend/app/api/templates/blueprints.py @@ -0,0 +1,67 @@ +"""A template's blueprint: parsing it, and keeping its id unique. + +The config id is a slug, not a row id — it is what documents record as their +origin and what the catalog matches on, so two rows must never share one. +Three endpoints need that rule (add from catalog, save from the builder, +duplicate), which is why it is written once here. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.authoring.schema import AuthoringTemplate +from app.errors import ApiError +from app.models import Template +from app.template_import import TemplateImportError, parse_template + + +def parse_or_422(source: str) -> AuthoringTemplate: + """YAML in, validated blueprint out. The parse error is the message: it + already says which field is wrong, and an admin is the one reading it.""" + try: + return parse_template(source) + except TemplateImportError as exc: + raise ApiError(422, str(exc), "invalid_template") from None + + +async def taken_config_ids(db: AsyncSession) -> set[str]: + return set((await db.execute(select(Template.config["id"].astext))).scalars().all()) + + +def unique_config_id(base: str, taken: set[str], *, suffix: str = "") -> str: + """`base`, or `base-2`, `base-3` … until it is free. + + `suffix` marks derived ids (a duplicate becomes `base-kopie`), so a copy + reads as a copy in the one place ids are visible. + """ + stem = f"{base}{suffix}" + candidate = stem + counter = 2 + while candidate in taken: + candidate = f"{stem}-{counter}" + counter += 1 + return candidate + + +async def ensure_config_id_free( + db: AsyncSession, config_id: str, *, except_row: uuid.UUID +) -> None: + """Refuse an edit that would move a config id onto a DIFFERENT row. + + Editing keeps the id stable, so a clash is never the row's own id — it + means someone would silently steal another template's identity. + """ + clash = ( + await db.execute( + select(Template.id).where( + Template.config["id"].astext == config_id, + Template.id != except_row, + ) + ) + ).scalar_one_or_none() + if clash is not None: + raise ApiError( + 409, f"Another template already uses the id '{config_id}'.", "id_taken" + ) diff --git a/backend/app/api/templates/browse.py b/backend/app/api/templates/browse.py new file mode 100644 index 0000000..f577ccb --- /dev/null +++ b/backend/app/api/templates/browse.py @@ -0,0 +1,45 @@ +"""Reading templates. Any authenticated user: the picker needs them.""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.templates.routing import reader_router +from app.api.templates.schemas import TemplateDetail, TemplateSummary +from app.api.templates.view import detail, summary +from app.auth.deps import get_current_user +from app.config import get_settings +from app.db import get_db +from app.errors import ApiError +from app.models import Template, User + +router = reader_router() + + +@router.get("") +async def list_templates( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[TemplateSummary]: + """Templates the picker offers. Templates in the reader's language come + first — they are customer content, so a mismatched one is still listed + rather than hidden.""" + rows = (await db.execute(select(Template).order_by(Template.name))).scalars().all() + wanted = user.locale or get_settings().default_locale + ordered = sorted(rows, key=lambda row: row.config.get("locale") != wanted) + return [summary(row) for row in ordered] + + +@router.get("/{template_id}") +async def get_template( + template_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> TemplateDetail: + row = await db.get(Template, template_id) + if row is None: + raise ApiError(404, "Template not found.", "not_found") + return detail(row) diff --git a/backend/app/api/templates/catalog.py b/backend/app/api/templates/catalog.py new file mode 100644 index 0000000..d7eb2b0 --- /dev/null +++ b/backend/app/api/templates/catalog.py @@ -0,0 +1,79 @@ +"""The blueprints that ship with the product. + +Nothing in the catalog is active. It is a shelf an admin takes from: adding a +blueprint copies it into an ordinary template row, which the customer then owns +and edits. The catalog never touches that row again. +""" + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.templates.blueprints import parse_or_422, taken_config_ids +from app.api.templates.routing import editor_router +from app.api.templates.schemas import CatalogDetail, CatalogSummary, TemplateDetail +from app.api.templates.view import detail +from app.db import get_db +from app.errors import ApiError +from app.template_catalog import catalog_for_locale, get_catalog_entry +from app.template_import import upsert_template + +router = editor_router() + + +@router.get("/catalog") +async def list_catalog( + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[CatalogSummary]: + """The blueprints shipped with Pablan, each marked with whether this + instance has already added it.""" + added = await taken_config_ids(db) + return [ + CatalogSummary( + id=entry.id, + name=entry.name, + description=entry.description, + sections=entry.sections, + added=entry.id in added, + ) + for entry in catalog_for_locale() + ] + + +@router.get("/catalog/{catalog_id}") +async def get_catalog_blueprint(catalog_id: str) -> CatalogDetail: + """Read a blueprint before adding it — the whole point of "view" is that + an admin can see its structure before committing to it.""" + entry = get_catalog_entry(catalog_id) + if entry is None: + raise ApiError(404, "Blueprint not found.", "not_found") + return CatalogDetail( + id=entry.id, + name=entry.name, + description=entry.description, + sections=entry.sections, + added=False, + yaml=entry.source, + ) + + +@router.post("/catalog/{catalog_id}") +async def add_from_catalog( + catalog_id: str, + db: Annotated[AsyncSession, Depends(get_db)], +) -> TemplateDetail: + """Copy a blueprint into this instance. The result is an ordinary + template row: editable, and never touched by the catalog again.""" + entry = get_catalog_entry(catalog_id) + if entry is None: + raise ApiError(404, "Blueprint not found.", "not_found") + if entry.id in await taken_config_ids(db): + raise ApiError( + 409, + "This template has already been added — edit or duplicate it instead.", + "already_added", + ) + row, _created = await upsert_template(db, parse_or_422(entry.source)) + await db.commit() + return detail(row) diff --git a/backend/app/api/templates/edit.py b/backend/app/api/templates/edit.py new file mode 100644 index 0000000..a58dddc --- /dev/null +++ b/backend/app/api/templates/edit.py @@ -0,0 +1,135 @@ +"""Changing what this instance offers. Admin only, by the router it hangs on. + +Two ways in, one guarantee: the form builder sends a structured config and the +YAML editor sends text, but both end as the same validated blueprint, so +neither path can save something the other would reject. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.templates.blueprints import ( + ensure_config_id_free, + parse_or_422, + taken_config_ids, + unique_config_id, +) +from app.api.templates.routing import editor_router +from app.api.templates.schemas import ( + TemplateBuildRequest, + TemplateDetail, + TemplateImportRequest, +) +from app.api.templates.view import detail +from app.db import get_db +from app.errors import ApiError +from app.models import Template + +router = editor_router() + +# What a template's config id falls back to when the form has nothing to slug. +FALLBACK_ID = "vorlage" + + +async def _row_or_404(db: AsyncSession, template_id: uuid.UUID) -> Template: + row = await db.get(Template, template_id) + if row is None: + raise ApiError(404, "Template not found.", "not_found") + return row + + +@router.post("/build") +async def build_template( + body: TemplateBuildRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> TemplateDetail: + """Save a template from the structured form builder. Creates a new row + (template_id null) or updates one in place.""" + template = body.config + config = template.model_dump(mode="json") + + if body.template_id is None: + # The config id is an internal slug the form derives from the name; + # make it unique so a second "Onboarding" never overwrites the first. + config["id"] = unique_config_id( + template.id or FALLBACK_ID, await taken_config_ids(db) + ) + row = Template(name=template.name, version=template.version, config=config) + db.add(row) + else: + await ensure_config_id_free(db, template.id, except_row=body.template_id) + row = await _row_or_404(db, body.template_id) + row.name = template.name + row.version = template.version + row.config = config + + await db.commit() + return detail(row) + + +@router.put("/{template_id}") +async def update_template( + template_id: uuid.UUID, + body: TemplateImportRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> TemplateDetail: + """Replace a template's YAML. Validated against the schema on save.""" + row = await _row_or_404(db, template_id) + template = parse_or_422(body.yaml) + await ensure_config_id_free(db, template.id, except_row=row.id) + + row.name = template.name + row.version = template.version + row.config = template.model_dump(mode="json") + await db.commit() + return detail(row) + + +async def _copy_name(db: AsyncSession, name: str) -> str: + """The next free " (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() diff --git a/backend/app/api/templates/routing.py b/backend/app/api/templates/routing.py new file mode 100644 index 0000000..a71ab5d --- /dev/null +++ b/backend/app/api/templates/routing.py @@ -0,0 +1,21 @@ +"""Two routers, because templates have two audiences. + +Everyone may READ the templates (the picker needs them); only an admin may +change what the instance offers. Expressing that as two constructors means a +new endpoint is gated by which router it is added to, not by remembering to +repeat a dependency. +""" + +from fastapi import APIRouter, Depends + +from app.auth.deps import require_admin + + +def reader_router() -> APIRouter: + return APIRouter(prefix="/templates", tags=["templates"]) + + +def editor_router() -> APIRouter: + return APIRouter( + prefix="/templates", tags=["templates"], dependencies=[Depends(require_admin)] + ) diff --git a/backend/app/api/templates/schemas.py b/backend/app/api/templates/schemas.py new file mode 100644 index 0000000..3b57317 --- /dev/null +++ b/backend/app/api/templates/schemas.py @@ -0,0 +1,61 @@ +"""Request and response shapes for templates and the shipped catalog.""" + +import uuid +from typing import Any + +from pydantic import BaseModel + +from app.authoring.schema import AuthoringTemplate + + +class TemplateSummary(BaseModel): + id: uuid.UUID + # The blueprint id from the config (e.g. "onboarding-basis"): stable across + # installs, where the row id is not. Anything that wants to offer ONE known + # blueprint (the profile page's "write about yourself") finds it by this. + config_id: str + name: str + version: str + description: str = "" + + +class TemplateDetail(TemplateSummary): + config: dict[str, Any] + # The editable source. Serialized server-side because the frontend has + # no YAML library and must not gain one. + yaml: str + + +class CatalogSummary(BaseModel): + """A blueprint on disk. `id` is the config id, NOT a row id — a catalog + entry has no row until someone adds it.""" + + id: str + name: str + description: str + # How many skeleton sections the blueprint carries hints for. + sections: int + # Whether a template with this config id already exists, so the UI can + # offer "View" instead of a second "Add". + added: bool + + +class CatalogDetail(CatalogSummary): + yaml: str + + +class TemplateImportRequest(BaseModel): + yaml: str + + +class TemplateBuildRequest(BaseModel): + """A template assembled by the form builder. The config is the same schema + a pasted YAML parses into, so both paths get one validation guarantee — + the frontend has no YAML library and must not gain one, so it sends the + structured config instead of serializing it.""" + + # The row to update, or null to create a new template. Kept separate from + # the config id (a stable slug) so renaming the display name never forks + # the row. + template_id: uuid.UUID | None = None + config: AuthoringTemplate diff --git a/backend/app/api/templates/view.py b/backend/app/api/templates/view.py new file mode 100644 index 0000000..58856dc --- /dev/null +++ b/backend/app/api/templates/view.py @@ -0,0 +1,33 @@ +"""Template rows to API shapes. + +Both are built explicitly rather than validated from the row: the blueprint id +and the description live inside `config`, and `yaml` is rendered per request, +so there is nothing on the row to read them from. +""" + +import yaml + +from app.api.templates.schemas import TemplateDetail, TemplateSummary +from app.models import Template + + +def summary(row: Template) -> TemplateSummary: + return TemplateSummary( + id=row.id, + config_id=row.config.get("id", ""), + name=row.name, + version=row.version, + description=row.config.get("description", ""), + ) + + +def detail(row: Template) -> TemplateDetail: + return TemplateDetail( + id=row.id, + config_id=row.config.get("id", ""), + name=row.name, + version=row.version, + description=row.config.get("description", ""), + config=row.config, + yaml=yaml.safe_dump(row.config, allow_unicode=True, sort_keys=False, width=80), + ) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..f311270 --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,40 @@ +import uuid +from typing import Annotated + +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.sessions import COOKIE_NAME, get_valid_session +from app.db import get_db +from app.errors import ApiError +from app.models import AuthSession, User, UserRole + + +async def get_current_auth_session( + request: Request, db: Annotated[AsyncSession, Depends(get_db)] +) -> AuthSession: + raw = request.cookies.get(COOKIE_NAME) + if raw is None: + raise ApiError(401, "Not authenticated.", "not_authenticated") + try: + session_id = uuid.UUID(raw) + except ValueError: + raise ApiError(401, "Not authenticated.", "not_authenticated") from None + session = await get_valid_session(db, session_id) + if session is None: + raise ApiError(401, "Not authenticated.", "not_authenticated") + return session + + +async def get_current_user( + session: Annotated[AuthSession, Depends(get_current_auth_session)], +) -> User: + return session.user + + +async def require_admin( + user: Annotated[User, Depends(get_current_user)], +) -> User: + if user.role != UserRole.admin: + raise ApiError(403, "Admin privileges required.", "forbidden") + return user diff --git a/backend/app/auth/passwords.py b/backend/app/auth/passwords.py new file mode 100644 index 0000000..ee03fab --- /dev/null +++ b/backend/app/auth/passwords.py @@ -0,0 +1,23 @@ +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError + +_hasher = PasswordHasher() + +# Verified against when the user does not exist, so login duration does not +# reveal whether an email address is registered. +_DUMMY_HASH = _hasher.hash("pablan-dummy-password") + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except (VerificationError, InvalidHashError): + return False + + +def burn_verification_time() -> None: + verify_password(_DUMMY_HASH, "wrong-password") diff --git a/backend/app/auth/sessions.py b/backend/app/auth/sessions.py new file mode 100644 index 0000000..ce12124 --- /dev/null +++ b/backend/app/auth/sessions.py @@ -0,0 +1,68 @@ +import uuid +from datetime import UTC, datetime, timedelta + +from fastapi import Response +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models import AuthSession, User + +COOKIE_NAME = "pablan_session" + + +async def create_auth_session(db: AsyncSession, user: User) -> AuthSession: + settings = get_settings() + session = AuthSession( + user_id=user.id, + expires_at=datetime.now(UTC) + timedelta(days=settings.auth_session_ttl_days), + ) + db.add(session) + await db.flush() + return session + + +async def get_valid_session( + db: AsyncSession, session_id: uuid.UUID +) -> AuthSession | None: + session = await db.get(AuthSession, session_id) + if session is None or session.expires_at <= datetime.now(UTC): + return None + return session + + +async def revoke_user_sessions( + db: AsyncSession, user_id: uuid.UUID, *, keep_session_id: uuid.UUID | None = None +) -> None: + """Log a user out everywhere — the session-revocation primitive. + + `keep_session_id` spares the caller's own session, which is what a + self-service password change wants: every other device is logged out, + the one you are typing on is not. + """ + statement = delete(AuthSession).where(AuthSession.user_id == user_id) + if keep_session_id is not None: + statement = statement.where(AuthSession.id != keep_session_id) + await db.execute(statement) + + +def set_session_cookie(response: Response, session: AuthSession) -> None: + settings = get_settings() + response.set_cookie( + COOKIE_NAME, + str(session.id), + max_age=settings.auth_session_ttl_days * 24 * 60 * 60, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + ) + + +def clear_session_cookie(response: Response) -> None: + settings = get_settings() + response.delete_cookie( + COOKIE_NAME, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + ) diff --git a/backend/app/authoring/__init__.py b/backend/app/authoring/__init__.py new file mode 100644 index 0000000..b9e8295 --- /dev/null +++ b/backend/app/authoring/__init__.py @@ -0,0 +1,8 @@ +"""Writing-first knowledge capture. + +Capture is not a conversation Mode: the artifact is a Document the user +writes directly (Markdown is the source of truth), and the model +refines one section at a time (FIM-style). This package holds the template +schema, the skeleton/title rendering, the active-section boundary and the +refinement prompt. The HTTP surface lives in `app/api/authoring.py`. +""" diff --git a/backend/app/authoring/context.py b/backend/app/authoring/context.py new file mode 100644 index 0000000..d2d198e --- /dev/null +++ b/backend/app/authoring/context.py @@ -0,0 +1,67 @@ +"""Conversation context for a capture. + +When a document is written out of a chat, that chat's subject is useful twice: +to find existing documents the user might extend, and as background for section +refinement. Both use a short LLM topic summary of the conversation. All LLM +traffic goes through `llm/client.py`; nothing here logs content — a failure +degrades quietly rather than breaking the capture. +""" + +import logging + +from pydantic import BaseModel + +from app.authoring.prompts import render_topic_summary_prompt +from app.llm.client import chat_json +from app.llm.errors import LLMError +from app.models import Conversation, MessageRole + +logger = logging.getLogger("pablan.authoring") + +# How many recent turns feed the summary — enough for the subject, bounded so +# a long thread cannot blow up the utility prompt. +MAX_CONTEXT_MESSAGES = 12 + +# Skip the reasoning model's hidden thinking: this is a short, latency- +# sensitive utility call. +_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}} + + +class _TopicSummary(BaseModel): + topic: str + + +def conversation_transcript(conversation: Conversation) -> str: + """The recent user/assistant turns as a plain transcript.""" + turns = [ + message + for message in conversation.messages + if message.role in (MessageRole.user, MessageRole.assistant) + ][-MAX_CONTEXT_MESSAGES:] + return "\n".join( + f"{'User' if message.role == MessageRole.user else 'Assistant'}: " + f"{message.content}" + for message in turns + ) + + +async def summarize_transcript(transcript: str) -> str: + """A short topic summary of a conversation transcript, or '' if none can + be made. Failures degrade quietly.""" + if not transcript.strip(): + return "" + try: + result = await chat_json( + render_topic_summary_prompt(transcript), + _TopicSummary, + extra_body=_NO_THINKING, + ) + except LLMError: + logger.info("topic summary failed", extra={"event": "topic_summary_failed"}) + return "" + return result.topic.strip() + + +async def summarize_conversation(conversation: Conversation) -> str: + """A short topic summary of the conversation, or '' if none can be made.""" + return await summarize_transcript(conversation_transcript(conversation)) diff --git a/backend/app/authoring/document.py b/backend/app/authoring/document.py new file mode 100644 index 0000000..b7adffb --- /dev/null +++ b/backend/app/authoring/document.py @@ -0,0 +1,23 @@ +"""Render a template's Markdown skeleton and title into a new draft document.""" + +from datetime import UTC, datetime + +from app.authoring.schema import AuthoringTemplate +from app.models import User + + +def render_title(template: AuthoringTemplate, user: User) -> str: + today = datetime.now(UTC).date().isoformat() + return ( + template.title_template.replace("{{user.name}}", user.name) + .replace("{{date}}", today) + .strip() + ) + + +def render_skeleton(template: AuthoringTemplate) -> str: + """The Markdown the editor opens with: the skeleton verbatim, normalized + to a single trailing newline. An empty skeleton yields an empty document + the author fills from scratch.""" + skeleton = template.skeleton.strip() + return f"{skeleton}\n" if skeleton else "" diff --git a/backend/app/authoring/history.py b/backend/app/authoring/history.py new file mode 100644 index 0000000..9d13eec --- /dev/null +++ b/backend/app/authoring/history.py @@ -0,0 +1,40 @@ +"""The document audit trail. + +Every content edit and lifecycle transition is appended to `document_events` +as an immutable record of who did what, when. Content-bearing actions snapshot +the Markdown source of truth (never the disposable chunks) so a past version +can later be viewed or diffed. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Document, DocumentEvent, DocumentEventAction, User + + +def record_event( + db: AsyncSession, + document: Document, + actor: User, + action: DocumentEventAction, + *, + snapshot: bool = False, +) -> None: + """Append an audit record for `document`. + + `snapshot` freezes the current Markdown, title and meta so the version can + be reconstructed later — pass it for content-bearing events (created / + edited). Visibility is small, so it is always recorded. Leave `snapshot` + False for pure transitions that carry no new content. The document must + already have an id (flush a freshly created document first). + """ + db.add( + DocumentEvent( + document_id=document.id, + actor_id=actor.id, + action=action, + content_md=document.content_md if snapshot else None, + title=document.title if snapshot else None, + visibility=document.visibility, + meta=document.meta if snapshot else None, + ) + ) diff --git a/backend/app/authoring/prompts.py b/backend/app/authoring/prompts.py new file mode 100644 index 0000000..58b2452 --- /dev/null +++ b/backend/app/authoring/prompts.py @@ -0,0 +1,81 @@ +"""Refinement prompt for the writing editor — natural language only. + +The model refines exactly ONE section of a document the user is writing. The +rest of the document travels as prefix/suffix context so the section stays +coherent with its surroundings, but the model regenerates ONLY the section — +a large document is never re-emitted whole (FIM-style). + +The base texts (persona, rules, framings) are admin-editable: they come from +`app/prompts/overrides.py::get_prompt`, which returns a DB override when one +exists and the code default (`app/prompts/defaults.py`) otherwise. +""" + +from app.llm.client import ChatMessage +from app.prompts.overrides import get_prompt + + +def render_refine_prompt( + section: str, + *, + prefix: str, + suffix: str, + persona: str | None, + hint: str | None, + context: str | None = None, + knowledge: list[str] | None = None, +) -> list[ChatMessage]: + # A template may carry its own persona; otherwise the admin-editable default. + system_parts = [persona.strip() if persona else get_prompt("refine_persona")] + if hint: + system_parts.append(f"What this section should convey: {hint}") + if context: + # Background from the chat this capture came from, so the refinement + # is on-topic — but only as orientation, never a source of new facts. + system_parts.append( + f"Background (the conversation this document came from, for " + f"orientation only — do not invent facts from it): {context}" + ) + system_parts.append(get_prompt("refine_rules")) + + # The document being edited leads the user turn (prefix/suffix/section); + # the retrieved knowledge trails it, because it changes on every call and + # keeping it last leaves the stable prompt prefix reusable between calls. + user_parts: list[str] = [] + if prefix.strip(): + user_parts.append( + f"Text before the section (context only, do not repeat it):\n{prefix}" + ) + if suffix.strip(): + user_parts.append( + f"Text after the section (context only, do not repeat it):\n{suffix}" + ) + user_parts.append(f"Refine only this section:\n{section}") + if knowledge: + # What the company has already documented elsewhere. It is grounding, + # not source material: it keeps terminology and facts consistent and + # lets the section point at related documents, but it must not be + # copied in or become a way to add facts the section's notes do not + # support. + joined = "\n\n".join(knowledge) + user_parts.append(f"{get_prompt('grounding_framing')}\n{joined}") + + return [ + {"role": "system", "content": "\n\n".join(system_parts)}, + {"role": "user", "content": "\n\n".join(user_parts)}, + ] + + +def render_topic_summary_prompt(transcript: str) -> list[ChatMessage]: + """Condense a conversation into a short search topic (a few words).""" + return [ + {"role": "system", "content": get_prompt("topic_summary")}, + {"role": "user", "content": f"Conversation:\n{transcript}"}, + ] + + +def render_title_prompt(content_md: str) -> list[ChatMessage]: + """Suggest a concise document title from its written content.""" + return [ + {"role": "system", "content": get_prompt("title")}, + {"role": "user", "content": f"Document:\n{content_md}"}, + ] diff --git a/backend/app/authoring/schema.py b/backend/app/authoring/schema.py new file mode 100644 index 0000000..3fb8c4f --- /dev/null +++ b/backend/app/authoring/schema.py @@ -0,0 +1,67 @@ +"""Pydantic schema for authoring templates (schema version 1.0). + +A template is a **Markdown skeleton** — a starting document with headings the +author fills in — plus a persona and optional per-section hints that steer the +section-refinement model. It is declarative configuration, not code (see +docs/authoring-templates.md), stored in `templates.config` (JSONB) and +validated on load. +""" + +from typing import Literal + +from pydantic import BaseModel, field_validator + + +class TemplateModelHints(BaseModel): + temperature: float = 0.4 + # UI warning when the configured endpoint is weaker than the template + # expects; read by api/templates.py. + min_class_hint: str | None = None + + +class SectionHint(BaseModel): + """Steers what the refinement model should draw out of one section. + + `heading` is matched to a skeleton heading by its exact text, so the hint + only reaches the model while the author is writing under that heading. + """ + + heading: str + hint: str + + +class TemplateMetadata(BaseModel): + visibility: Literal["public", "department", "restricted"] = "department" + + +class AuthoringTemplate(BaseModel): + id: str + name: str + version: str + # Names the template's shape, so a differently-shaped config is rejected + # rather than silently loaded as an authoring template. + kind: Literal["authoring"] = "authoring" + # The language this template's CONTENT is written in — persona, skeleton + # and hints, not the UI. The picker lists matching templates first. + locale: Literal["de", "en"] | None = None + description: str = "" + model: TemplateModelHints = TemplateModelHints() + persona: str + # The Markdown the editor opens with: headings the author fills in. This + # IS the starting content, not a description of it. + skeleton: str + sections: list[SectionHint] = [] + title_template: str + metadata: TemplateMetadata = TemplateMetadata() + + @field_validator("version", mode="before") + @classmethod + def _version_to_string(cls, value: object) -> str: + # YAML reads an unquoted 1.0 as a float. + return str(value) + + def hint_for(self, heading: str) -> str | None: + for section in self.sections: + if section.heading == heading: + return section.hint + return None diff --git a/backend/app/authoring/sections.py b/backend/app/authoring/sections.py new file mode 100644 index 0000000..c8a39b0 --- /dev/null +++ b/backend/app/authoring/sections.py @@ -0,0 +1,131 @@ +"""Find the section of a Markdown document the cursor sits in. + +The refinement endpoint refines exactly one section at a time (FIM-style), +so this is the AUTHORITATIVE boundary computation — the client mirrors it for +a visual highlight, but the server owns it. A section runs from the nearest +heading at or above the cursor down to the line before the next heading of +the same or higher level; content before the first heading is its own +section. A section whose body exceeds the chunk cap narrows to the blank-line +paragraph at the cursor, so a large document never refines as one giant block. + +Shares the heading regex, fence-awareness and cap with `rag/chunking.py` so +"what is a section" means the same thing to refinement and to indexing. +""" + +from dataclasses import dataclass + +from app.rag.chunking import HEADING_RE, TARGET_CHUNK_CHARS + + +@dataclass(frozen=True) +class ActiveSection: + start_line: int # 1-based, inclusive, into content_md + end_line: int # 1-based, inclusive + + +def _heading_lines(lines: list[str]) -> list[tuple[int, int]]: + """(line_index_0based, level) for every heading line, ignoring fences.""" + headings: list[tuple[int, int]] = [] + in_fence = False + for i, line in enumerate(lines): + if line.lstrip().startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + match = HEADING_RE.match(line) + if match: + headings.append((i, len(match.group(1)))) + return headings + + +def _paragraph_at( + lines: list[str], start0: int, end0: int, cursor0: int +) -> tuple[int, int] | None: + """The blank-line-delimited block (fence-aware) at the cursor, within + [start0, end0]. Falls back to the block just before the cursor when it + sits on a blank gap, else the first block.""" + blocks: list[tuple[int, int]] = [] + block_start: int | None = None + in_fence = False + for i in range(start0, end0 + 1): + line = lines[i] + if line.lstrip().startswith("```"): + in_fence = not in_fence + if block_start is None: + block_start = i + continue + if not line.strip() and not in_fence: + if block_start is not None: + blocks.append((block_start, i - 1)) + block_start = None + elif block_start is None: + block_start = i + if block_start is not None: + blocks.append((block_start, end0)) + if not blocks: + return None + for b_start, b_end in blocks: + if b_start <= cursor0 <= b_end: + return b_start, b_end + for b_start, b_end in reversed(blocks): + if b_end < cursor0: + return b_start, b_end + return blocks[0] + + +def active_section(content_md: str, cursor_line: int) -> ActiveSection: + lines = content_md.splitlines() + n = len(lines) + if n == 0: + return ActiveSection(1, 1) + cursor0 = max(1, min(cursor_line, n)) - 1 + + headings = _heading_lines(lines) + owner: tuple[int, int] | None = None + for idx, level in headings: + if idx <= cursor0: + owner = (idx, level) + else: + break + + if owner is None: + # Preamble before the first heading (or a document with no headings). + start0 = 0 + end0 = headings[0][0] - 1 if headings else n - 1 + else: + start0, owner_level = owner + end0 = n - 1 + for idx, level in headings: + if idx > start0 and level <= owner_level: + end0 = idx - 1 + break + + # Trailing blank lines belong to the separation before the next section, + # not to this one: keeping them in the range would let an accepted + # suggestion swallow the blank line above the next heading. + while end0 > start0 and not lines[end0].strip(): + end0 -= 1 + + body = "\n".join(lines[start0 : end0 + 1]) + if len(body) > TARGET_CHUNK_CHARS: + narrowed = _paragraph_at(lines, start0, end0, cursor0) + if narrowed is not None: + start0, end0 = narrowed + + return ActiveSection(start_line=start0 + 1, end_line=end0 + 1) + + +def slice_lines( + content_md: str, start_line: int, end_line: int +) -> tuple[str, str, str]: + """(prefix, section, suffix) split at the 1-based inclusive line range. + + The section is the lines the model refines; prefix/suffix are the rest of + the document, handed to the model as context it must not re-emit. + """ + lines = content_md.splitlines() + prefix = "\n".join(lines[: start_line - 1]) + section = "\n".join(lines[start_line - 1 : end_line]) + suffix = "\n".join(lines[end_line:]) + return prefix, section, suffix diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..6d8513d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,64 @@ +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Repo-root .env for native dev; inside Docker the file is absent and +# configuration comes from real environment variables (which take precedence). +_REPO_ROOT = Path(__file__).resolve().parents[2] +_ENV_FILE = _REPO_ROOT / ".env" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="PABLAN_", env_file=_ENV_FILE, extra="ignore" + ) + + env: Literal["development", "production"] = "development" + database_url: str = "postgresql+asyncpg://pablan:change-me@localhost:5432/pablan" + + # Secure=false is needed for dev over plain http on non-localhost + # addresses (WireGuard IPs) — see .env.example. + cookie_secure: bool = True + auth_session_ttl_days: int = 14 + + query_retention_days: int = 90 + + # The shipped template catalog (repo templates/ in dev; the customer + # stack mounts the directory and overrides this path). + templates_dir: str = str(_REPO_ROOT / "templates") + help_dir: str = str(_REPO_ROOT / "help") + + # The instance's own language: which blueprint variant the first-install + # starter set uses, and the fallback when a visitor states no preference. + # Per-user choice lives on users.locale and wins over this. + default_locale: Literal["de", "en"] = "de" + + log_level: str = "INFO" + # Content debug logging (prompts/responses) — NEVER in production. + debug_log_prompts: bool = False + llm_timeout_seconds: float = 120.0 + # How many requests Pablan lets one endpoint see at once, and how long a + # request waits for a free slot before it is answered with "busy". Match + # llm_max_parallel to the server's parallel slots (llama.cpp: --parallel). + # See app/llm/gate.py. + llm_max_parallel: int = 4 + llm_queue_wait_seconds: float = 20.0 + llm_max_queued: int = 24 + job_poll_seconds: float = 1.0 + + chat_base_url: str = "http://localhost:8001/v1" + chat_api_key: str = "none" + chat_model: str = "" + utility_base_url: str = "http://localhost:8001/v1" + utility_api_key: str = "none" + utility_model: str = "" + embedding_base_url: str = "http://localhost:8002/v1" + embedding_api_key: str = "none" + embedding_model: str = "" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..adbbcfd --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,17 @@ +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.config import get_settings + +engine = create_async_engine(get_settings().database_url) +async_session_factory = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db() -> AsyncIterator[AsyncSession]: + async with async_session_factory() as session: + yield session diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..6bcc344 --- /dev/null +++ b/backend/app/errors.py @@ -0,0 +1,20 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + + +class ApiError(Exception): + """API error rendered as the protocol's {detail, code} problem shape.""" + + def __init__(self, status_code: int, detail: str, code: str) -> None: + self.status_code = status_code + self.detail = detail + self.code = code + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(ApiError) + async def handle_api_error(request: Request, exc: ApiError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail, "code": exc.code}, + ) diff --git a/backend/app/help_import.py b/backend/app/help_import.py new file mode 100644 index 0000000..52b15c6 --- /dev/null +++ b/backend/app/help_import.py @@ -0,0 +1,97 @@ +"""Built-in help documents: Markdown files → documents table. + +The help pages that describe Pablan itself ship with the product and live in +the repo-level help/ directory (product content, not code). They are +re-imported on every start, so a release always carries the current +documentation, and they are flagged `is_builtin` so the API refuses to edit +or delete them. +""" + +import logging +from pathlib import Path + +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.ingestion.handlers import INDEX_DOCUMENT +from app.ingestion.queue import enqueue +from app.models import ( + Document, + DocumentStatus, + DocumentVisibility, +) + +logger = logging.getLogger("pablan.help") + +FRONTMATTER_SEPARATOR = "---" +META_KEY = "help_key" + + +class HelpImportError(Exception): + """Malformed help file — a packaging bug, never user input.""" + + +def parse_help_document(source: str) -> tuple[str, str, str]: + """Split the `key`/`title` frontmatter from the Markdown body.""" + if not source.startswith(FRONTMATTER_SEPARATOR): + raise HelpImportError("Help document must start with YAML frontmatter.") + _, frontmatter, body = source.split(FRONTMATTER_SEPARATOR, 2) + try: + meta = yaml.safe_load(frontmatter) + except yaml.YAMLError as exc: + raise HelpImportError(f"Invalid frontmatter: {type(exc).__name__}") from None + if not isinstance(meta, dict) or not meta.get("key") or not meta.get("title"): + raise HelpImportError("Help frontmatter needs at least 'key' and 'title'.") + return str(meta["key"]), str(meta["title"]), body.strip() + + +async def import_help_documents(db: AsyncSession) -> int: + """Upsert every help/*.md by its key. Returns the number re-indexed.""" + directory = Path(get_settings().help_dir) + if not directory.is_dir(): + logger.warning("help directory missing", extra={"event": "help_import_skipped"}) + return 0 + + reindexed = 0 + for path in sorted(directory.glob("*.md")): + key, title, body = parse_help_document(path.read_text()) + existing = ( + await db.execute( + select(Document).where( + Document.is_builtin.is_(True), + Document.meta[META_KEY].astext == key, + ) + ) + ).scalar_one_or_none() + + if existing is None: + document = Document( + title=title, + status=DocumentStatus.published, + # Help is for everyone; it has no author and no department. + visibility=DocumentVisibility.public, + content_md=body, + meta={META_KEY: key}, + is_builtin=True, + ) + db.add(document) + await db.flush() + elif existing.content_md == body and existing.title == title: + continue # unchanged — no need to re-embed + else: + existing.title = title + existing.content_md = body + existing.meta = {**existing.meta, META_KEY: key} + document = existing + + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) + reindexed += 1 + + await db.commit() + logger.info( + "help documents imported", + extra={"event": "help_import", "reindexed": reindexed}, + ) + return reindexed diff --git a/backend/app/ingestion/__init__.py b/backend/app/ingestion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ingestion/handlers.py b/backend/app/ingestion/handlers.py new file mode 100644 index 0000000..2eb6aa2 --- /dev/null +++ b/backend/app/ingestion/handlers.py @@ -0,0 +1,115 @@ +"""Job handlers. Importing this module registers them with the queue.""" + +import logging +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.ingestion.queue import enqueue, job_handler +from app.models import ( + AuthSession, + Conversation, + ConversationMode, + Document, + DocumentStatus, + Job, + JobStatus, +) +from app.rag.indexing import reindex_document, remove_chunks + +logger = logging.getLogger("pablan.queue") + +RETENTION_CLEANUP = "retention_cleanup" +INDEX_DOCUMENT = "index_document" +REINDEX_ALL = "reindex_all" + + +@job_handler(INDEX_DOCUMENT) +async def index_document(db: AsyncSession, job: Job) -> None: + """(Re)build the chunks of one document; drop them if it is not published.""" + document_id = uuid.UUID(job.payload["document_id"]) + document = await db.get(Document, document_id) + if document is None: + logger.info( + "index skipped, document gone", + extra={"event": "index_skipped", "document_id": str(document_id)}, + ) + return + if document.status == DocumentStatus.published: + await reindex_document(db, document) + else: + await remove_chunks(db, document.id) + + +@job_handler(REINDEX_ALL) +async def reindex_all(db: AsyncSession, job: Job) -> None: + """Fan out one index_document job per published document. + + Never embeds the corpus in this handler itself — the queue holds the + claim transaction open for the whole handler run. + """ + document_ids = ( + ( + await db.execute( + select(Document.id).where(Document.status == DocumentStatus.published) + ) + ) + .scalars() + .all() + ) + for document_id in document_ids: + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document_id)}) + logger.info( + "reindex fan-out", + extra={"event": "reindex_all", "document_count": len(document_ids)}, + ) + + +@job_handler(RETENTION_CLEANUP) +async def retention_cleanup(db: AsyncSession, job: Job) -> None: + """GDPR retention: drop old query conversations and expired auth sessions. + + Messages go with their conversation via ON DELETE CASCADE. Reschedules + itself daily. + """ + now = datetime.now(UTC) + cutoff = now - timedelta(days=get_settings().query_retention_days) + + conversations_deleted = ( + await db.execute( + delete(Conversation).where( + Conversation.mode == ConversationMode.query, + Conversation.updated_at < cutoff, + ) + ) + ).rowcount + sessions_deleted = ( + await db.execute(delete(AuthSession).where(AuthSession.expires_at < now)) + ).rowcount + + await enqueue(db, RETENTION_CLEANUP, run_after=now + timedelta(days=1)) + logger.info( + "retention cleanup", + extra={ + "event": "retention_cleanup", + "conversations_deleted": conversations_deleted, + "auth_sessions_deleted": sessions_deleted, + }, + ) + + +async def ensure_retention_scheduled(db: AsyncSession) -> None: + """Idempotent startup bootstrap: exactly one pending retention job.""" + existing = ( + await db.execute( + select(Job.id).where( + Job.type == RETENTION_CLEANUP, Job.status == JobStatus.pending + ) + ) + ).first() + if existing is None: + await enqueue(db, RETENTION_CLEANUP) + await db.commit() diff --git a/backend/app/ingestion/queue.py b/backend/app/ingestion/queue.py new file mode 100644 index 0000000..840dfdc --- /dev/null +++ b/backend/app/ingestion/queue.py @@ -0,0 +1,179 @@ +"""Postgres-backed background queue. + +One asyncio loop in the app lifespan claims jobs via +SELECT … FOR UPDATE SKIP LOCKED. The claim transaction stays open while the +handler runs: a crash rolls everything back and the job remains pending and +claimable after restart — handler writes are atomic with job completion. +Failure bookkeeping (attempts, backoff, last_error) happens in a follow-up +transaction. Single worker per process; the loop moves into a worker +container unchanged when scale demands it (Variant B). +""" + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import get_settings +from app.db import async_session_factory +from app.log import safe_error +from app.metrics import metrics +from app.models import Job, JobStatus + +logger = logging.getLogger("pablan.queue") + +JobHandler = Callable[[AsyncSession, Job], Awaitable[None]] + +_HANDLERS: dict[str, JobHandler] = {} + +MAX_ATTEMPTS = 5 +BACKOFF_BASE_SECONDS = 30.0 # 30s, 1m, 2m, 4m between retries + + +def job_handler(job_type: str) -> Callable[[JobHandler], JobHandler]: + def register(fn: JobHandler) -> JobHandler: + _HANDLERS[job_type] = fn + return fn + + return register + + +def backoff_delay(attempts: int) -> timedelta: + return timedelta(seconds=BACKOFF_BASE_SECONDS * 2 ** (attempts - 1)) + + +async def enqueue( + db: AsyncSession, + job_type: str, + payload: dict[str, Any] | None = None, + run_after: datetime | None = None, +) -> Job: + job = Job(type=job_type, payload=payload or {}) + if run_after is not None: + job.run_after = run_after + db.add(job) + await db.flush() + return job + + +async def process_one( + session_factory: async_sessionmaker[AsyncSession] = async_session_factory, +) -> bool: + """Claim and process a single due job. Returns True if one was processed.""" + started = asyncio.get_running_loop().time() + async with session_factory() as db: + job = ( + await db.execute( + select(Job) + .where(Job.status == JobStatus.pending, Job.run_after <= func.now()) + .order_by(Job.run_after) + .limit(1) + .with_for_update(skip_locked=True) + ) + ).scalar_one_or_none() + if job is None: + await db.rollback() + return False + + job_id, job_type, attempts_before = job.id, job.type, job.attempts + try: + handler = _HANDLERS.get(job_type) + if handler is None: + raise LookupError(f"no handler registered for job type {job_type!r}") + await handler(db, job) + job.status = JobStatus.done + job.attempts = attempts_before + 1 + await db.commit() + except Exception as exc: + await db.rollback() + await _record_failure(session_factory, job_id, exc) + duration = asyncio.get_running_loop().time() - started + metrics.inc("jobs_processed_total", {"type": job_type, "status": "failed"}) + metrics.observe("job_seconds", duration, {"type": job_type}) + logger.warning( + "job failed", + extra={ + "event": "job_failed", + "job_id": str(job_id), + "job_type": job_type, + "attempt": attempts_before + 1, + "error": safe_error(exc), + }, + ) + return True + + duration = asyncio.get_running_loop().time() - started + metrics.inc("jobs_processed_total", {"type": job_type, "status": "done"}) + metrics.observe("job_seconds", duration, {"type": job_type}) + logger.info( + "job done", + extra={ + "event": "job_done", + "job_id": str(job_id), + "job_type": job_type, + "attempt": attempts_before + 1, + "duration_ms": round(duration * 1000), + }, + ) + return True + + +async def _record_failure( + session_factory: async_sessionmaker[AsyncSession], + job_id: Any, + exc: Exception, +) -> None: + async with session_factory() as db: + job = await db.get(Job, job_id, with_for_update=True) + if job is None: # pragma: no cover — job deleted underneath us + return + job.attempts += 1 + job.last_error = safe_error(exc, limit=500) + if job.attempts >= MAX_ATTEMPTS: + job.status = JobStatus.failed + metrics.inc("jobs_exhausted_total", {"type": job.type}) + else: + job.status = JobStatus.pending + job.run_after = datetime.now(UTC) + backoff_delay(job.attempts) + metrics.inc("jobs_retried_total", {"type": job.type}) + await db.commit() + + +async def _update_depth_gauge( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as db: + depth = ( + await db.execute( + select(func.count(Job.id)).where(Job.status == JobStatus.pending) + ) + ).scalar_one() + metrics.set_gauge("jobs_queue_depth", float(depth)) + + +async def run_queue( + stop_event: asyncio.Event, + session_factory: async_sessionmaker[AsyncSession] = async_session_factory, +) -> None: + poll_seconds = get_settings().job_poll_seconds + logger.info("job queue started", extra={"event": "queue_started"}) + while not stop_event.is_set(): + worked = False + try: + worked = await process_one(session_factory) + await _update_depth_gauge(session_factory) + except Exception as exc: + logger.error( + "queue iteration failed", + extra={"event": "queue_error", "error": safe_error(exc)}, + ) + if not worked: + try: + await asyncio.wait_for(stop_event.wait(), timeout=poll_seconds) + except TimeoutError: + pass + logger.info("job queue stopped", extra={"event": "queue_stopped"}) diff --git a/backend/app/llm/__init__.py b/backend/app/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py new file mode 100644 index 0000000..3b19824 --- /dev/null +++ b/backend/app/llm/client.py @@ -0,0 +1,347 @@ +"""The ONLY code that talks to LLM endpoints. + +Exactly three functions: chat_stream, chat_json, embed. Three model roles +(chat / utility / embedding), each base_url + api_key + model from settings. +The openai SDK is used purely as a client for OpenAI-compatible endpoints +(llama.cpp locally, cloud APIs in production). + +Logging policy: metadata only — prompts and responses are logged ONLY at +DEBUG level behind PABLAN_DEBUG_LOG_PROMPTS=true (never in production). +LLMError messages are sanitized and never contain content. +""" + +import logging +import time +from collections.abc import AsyncIterator +from functools import lru_cache +from typing import Any, Literal, TypeVar + +import httpx +from openai import AsyncOpenAI +from pydantic import BaseModel, ValidationError + +from app.config import get_settings +from app.llm.errors import LLMError, llm_error +from app.llm.gate import slot +from app.llm.overrides import env_defaults, get_config +from app.metrics import metrics + +logger = logging.getLogger("pablan.llm") + +Role = Literal["chat", "utility", "embedding"] +ChatMessage = dict[str, str] +T = TypeVar("T", bound=BaseModel) + +# Turn off a reasoning model's hidden thinking. Latency-critical calls (a +# refinement fires on a typing pause) want the answer, not the deliberation: +# ~1s instead of ~10s with no quality loss on mechanical rewrites. Endpoints +# and templates that do not know the parameter ignore it. +NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}} + +_RETRY_INSTRUCTION = ( + "Your previous reply did not match the required JSON schema. " + "Reply again with ONLY valid JSON matching the schema — no prose." +) + + +def _http_client_factory() -> httpx.AsyncClient | None: + """Tests override this to inject an ASGI transport.""" + return None + + +def role_config(role: Role) -> tuple[str, str, str]: + """Effective endpoint config: the DB row, seeded from `.env` at first + start (see app/llm/overrides.py — "bootstrap, then DB"). + + The `or env` fallbacks are a safety net, not the model: they cover the + window before `load_config()` has run (early startup, tests that never + touch the table) and a field an admin blanked. In a bootstrapped + instance the stored value always wins. + """ + stored = get_config(role) + env = env_defaults(role) + return ( + stored.base_url or env.base_url or "", + stored.api_key or env.api_key or "", + stored.model or env.model or "", + ) + + +def _build_client(base_url: str, api_key: str) -> AsyncOpenAI: + kwargs: dict[str, Any] = { + "base_url": base_url, + "api_key": api_key, + "timeout": get_settings().llm_timeout_seconds, + # One SDK retry: llama.cpp closes idle keep-alive connections, and + # the first call on a stale connection fails with APIConnectionError. + # (SDK-internal retries are not separately metered.) + "max_retries": 1, + } + http_client = _http_client_factory() + if http_client is not None: + kwargs["http_client"] = http_client + return AsyncOpenAI(**kwargs) + + +@lru_cache(maxsize=None) +def _client_for(role: Role) -> AsyncOpenAI: + base_url, api_key, _ = role_config(role) + return _build_client(base_url, api_key) + + +def rebuild_clients() -> None: + """Apply changed endpoint config without a restart: the cached clients + hold the old base_url and key, so they must go.""" + _client_for.cache_clear() + + +async def probe( + role: Role, + *, + base_url: str | None = None, + api_key: str | None = None, + model: str | None = None, +) -> None: + """Smallest possible call against a candidate config, so an admin can + test an endpoint before saving it. Raises LLMError on failure.""" + effective_url, effective_key, effective_model = role_config(role) + client = _build_client(base_url or effective_url, api_key or effective_key) + target = model or effective_model + started = time.monotonic() + try: + if role == "embedding": + await client.embeddings.create(model=target, input=["ping"]) + else: + stream = await client.chat.completions.create( + model=target, + messages=[{"role": "user", "content": "ping"}], + max_tokens=1, + stream=True, + ) + async for _ in stream: + break + except Exception as exc: + raise llm_error("probe", role, exc, started) from None + + +async def list_models( + role: Role, + *, + base_url: str | None = None, + api_key: str | None = None, +) -> list[str]: + """Ask an endpoint what it serves (`GET /v1/models`). + + Server-side on purpose: the credentials must never leave the backend, + and the browser has no business talking to the model endpoint at all. + + Not every OpenAI-compatible server implements the route, so a failure + here is ordinary rather than exceptional — the caller degrades to a + free-text model field. Raises LLMError so the caller can distinguish + "no such route" from "wrong credentials". + """ + effective_url, effective_key, _ = role_config(role) + client = _build_client(base_url or effective_url, api_key or effective_key) + started = time.monotonic() + try: + page = await client.models.list() + except Exception as exc: + raise llm_error("list_models", role, exc, started) from None + # Ids only, sorted for a stable dropdown. Model ids are configuration, + # not content, so they may be returned and logged by count. + return sorted({model.id for model in page.data if getattr(model, "id", None)}) + + +def _record( + role: Role, + kind: str, + status: str, + started: float, + usage: Any = None, + **extra_fields: Any, +) -> None: + duration = time.monotonic() - started + metrics.inc("llm_calls_total", {"role": role, "kind": kind, "status": status}) + metrics.observe("llm_call_seconds", duration, {"role": role, "kind": kind}) + extra: dict[str, Any] = { + "event": "llm_call", + "role": role, + "kind": kind, + "status": status, + "duration_ms": round(duration * 1000), + **extra_fields, + } + if usage is not None: + prompt_tokens = getattr(usage, "prompt_tokens", None) + completion_tokens = getattr(usage, "completion_tokens", None) + if prompt_tokens: + metrics.inc( + "llm_tokens_total", {"role": role, "direction": "prompt"}, prompt_tokens + ) + extra["prompt_tokens"] = prompt_tokens + if completion_tokens: + metrics.inc( + "llm_tokens_total", + {"role": role, "direction": "completion"}, + completion_tokens, + ) + extra["completion_tokens"] = completion_tokens + logger.info("llm call", extra=extra) + + +def _debug_log_content(label: str, content: Any) -> None: + if get_settings().debug_log_prompts: + logger.debug("llm content", extra={"label": label, "content": content}) + + +async def chat_stream( + messages: list[ChatMessage], + *, + role: Role = "chat", + temperature: float | None = None, + max_tokens: int | None = None, + extra_body: dict[str, Any] | None = None, +) -> AsyncIterator[str]: + """Stream a chat completion as text deltas. + + `extra_body` is passed through to the endpoint verbatim — used to reach + non-standard OpenAI-compatible parameters such as + `{"chat_template_kwargs": {"enable_thinking": False}}`, which turns off a + reasoning model's hidden thinking for latency-critical calls. Only + `delta.content` is ever yielded, so a reasoning channel never leaks into + the output regardless. + """ + base_url, _, model = role_config(role) + _debug_log_content("chat_stream.messages", messages) + options: dict[str, Any] = {} + if temperature is not None: + options["temperature"] = temperature + if max_tokens is not None: + options["max_tokens"] = max_tokens + if extra_body is not None: + options["extra_body"] = extra_body + + started = time.monotonic() + status = "ok" + usage = None + try: + # The slot is held until the last token: a streaming completion + # occupies its server slot for its whole life (app/llm/gate.py). + async with slot(base_url, role): + stream = await _client_for(role).chat.completions.create( + model=model, + messages=messages, # type: ignore[arg-type] + stream=True, + stream_options={"include_usage": True}, + **options, + ) + async for chunk in stream: + if chunk.usage is not None: + usage = chunk.usage + if chunk.choices and chunk.choices[0].delta.content: + yield chunk.choices[0].delta.content + except GeneratorExit: + status = "aborted" + raise + except LLMError: + status = "error" + raise + except Exception as exc: + status = "error" + raise llm_error("chat_stream", role, exc, started) from None + finally: + _record(role, "chat_stream", status, started, usage) + + +async def chat_json( + messages: list[ChatMessage], + schema: type[T], + *, + role: Role = "utility", + temperature: float = 0.0, + max_tokens: int | None = None, + extra_body: dict[str, Any] | None = None, +) -> T: + """Structured output: response_format JSON schema + validation + one retry. + + `extra_body` is passed through verbatim (e.g. + `{"chat_template_kwargs": {"enable_thinking": False}}` to skip a reasoning + model's hidden thinking on latency-sensitive utility calls).""" + base_url, _, model = role_config(role) + response_format = { + "type": "json_schema", + "json_schema": { + "name": schema.__name__, + "schema": schema.model_json_schema(), + "strict": True, + }, + } + options: dict[str, Any] = {"temperature": temperature} + if max_tokens is not None: + options["max_tokens"] = max_tokens + if extra_body is not None: + options["extra_body"] = extra_body + + attempt_messages = list(messages) + for attempt in (1, 2): + _debug_log_content("chat_json.messages", attempt_messages) + started = time.monotonic() + usage = None + try: + async with slot(base_url, role): + response = await _client_for(role).chat.completions.create( + model=model, + messages=attempt_messages, # type: ignore[arg-type] + response_format=response_format, # type: ignore[arg-type] + **options, + ) + usage = response.usage + content = response.choices[0].message.content or "" + result = schema.model_validate_json(content) + _record(role, "chat_json", "ok", started, usage, attempt=attempt) + return result + except ValidationError: + _record(role, "chat_json", "invalid", started, usage, attempt=attempt) + _debug_log_content("chat_json.invalid_response", content) + attempt_messages = [ + *attempt_messages, + {"role": "assistant", "content": content}, + {"role": "user", "content": _RETRY_INSTRUCTION}, + ] + except LLMError: + _record(role, "chat_json", "error", started, usage, attempt=attempt) + raise + except Exception as exc: + _record(role, "chat_json", "error", started, usage, attempt=attempt) + raise llm_error("chat_json", role, exc, started, attempt=attempt) from None + + raise LLMError( + f"chat_json failed (role={role}): response did not match schema " + f"{schema.__name__} after retry", + role=role, + kind="chat_json", + status="invalid", + cause_type="ValidationError", + duration_ms=round((time.monotonic() - started) * 1000), + attempt=2, + ) + + +async def embed(texts: list[str], *, role: Role = "embedding") -> list[list[float]]: + """Embed a batch of texts; order of results matches the input order.""" + base_url, _, model = role_config(role) + started = time.monotonic() + try: + async with slot(base_url, role): + response = await _client_for(role).embeddings.create( + model=model, input=texts + ) + except LLMError: + _record(role, "embed", "error", started, batch_size=len(texts)) + raise + except Exception as exc: + _record(role, "embed", "error", started, batch_size=len(texts)) + raise llm_error("embed", role, exc, started) from None + _record(role, "embed", "ok", started, response.usage, batch_size=len(texts)) + ordered = sorted(response.data, key=lambda item: item.index) + return [item.embedding for item in ordered] diff --git a/backend/app/llm/errors.py b/backend/app/llm/errors.py new file mode 100644 index 0000000..3c466ff --- /dev/null +++ b/backend/app/llm/errors.py @@ -0,0 +1,101 @@ +"""What it means when an endpoint does not answer. + +Separate from `client.py` because eight modules catch this and none of them +talk to an endpoint: routers, modes and the authoring code only need to know +what went wrong and how to say it. The client itself stays the one place that +CALLS an endpoint. + +Nothing here ever carries content — not the prompt, not the reply, not the +original exception's message. A failure is described by class name, status +code and duration, which is everything a log line may hold (rule 12). +""" + +import time + +# Exception class names that mean "nothing answered at the other end" versus +# "the other end is there but not ready for us". The SDK wraps both, so the +# class name is all we have: APITimeoutError subclasses APIConnectionError, +# which is why timeouts are matched first. +_TIMEOUT_CAUSES = frozenset( + {"APITimeoutError", "ReadTimeout", "PoolTimeout", "TimeoutError"} +) +_CONNECTION_CAUSES = frozenset( + {"APIConnectionError", "ConnectError", "ConnectTimeout", "RemoteProtocolError"} +) +# Server said "come back later" (rate limit, no free slot, model still loading). +_BUSY_STATUS = frozenset({408, 429, 503, 504}) +# Server said "not with these credentials / not this model". +_SETUP_STATUS = frozenset({401, 403, 404}) + + +class LLMError(Exception): + """Sanitized LLM failure: structured metadata for debugging — + never content, never original exception messages. + + Fields: role, kind, status ("error" | "invalid"), cause_type (original + exception CLASS NAME only), status_code (HTTP, if any), duration_ms, + attempt (chat_json: 1 or 2). + """ + + def __init__( + self, + message: str, + *, + role: str, + kind: str, + status: str, + cause_type: str | None = None, + status_code: int | None = None, + duration_ms: int | None = None, + attempt: int | None = None, + ) -> None: + super().__init__(message) + self.role = role + self.kind = kind + self.status = status + self.cause_type = cause_type + self.status_code = status_code + self.duration_ms = duration_ms + self.attempt = attempt + + @property + def code(self) -> str: + """The API error code for this failure — the ONE place an endpoint + failure is classified, so every caller reports the same reason and the + frontend can phrase it (`docs/api-protocol.md`). + + `llm_busy` and `llm_unreachable` are worth telling apart: the first is + worth retrying in a moment, the second needs someone to start the + endpoint. + """ + if self.status_code in _BUSY_STATUS: + return "llm_busy" + if self.status_code in _SETUP_STATUS: + return "llm_misconfigured" + if self.cause_type in _TIMEOUT_CAUSES: + return "llm_busy" + if self.cause_type in _CONNECTION_CAUSES: + return "llm_unreachable" + return "llm_failed" + + +def llm_error( + kind: str, + role: str, + exc: Exception, + started: float, + *, + attempt: int | None = None, +) -> LLMError: + """Wrap whatever the SDK raised, keeping only what may be logged.""" + status_code = getattr(exc, "status_code", None) + return LLMError( + f"{kind} failed (role={role}): {type(exc).__name__}", + role=role, + kind=kind, + status="error", + cause_type=type(exc).__name__, + status_code=status_code if isinstance(status_code, int) else None, + duration_ms=round((time.monotonic() - started) * 1000), + attempt=attempt, + ) diff --git a/backend/app/llm/gate.py b/backend/app/llm/gate.py new file mode 100644 index 0000000..ae37ac4 --- /dev/null +++ b/backend/app/llm/gate.py @@ -0,0 +1,150 @@ +"""How many requests Pablan lets an endpoint see at once. + +A self-hosted llama.cpp server has a fixed number of parallel slots. Sending +more than that does not make it faster: the extra requests sit in the server's +own queue where Pablan can neither see nor bound them, and every one of them +counts against the HTTP timeout. Two colleagues chatting while a reindex runs +is enough to turn a working instance into one where everything times out at +once. + +So the waiting happens here instead, in front of the endpoint: + +- **One gate per endpoint, not per role.** chat and utility usually point at + the same server (they do in the shipped `.env`), and it is the SERVER that + has the slots. Keying by base_url is what makes the limit real. +- **A bounded wait.** A caller waits at most `llm_queue_wait_seconds` for a + slot and then fails as `llm_busy` — a fast, honest "try again" instead of a + two-minute timeout that looks like a broken endpoint. +- **A bounded queue.** Past `llm_max_queued` waiters the gate stops admitting: + when far more work has arrived than the endpoint can absorb, the useful + answer is "busy", given immediately, to everyone beyond the line. + +`llm_busy` is already the vocabulary for this (`llm/errors.py`), and the +frontend phrases it as "the model is busy" — so a queue rejection reaches the +user as the same, correct sentence as a 429 from a cloud provider. + +Admin diagnostics (`probe`, `list_models`) deliberately do NOT pass through +the gate: they are single tiny calls, and an admin has to be able to test an +endpoint precisely when it is saturated. +""" + +import asyncio +import logging +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from app.config import get_settings +from app.llm.errors import LLMError +from app.metrics import metrics + +logger = logging.getLogger("pablan.llm") + + +class _Endpoint: + """The live picture of one endpoint: who is in it, who is waiting.""" + + def __init__(self, limit: int) -> None: + self.limit = limit + self.semaphore = asyncio.Semaphore(limit) + self.in_flight = 0 + self.waiting = 0 + + @property + def saturated(self) -> bool: + return self.in_flight >= self.limit + + +_endpoints: dict[str, _Endpoint] = {} + + +def _busy_error(role: str, reason: str, waited: float) -> LLMError: + """A queue rejection, in the same shape as an endpoint's own 503 — the + caller classifies it through `LLMError.code` like any other failure.""" + metrics.inc("llm_queue_rejected_total", {"role": role, "reason": reason}) + logger.info( + "llm request not admitted", + extra={ + "event": "llm_queue_rejected", + "role": role, + "reason": reason, + "waited_ms": round(waited * 1000), + }, + ) + return LLMError( + f"endpoint busy (role={role}): {reason}", + role=role, + kind="queue", + status="error", + # 503 is what a saturated endpoint says itself, and what maps to + # `llm_busy`. Keeping the queue's own rejection in that vocabulary + # means one reason reaches the user, not two. + status_code=503, + ) + + +def _endpoint_for(base_url: str) -> _Endpoint: + limit = max(1, get_settings().llm_max_parallel) + endpoint = _endpoints.get(base_url) + if endpoint is None or endpoint.limit != limit: + # A changed limit (settings reloaded in a test) rebuilds the gate. + # In-flight callers hold the old semaphore and still release it. + endpoint = _Endpoint(limit) + _endpoints[base_url] = endpoint + return endpoint + + +def endpoint_busy(base_url: str) -> bool: + """Is every slot on this endpoint taken right now? + + Read by the query mode so a waiting turn can SAY it is waiting instead of + showing a frozen cursor. Advisory: by the time the caller acquires, a slot + may well have freed. + """ + endpoint = _endpoints.get(base_url) + return endpoint is not None and endpoint.saturated + + +@asynccontextmanager +async def slot(base_url: str, role: str) -> AsyncIterator[None]: + """Hold one of the endpoint's slots for the whole call. + + For a stream that means until the last token: a streaming completion + occupies its server slot until it ends, and releasing early would let the + gate admit work the endpoint has no room for. + """ + settings = get_settings() + endpoint = _endpoint_for(base_url) + + if endpoint.saturated and endpoint.waiting >= max(0, settings.llm_max_queued): + raise _busy_error(role, "queue_full", 0.0) + + started = time.monotonic() + endpoint.waiting += 1 + metrics.set_gauge("llm_queue_waiting", float(endpoint.waiting), {"role": role}) + try: + await asyncio.wait_for( + endpoint.semaphore.acquire(), timeout=settings.llm_queue_wait_seconds + ) + except TimeoutError: + raise _busy_error(role, "queue_timeout", time.monotonic() - started) from None + finally: + endpoint.waiting -= 1 + metrics.set_gauge("llm_queue_waiting", float(endpoint.waiting), {"role": role}) + + waited = time.monotonic() - started + if waited > 0.01: + metrics.observe("llm_queue_wait_seconds", waited, {"role": role}) + endpoint.in_flight += 1 + metrics.set_gauge("llm_in_flight", float(endpoint.in_flight), {"role": role}) + try: + yield + finally: + endpoint.in_flight -= 1 + metrics.set_gauge("llm_in_flight", float(endpoint.in_flight), {"role": role}) + endpoint.semaphore.release() + + +def reset() -> None: + """Drop every gate. Tests only — a live gate holds waiters.""" + _endpoints.clear() diff --git a/backend/app/llm/overrides.py b/backend/app/llm/overrides.py new file mode 100644 index 0000000..2d66351 --- /dev/null +++ b/backend/app/llm/overrides.py @@ -0,0 +1,143 @@ +"""The LLM endpoint configuration, as the process sees it. + +**Bootstrap, then DB.** On the very first start the `PABLAN_*` environment +variables are copied into `llm_settings`, one row per role. From that +moment the table is the truth: later `.env` edits are ignored, because a +configuration an admin can change in the UI and a configuration the +deployment can change underneath them cannot both be authoritative. The +environment stays reachable as the value a field can be *reset* to, which +is what `env_defaults()` is for. + +The configuration lives in a module-level cache because `_role_config` is a +hot, synchronous function on every LLM call — it cannot await a query. The +cache is filled at startup and refreshed whenever an admin writes, which is +also when the OpenAI clients are rebuilt. + +Single-process by design: the customer stack pins `--workers 1` (see +architecture.md), so there is exactly one cache to refresh. A multi-worker +deployment would need a notification channel instead. +""" + +import logging +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models import LLMSetting + +logger = logging.getLogger("pablan.llm") + +ROLES = ("chat", "utility", "embedding") + + +@dataclass(frozen=True) +class RoleConfig: + """One role's stored configuration. A None field means the column is + empty, which after bootstrap only happens if an admin blanked it.""" + + base_url: str | None = None + model: str | None = None + api_key: str | None = None + + +_config: dict[str, RoleConfig] = {} + + +def env_defaults(role: str) -> RoleConfig: + """What `.env` says for this role — the value "reset to .env" restores. + + Read live rather than remembered from bootstrap: an admin who fixes a + typo in `.env` and resets the field should get the corrected value, not + the one that was wrong at install time. + """ + settings = get_settings() + base_url, api_key, model = { + "chat": (settings.chat_base_url, settings.chat_api_key, settings.chat_model), + "utility": ( + settings.utility_base_url, + settings.utility_api_key, + settings.utility_model, + ), + "embedding": ( + settings.embedding_base_url, + settings.embedding_api_key, + settings.embedding_model, + ), + }[role] + return RoleConfig(base_url=base_url, model=model, api_key=api_key) + + +_FIELDS = ("base_url", "model", "api_key") + + +async def bootstrap_llm_settings(db: AsyncSession) -> int: + """Copy the environment into any field that still defers to it. + + Runs at every startup, but only ever fills blanks: a field is written + exactly when it is flagged `*_from_env` AND currently empty. That is + true for a fresh install (no rows yet) and for a field an upgrade + marked as still belonging to `.env`, and false for anything an admin + has typed, which is never touched. + + Returns the number of fields written. + """ + rows = {row.role: row for row in (await db.execute(select(LLMSetting))).scalars()} + written = 0 + for role in ROLES: + row = rows.get(role) + if row is None: + # Flags set explicitly rather than left to the column defaults: + # those only materialise on flush, and the loop below reads them + # before that. + row = LLMSetting( + role=role, + base_url_from_env=True, + model_from_env=True, + api_key_from_env=True, + ) + db.add(row) + defaults = env_defaults(role) + for field in _FIELDS: + if not getattr(row, f"{field}_from_env") or getattr(row, field): + continue + setattr(row, field, getattr(defaults, field) or None) + written += 1 + if written: + await db.commit() + # Counts and roles only — never the values, one of which is a key. + # NB: not `created` — logging reserves that name on + # LogRecord and raises KeyError when an `extra` key collides with it. + logger.info( + "llm settings bootstrapped", + extra={"event": "llm_bootstrap", "fields_written": written}, + ) + return written + + +async def load_config(db: AsyncSession) -> None: + """Re-read every stored row. Call after any write.""" + rows = (await db.execute(select(LLMSetting))).scalars().all() + _config.clear() + _config.update( + { + row.role: RoleConfig( + base_url=row.base_url, model=row.model, api_key=row.api_key + ) + for row in rows + } + ) + logger.info( + "llm settings loaded", + extra={"event": "llm_settings_loaded", "roles": sorted(_config)}, + ) + + +def get_config(role: str) -> RoleConfig: + return _config.get(role, RoleConfig()) + + +def clear() -> None: + """Drop the cache — used by tests between cases.""" + _config.clear() diff --git a/backend/app/log.py b/backend/app/log.py new file mode 100644 index 0000000..7c92b38 --- /dev/null +++ b/backend/app/log.py @@ -0,0 +1,103 @@ +"""Structured JSON logging (stdlib only). + +Logging policy: log lines carry metadata only — never +prompts, LLM responses, user messages or document text. Exceptions are +reduced to their type plus a sanitized message; SQLAlchemy statement/param +dumps are stripped because parameters can contain user content. +""" + +import json +import logging +import sys +from contextvars import ContextVar +from datetime import UTC, datetime +from typing import Any + +from app.config import get_settings + +correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None) +conversation_id: ContextVar[str | None] = ContextVar("conversation_id", default=None) + +# LogRecord attributes that are not user-supplied extras. +_STANDARD_ATTRS = frozenset( + { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "taskName", + "thread", + "threadName", + } +) + + +def safe_error(exc: BaseException, limit: int = 300) -> str: + """Exception text that is safe to log or persist (no content leaks). + + SQLAlchemy appends "[SQL: ...] [parameters: (...)]" to its messages; + parameters can contain user content, so everything from "[SQL" on is cut. + """ + text = str(exc).split("[SQL", 1)[0].strip() + return f"{type(exc).__name__}: {text[:limit]}" + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat( + timespec="milliseconds" + ), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + cid = correlation_id.get() + if cid: + payload["correlation_id"] = cid + conv = conversation_id.get() + if conv: + payload["conversation_id"] = conv + for key, value in record.__dict__.items(): + if key not in _STANDARD_ATTRS and not key.startswith("_"): + payload[key] = value + if record.exc_info and record.exc_info[1] is not None: + payload["error"] = safe_error(record.exc_info[1]) + return json.dumps(payload, default=str) + + +# These third-party loggers dump request/response bodies at DEBUG — with +# prompts and user content in them, so they stay capped at INFO unless +# content debug logging is explicitly enabled. +_CONTENT_DEBUG_LOGGERS = ("openai", "httpx", "httpcore") + + +def apply_content_log_guard() -> None: + level = logging.DEBUG if get_settings().debug_log_prompts else logging.INFO + for name in _CONTENT_DEBUG_LOGGERS: + logging.getLogger(name).setLevel(level) + + +def setup_logging() -> None: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(get_settings().log_level.upper()) + apply_content_log_guard() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f5d367f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,59 @@ +import asyncio +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, Response + +from app.api import api_router +from app.db import async_session_factory +from app.errors import register_exception_handlers +from app.help_import import import_help_documents +from app.ingestion.handlers import ensure_retention_scheduled +from app.ingestion.queue import run_queue +from app.llm.overrides import bootstrap_llm_settings +from app.llm.overrides import load_config as load_llm_config +from app.log import correlation_id, setup_logging +from app.prompts.overrides import load_config as load_prompt_config +from app.template_catalog import seed_starter_templates + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + setup_logging() + async with async_session_factory() as db: + await ensure_retention_scheduled(db) + await seed_starter_templates(db) + await import_help_documents(db) + await bootstrap_llm_settings(db) + await load_llm_config(db) + await load_prompt_config(db) + stop_event = asyncio.Event() + queue_task = asyncio.create_task(run_queue(stop_event)) + yield + stop_event.set() + try: + await asyncio.wait_for(queue_task, timeout=10) + except TimeoutError: # pragma: no cover — a handler refused to finish + queue_task.cancel() + + +app = FastAPI(title="Pablan", version="0.1.0", lifespan=lifespan) +register_exception_handlers(app) + + +@app.middleware("http") +async def add_correlation_id( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + cid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16] + token = correlation_id.set(cid) + try: + response = await call_next(request) + finally: + correlation_id.reset(token) + response.headers["x-request-id"] = cid + return response + + +app.include_router(api_router) diff --git a/backend/app/metrics.py b/backend/app/metrics.py new file mode 100644 index 0000000..3e499ae --- /dev/null +++ b/backend/app/metrics.py @@ -0,0 +1,92 @@ +"""In-process metrics registry — no dependencies, single event loop. + +Counters, gauges and histogram summaries (count/sum/min/max), labeled. +Exposed as JSON via GET /api/admin/metrics; a Prometheus text exporter would +sit on top of this registry rather than replace it. +""" + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any + +LabelKey = tuple[tuple[str, str], ...] + + +def _key(labels: dict[str, str] | None) -> LabelKey: + return tuple(sorted((labels or {}).items())) + + +@dataclass +class HistogramData: + count: int = 0 + total: float = 0.0 + minimum: float | None = None + maximum: float | None = None + + +class MetricsRegistry: + def __init__(self) -> None: + self._counters: dict[str, dict[LabelKey, float]] = defaultdict( + lambda: defaultdict(float) + ) + self._gauges: dict[str, dict[LabelKey, float]] = defaultdict(dict) + self._histograms: dict[str, dict[LabelKey, HistogramData]] = defaultdict(dict) + + def inc( + self, name: str, labels: dict[str, str] | None = None, value: float = 1.0 + ) -> None: + self._counters[name][_key(labels)] += value + + def set_gauge( + self, name: str, value: float, labels: dict[str, str] | None = None + ) -> None: + self._gauges[name][_key(labels)] = value + + def observe( + self, name: str, value: float, labels: dict[str, str] | None = None + ) -> None: + data = self._histograms[name].setdefault(_key(labels), HistogramData()) + data.count += 1 + data.total += value + data.minimum = value if data.minimum is None else min(data.minimum, value) + data.maximum = value if data.maximum is None else max(data.maximum, value) + + def snapshot(self) -> dict[str, Any]: + return { + "counters": { + name: [ + {"labels": dict(key), "value": value} + for key, value in sorted(series.items()) + ] + for name, series in sorted(self._counters.items()) + }, + "gauges": { + name: [ + {"labels": dict(key), "value": value} + for key, value in sorted(series.items()) + ] + for name, series in sorted(self._gauges.items()) + }, + "histograms": { + name: [ + { + "labels": dict(key), + "count": data.count, + "sum": data.total, + "min": data.minimum, + "max": data.maximum, + "avg": data.total / data.count if data.count else None, + } + for key, data in sorted(series.items()) + ] + for name, series in sorted(self._histograms.items()) + }, + } + + def reset(self) -> None: + self._counters.clear() + self._gauges.clear() + self._histograms.clear() + + +metrics = MetricsRegistry() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8ffec42 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,58 @@ +from app.models.auth_session import AuthSession +from app.models.base import Base +from app.models.conversation import Conversation, Message +from app.models.department import Department +from app.models.document import ( + EMBEDDING_DIM, + Chunk, + DocPermission, + Document, + DocumentEvent, + ReviewRequest, +) +from app.models.enums import ( + AccessReason, + ConversationMode, + ConversationStatus, + DocumentEventAction, + DocumentStatus, + DocumentVisibility, + JobStatus, + MessageRole, + PermissionLevel, + UserRole, +) +from app.models.job import Job +from app.models.llm_setting import LLMSetting +from app.models.prompt_setting import PromptSetting +from app.models.template import Template +from app.models.user import User + +__all__ = [ + "EMBEDDING_DIM", + "AuthSession", + "Base", + "Chunk", + "Conversation", + "ConversationMode", + "ConversationStatus", + "Department", + "DocPermission", + "Document", + "DocumentEvent", + "ReviewRequest", + "DocumentEventAction", + "DocumentStatus", + "DocumentVisibility", + "Job", + "LLMSetting", + "JobStatus", + "Message", + "MessageRole", + "AccessReason", + "PermissionLevel", + "PromptSetting", + "Template", + "User", + "UserRole", +] diff --git a/backend/app/models/auth_session.py b/backend/app/models/auth_session.py new file mode 100644 index 0000000..a034ae9 --- /dev/null +++ b/backend/app/models/auth_session.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.user import User + + +class AuthSession(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """Server-side login session; the id doubles as the cookie token.""" + + __tablename__ = "auth_sessions" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(lazy="joined") diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..9a78b64 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + # Fetch server-generated defaults (created_at/updated_at) via RETURNING + # at flush time — otherwise the async session would need a lazy refresh + # on attribute access, which raises MissingGreenlet outside a greenlet. + __mapper_args__ = {"eager_defaults": True} + + +class UUIDPrimaryKeyMixin: + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py new file mode 100644 index 0000000..6dd8d6e --- /dev/null +++ b/backend/app/models/conversation.py @@ -0,0 +1,56 @@ +import uuid +from typing import Any + +from sqlalchemy import Enum, ForeignKey, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.enums import ConversationMode, ConversationStatus, MessageRole + + +class Conversation(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """Chat thread. The only core mode is query (RAG Q&A); EE adds insight. + + Capture is no longer a conversation — it writes a Document directly (see + app/authoring/) — so this table holds no per-turn engine state any more. + """ + + __tablename__ = "conversations" + + mode: Mapped[ConversationMode] = mapped_column( + Enum(ConversationMode, native_enum=False, length=32) + ) + status: Mapped[ConversationStatus] = mapped_column( + Enum(ConversationStatus, native_enum=False, length=32), + default=ConversationStatus.active, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + + messages: Mapped[list["Message"]] = relationship( + back_populates="conversation", + cascade="all, delete-orphan", + order_by="Message.created_at", + ) + + +class Message(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "messages" + + conversation_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("conversations.id", ondelete="CASCADE"), index=True + ) + role: Mapped[MessageRole] = mapped_column( + Enum(MessageRole, native_enum=False, length=32) + ) + content: Mapped[str] = mapped_column(Text) + # Assistant turns snapshot their citations here ({"sources": [...]}) so + # they survive reload and re-indexing — chunks are disposable, the + # rendered citation is not. + meta: Mapped[dict[str, Any]] = mapped_column( + JSONB, default=dict, server_default="{}" + ) + + conversation: Mapped[Conversation] = relationship(back_populates="messages") diff --git a/backend/app/models/department.py b/backend/app/models/department.py new file mode 100644 index 0000000..e137b50 --- /dev/null +++ b/backend/app/models/department.py @@ -0,0 +1,10 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class Department(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "departments" + + name: Mapped[str] = mapped_column(String(200), unique=True) diff --git a/backend/app/models/document.py b/backend/app/models/document.py new file mode 100644 index 0000000..d11a5ad --- /dev/null +++ b/backend/app/models/document.py @@ -0,0 +1,199 @@ +import uuid +from datetime import datetime +from typing import Any + +from pgvector.sqlalchemy import Vector +from sqlalchemy import ( + Boolean, + Computed, + DateTime, + Enum, + ForeignKey, + Index, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.enums import ( + DocumentEventAction, + DocumentStatus, + DocumentVisibility, + PermissionLevel, +) + +# Fixed by the embedding model (bge-m3). Changing the embedding model to a +# different dimension requires a migration plus reindex_all. +EMBEDDING_DIM = 1024 + + +class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """Markdown is the source of truth; chunks are disposable derivatives.""" + + __tablename__ = "documents" + + title: Mapped[str] = mapped_column(String(500)) + status: Mapped[DocumentStatus] = mapped_column( + Enum(DocumentStatus, native_enum=False, length=32), + default=DocumentStatus.draft, + ) + visibility: Mapped[DocumentVisibility] = mapped_column( + Enum(DocumentVisibility, native_enum=False, length=32), + default=DocumentVisibility.department, + ) + content_md: Mapped[str] = mapped_column(Text) + meta: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) + # Built-in help documents: shipped with the product, re-imported from + # files on every start, and neither editable nor deletable in the UI. + is_builtin: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + # SET NULL: documents must survive their author leaving the company. + author_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL") + ) + department_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("departments.id", ondelete="SET NULL") + ) + + chunks: Mapped[list["Chunk"]] = relationship( + back_populates="document", cascade="all, delete-orphan" + ) + # Loaded with every document: whether a question is open decides who may + # edit it and how it is marked wherever it appears, so it is never a + # separate lookup a caller could forget. + reviews: Mapped[list["ReviewRequest"]] = relationship( + back_populates="document", + cascade="all, delete-orphan", + lazy="selectin", + order_by="ReviewRequest.created_at", + ) + + @property + def open_reviews(self) -> list["ReviewRequest"]: + return [review for review in self.reviews if review.resolved_at is None] + + +class Chunk(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "chunks" + __table_args__ = ( + UniqueConstraint("document_id", "chunk_index"), + Index( + "ix_chunks_embedding_hnsw", + "embedding", + postgresql_using="hnsw", + postgresql_ops={"embedding": "vector_cosine_ops"}, + ), + Index("ix_chunks_tsv", "tsv", postgresql_using="gin"), + ) + + document_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("documents.id", ondelete="CASCADE"), index=True + ) + chunk_index: Mapped[int] = mapped_column() + content: Mapped[str] = mapped_column(Text) + embedding: Mapped[list[float]] = mapped_column(Vector(EMBEDDING_DIM)) + # The heading path is part of what the chunk says: a section reading + # "Solldruck 180 bar" never repeats which machine it belongs to, so a + # keyword query naming the machine has to reach it through its path. + tsv = mapped_column( + TSVECTOR, + Computed( + "to_tsvector('german'::regconfig, " + "content || ' ' || coalesce(meta->>'heading_path', ''))", + persisted=True, + ), + ) + meta: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) + + document: Mapped[Document] = relationship(back_populates="chunks") + + +class DocumentEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """An append-only audit record: who did what to a document, and when. + + Content-bearing actions (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 record survives its actor + leaving the company, exactly like author_id on the document itself. Events + cascade with the document (DB-level ON DELETE CASCADE). + """ + + __tablename__ = "document_events" + + document_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("documents.id", ondelete="CASCADE"), index=True + ) + # Who acted. Nullable so the trail outlives the actor's account. + actor_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL") + ) + action: Mapped[DocumentEventAction] = mapped_column( + Enum(DocumentEventAction, native_enum=False, length=32) + ) + # Frozen Markdown for content-bearing events (created / edited); NULL for + # pure transitions (published / archived / a review being asked or + # answered). + content_md: Mapped[str | None] = mapped_column(Text) + title: Mapped[str | None] = mapped_column(String(500)) + # The document's visibility as of this event — cheap, so always recorded. + visibility: Mapped[DocumentVisibility | None] = mapped_column( + Enum(DocumentVisibility, native_enum=False, length=32) + ) + meta: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + + +class ReviewRequest(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """ "Please look at this" — a question about a document, addressed to a + colleague. + + Deliberately not a status. A document can be published AND have an open + question about it ("do the holiday numbers still hold?"), which is exactly + the case where readers most need to know: an open request marks the + document wherever it appears, including the sources under a chat answer. + + Resolving is the reviewer's answer. Editing the document first is normal — + being asked to review is what grants the right to edit it. + """ + + __tablename__ = "review_requests" + + document_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("documents.id", ondelete="CASCADE"), index=True + ) + # Both SET NULL: a request outlives the accounts on either side of it. + requester_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL") + ) + reviewer_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + # What exactly to look at. Optional: "please check this" is a valid ask. + question: Mapped[str | None] = mapped_column(Text) + # NULL while open. The pair (resolved_at, resolved_by) is the answer. + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + resolved_by_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL") + ) + + document: Mapped["Document"] = relationship(back_populates="reviews") + + +class DocPermission(TimestampMixin, Base): + """Additional department read grants on top of documents.visibility.""" + + __tablename__ = "doc_permissions" + + document_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True + ) + department_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("departments.id", ondelete="CASCADE"), primary_key=True + ) + level: Mapped[PermissionLevel] = mapped_column( + Enum(PermissionLevel, native_enum=False, length=32), + default=PermissionLevel.read, + ) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..77aa18a --- /dev/null +++ b/backend/app/models/enums.py @@ -0,0 +1,87 @@ +from enum import StrEnum + + +class UserRole(StrEnum): + member = "member" + admin = "admin" + + +class ConversationMode(StrEnum): + query = "query" + insight = "insight" # EE insights mode registers itself + + +class ConversationStatus(StrEnum): + active = "active" + completed = "completed" + abandoned = "abandoned" + + +class MessageRole(StrEnum): + user = "user" + assistant = "assistant" + system = "system" + + +class DocumentStatus(StrEnum): + """Where a document stands. + + Three states, because publishing is the author's own decision: a draft is + private, a published document is visible and indexed, an archived one is + neither. Uncertainty about CONTENT is not a status — it is an open review + request (`ReviewRequest`), which can sit on a published document too. + """ + + draft = "draft" + published = "published" + archived = "archived" + + +class DocumentVisibility(StrEnum): + public = "public" + department = "department" + restricted = "restricted" + + +class PermissionLevel(StrEnum): + read = "read" + + +class AccessReason(StrEnum): + """Why a document is visible to the requesting user. + + API-only (never stored): computed per request so the UI can explain + access instead of leaving visibility rules implicit. + """ + + author = "author" + public = "public" + department = "department" + granted = "granted" + # Only reason: somebody asked this user to check the document. It ends + # with their answer, which is why it is worth naming separately. + review = "review" + + +class DocumentEventAction(StrEnum): + """A recorded step in a document's audit history. + + Content-bearing actions (created / edited) snapshot the Markdown source of + truth; the rest record only who did what and when. + """ + + created = "created" + edited = "edited" + published = "published" + archived = "archived" + visibility_changed = "visibility_changed" + # Someone was asked to check the content, and someone answered. + review_requested = "review_requested" + review_resolved = "review_resolved" + + +class JobStatus(StrEnum): + pending = "pending" + running = "running" + done = "done" + failed = "failed" diff --git a/backend/app/models/job.py b/backend/app/models/job.py new file mode 100644 index 0000000..cc48c58 --- /dev/null +++ b/backend/app/models/job.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Enum, Index, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.enums import JobStatus + + +class Job(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """Postgres-backed background queue, claimed via FOR UPDATE SKIP LOCKED.""" + + __tablename__ = "jobs" + __table_args__ = (Index("ix_jobs_status_run_after", "status", "run_after"),) + + type: Mapped[str] = mapped_column(String(100)) + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) + status: Mapped[JobStatus] = mapped_column( + Enum(JobStatus, native_enum=False, length=32), default=JobStatus.pending + ) + run_after: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + attempts: Mapped[int] = mapped_column(default=0) + last_error: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/llm_setting.py b/backend/app/models/llm_setting.py new file mode 100644 index 0000000..a0c3e66 --- /dev/null +++ b/backend/app/models/llm_setting.py @@ -0,0 +1,36 @@ +from sqlalchemy import Boolean, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class LLMSetting(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """Per-role endpoint configuration, edited in the admin UI. + + One row per model role. The rows are created once, at first start, from + the `PABLAN_CHAT_*` / `PABLAN_UTILITY_*` / `PABLAN_EMBEDDING_*` environment + variables; from then on **this table is the truth** and later `.env` + edits are ignored (see docs/architecture.md, "bootstrap, then DB"). + + The `*_from_env` flags record where each field's current value came + from, so the UI can say "taken from .env" or "changed here" per field + and offer a reset. They are not a fallback mechanism: the value itself + always lives in the column next to them. Tracking the provenance + explicitly beats comparing against the current environment, which would + mislabel every field the moment someone edits `.env` after bootstrap. + + The api_key is stored in plaintext because it has to be replayed to the + endpoint on every call — there is nothing to compare a hash against. + It is never returned by the API and never logged. + """ + + __tablename__ = "llm_settings" + + role: Mapped[str] = mapped_column(String(32), unique=True) + base_url: Mapped[str | None] = mapped_column(String(500)) + model: Mapped[str | None] = mapped_column(String(200)) + api_key: Mapped[str | None] = mapped_column(Text) + + base_url_from_env: Mapped[bool] = mapped_column(Boolean, default=True) + model_from_env: Mapped[bool] = mapped_column(Boolean, default=True) + api_key_from_env: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/models/prompt_setting.py b/backend/app/models/prompt_setting.py new file mode 100644 index 0000000..c5bf2d3 --- /dev/null +++ b/backend/app/models/prompt_setting.py @@ -0,0 +1,23 @@ +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class PromptSetting(UUIDPrimaryKeyMixin, TimestampMixin, Base): + """An admin override for a shipped system prompt. + + Every prompt has a CODE default (`app/prompts/defaults.py`); a row here + exists only when an admin has changed one. `content` is the full replacement + text. Applied without a restart via a module-level cache + (`app/prompts/overrides.py`), refreshed on every write — like the LLM + settings, and single-process by design (`--workers 1`). Resetting a prompt + deletes its row, so the code default takes over again. Unlike LLM settings + there is no `.env` layer: prompts have no environment representation, so the + reset target is the code default rather than the environment. + """ + + __tablename__ = "prompt_settings" + + key: Mapped[str] = mapped_column(String(64), unique=True) + content: Mapped[str] = mapped_column(Text) diff --git a/backend/app/models/template.py b/backend/app/models/template.py new file mode 100644 index 0000000..576b435 --- /dev/null +++ b/backend/app/models/template.py @@ -0,0 +1,18 @@ +from typing import Any + +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class Template(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "templates" + + # Every row here belongs to the customer and is editable. Blueprints + # shipped with the product stay on disk in templates/ until an admin + # adds one (app/template_catalog.py) — there is no read-only template. + name: Mapped[str] = mapped_column(String(200)) + version: Mapped[str] = mapped_column(String(20)) + config: Mapped[dict[str, Any]] = mapped_column(JSONB) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8baac1b --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,29 @@ +import uuid + +from sqlalchemy import Enum, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.department import Department +from app.models.enums import UserRole + + +class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "users" + + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + name: Mapped[str] = mapped_column(String(200)) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole, native_enum=False, length=32), default=UserRole.member + ) + password_hash: Mapped[str] = mapped_column(String(255)) + # Interface language. NULL follows the browser's Accept-Language; a value + # pins it. One column rather than a preferences table: this is the only + # preference that has to follow the person across devices — the theme is + # per-device and lives in localStorage. + locale: Mapped[str | None] = mapped_column(String(5)) + department_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("departments.id", ondelete="SET NULL") + ) + + department: Mapped[Department | None] = relationship(lazy="joined") diff --git a/backend/app/modes/__init__.py b/backend/app/modes/__init__.py new file mode 100644 index 0000000..60f2946 --- /dev/null +++ b/backend/app/modes/__init__.py @@ -0,0 +1,6 @@ +from app.modes.query import QueryMode +from app.modes.registry import get_mode, register_mode, registered_modes + +register_mode(QueryMode()) + +__all__ = ["get_mode", "register_mode", "registered_modes"] diff --git a/backend/app/modes/base.py b/backend/app/modes/base.py new file mode 100644 index 0000000..d77a8e8 --- /dev/null +++ b/backend/app/modes/base.py @@ -0,0 +1,96 @@ +"""The Mode protocol. + +Every interaction type implements `Mode` and yields `ModeEvent`s; the +conversations router converts them 1:1 into SSE. Modes know nothing about +HTTP; routers know nothing about mode logic. + +Capture is NOT a Mode: it is writing into a Document directly (see +`app/authoring/`), not a conversation. The only core Mode is query (RAG +Q&A); EE registers the insights mode. +""" + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Conversation + + +@dataclass +class Token: + text: str + + +@dataclass +class SourceChunk: + document_id: uuid.UUID + title: str + heading_path: str + excerpt: str = "" + # Whether this passage was actually passed to the model (grounding the + # answer), or only retrieved and then dropped as too weak (the no-answer + # path). Drives the "?" context inspector; the cited-source badges show + # only `used` chunks. + used: bool = True + # The document has an unanswered request to check it: readable, but not + # settled. Marked on the citation, because trusting an answer means + # trusting what it leaned on. + review_pending: bool = False + + +@dataclass +class Sources: + chunks: list[SourceChunk] = field(default_factory=list) + + +@dataclass +class StateChanged: + """Progress signal for the UI — metadata only. + + Query mode reports a phase (e.g. "searching" / "no_answer") and a count + (documents found). No content ever rides this frame. + """ + + phase: str + count: int | None = None + + +@dataclass +class Done: + """Emitted by the ROUTER after persisting the assistant message. + Modes normally end their iterator instead of yielding this.""" + + message_id: uuid.UUID | None = None + + +@dataclass +class Error: + """Why the turn failed, as a code the frontend phrases (CLAUDE.md: the + backend never renders UI-language strings).""" + + code: str + + +@dataclass +class Degraded: + """No model could be reached, so this turn has no generated answer: the + accompanying `Sources` are what a plain full-text search found, for the + user to read themselves. `code` is the endpoint failure that caused it + (`LLMError.code`); the frontend says what it means.""" + + code: str + + +ModeEvent = Token | Sources | StateChanged | Done | Error | Degraded + + +@runtime_checkable +class Mode(Protocol): + name: str + + def handle_turn( + self, conversation: Conversation, user_message: str, db: AsyncSession + ) -> AsyncIterator[ModeEvent]: ... diff --git a/backend/app/modes/prompts.py b/backend/app/modes/prompts.py new file mode 100644 index 0000000..b58351d --- /dev/null +++ b/backend/app/modes/prompts.py @@ -0,0 +1,31 @@ +"""Prompt rendering for modes — always natural language, never raw YAML or +JSON dumps. + +The base texts (the assistant's system prompt, the no-sources note) are +admin-editable via `app/prompts/overrides.py::get_prompt`; the query mode reads +`query_system` directly and `render_context_turn` reads `query_no_sources`. +""" + +from app.prompts.overrides import get_prompt +from app.rag.retrieval import SearchResult + + +def render_context_turn(results: list[SearchResult], question: str) -> str: + """The final user turn: the retrieval for THIS question, then the question. + + Deliberately NOT part of the system prompt: keeping the excerpts here lets + the system prompt AND the conversation history stay byte-identical across a + conversation's turns, so the endpoint's prompt cache reuses them and only + this turn's excerpts are fresh work (docs/architecture.md, prompt caching). + """ + if not results: + # Refusing to answer a greeting because retrieval found nothing makes the + # assistant feel broken. It answers from general knowledge, just never as + # if that were company policy (the UI labels these source-free). + return f"{get_prompt('query_no_sources')}\n\n{question}" + blocks = [ + f"[{index}] {result.heading_path or result.title}\n{result.content}" + for index, result in enumerate(results, start=1) + ] + excerpts = "\n\n---\n\n".join(blocks) + return f"Knowledge base excerpts:\n\n{excerpts}\n\nQuestion:\n{question}" diff --git a/backend/app/modes/query.py b/backend/app/modes/query.py new file mode 100644 index 0000000..ef30bde --- /dev/null +++ b/backend/app/modes/query.py @@ -0,0 +1,213 @@ +"""Query mode: permission-filtered retrieval → grounded streamed answer.""" + +import re +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.authoring.context import summarize_transcript +from app.llm.client import chat_stream, role_config +from app.llm.errors import LLMError +from app.llm.gate import endpoint_busy +from app.models import Conversation, MessageRole, User +from app.modes.base import ( + Degraded, + ModeEvent, + SourceChunk, + Sources, + StateChanged, + Token, +) +from app.modes.prompts import render_context_turn +from app.prompts.overrides import get_prompt +from app.rag.retrieval import ( + SearchResult, + results_are_low_confidence, + search, + text_search, +) + +HISTORY_TURNS = 8 +EXCERPT_CHARS = 280 +TOP_K = 5 + + +def _topic_transcript(conversation: Conversation, user_message: str) -> str: + """The recent turns plus the current message, as a transcript for the + topic summary. Returns '' when there is no earlier context — a first + message that misses is a genuine no-answer, not a lost topic.""" + turns = [ + (message.role, message.content) + for message in conversation.messages + if message.role in (MessageRole.user, MessageRole.assistant) + ] + # The current message may or may not already be persisted into + # `conversation.messages`; append it only if it is not the last turn. + if not turns or turns[-1] != (MessageRole.user, user_message): + turns.append((MessageRole.user, user_message)) + if len(turns) < 2: + return "" + return "\n".join( + f"{'User' if role is MessageRole.user else 'Assistant'}: {content}" + for role, content in turns[-HISTORY_TURNS:] + ) + + +# Markdown reduced to prose, in the order the rules have to fire. +_TABLE_DIVIDER = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$", re.MULTILINE) +_BLOCK_MARKER = re.compile(r"^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+", re.MULTILINE) +_FENCE = re.compile(r"^\s*```.*$", re.MULTILINE) +_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)") +_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)") +_EMPHASIS = re.compile(r"(\*\*\*|\*\*|\*|___|__|`)(?=\S)(.+?)(?<=\S)\1", re.DOTALL) +# Single underscores need word boundaries that the asterisk forms do not: +# `_kursiv_` is emphasis, but `result_document_id` is an identifier and the +# corpus is full of them. +_UNDERSCORE_EMPHASIS = re.compile(r"(? str: + """Short preview of a cited chunk for the citation popover. + + The user has already passed the permission filter for this chunk, so + showing it back is safe, but keep it short: it is a hint, not the + document. + + Markdown is reduced to prose rather than rendered. The popover is a + ~320px hover surface showing a FRAGMENT, and rendering a fragment goes + wrong in exactly the cases that matter: a cited table becomes a real + table squeezed into the popover, a cited section heading renders at h2 + size, and a list item arrives without its list. Clean prose answers the + only question the popover exists for, "is this the passage I want?". + Clicking the badge opens the document in the side panel, which renders + the Markdown properly through the sanitizing renderer. + + Keeping this plain text is also what lets `Tooltip` promise that its + content is never markup: document text + reaches it through here and nowhere else. + """ + text = _FENCE.sub("", content) + # Divider rows first: they are pure punctuation and survive every other + # rule as a run of dashes and pipes. + text = _TABLE_DIVIDER.sub("", text) + text = _IMAGE.sub(r"\1", text) + text = _LINK.sub(r"\1", text) + text = _BLOCK_MARKER.sub("", text) + # Two passes: the outer run of `**bold _and_ italic**` has to go before + # the inner one is reachable. + for _ in range(2): + text = _EMPHASIS.sub(r"\2", text) + text = _UNDERSCORE_EMPHASIS.sub(r"\1", text) + # Remaining cell walls become sentence-ish separators, so a cited table + # reads as "Code · Meaning · Action" instead of a wall of pipes. + text = re.sub(r"\s*\|\s*", " · ", text) + text = re.sub(r"(?: · )+", " · ", text) + # A leading or trailing separator is what an empty first or last table + # cell leaves behind. Regex rather than str.strip: the latter treats the + # argument as a character set, which is not what this means. + text = re.sub(r"^(?:\s|·)+|(?:\s|·)+$", "", text) + + flattened = " ".join(text.split()) + if len(flattened) <= EXCERPT_CHARS: + return flattened + cut = flattened[:EXCERPT_CHARS] + head, separator, _ = cut.rpartition(" ") + return (head if separator else cut) + "…" + + +def _source(result: SearchResult, *, used: bool) -> SourceChunk: + return SourceChunk( + document_id=result.document_id, + title=result.title, + heading_path=result.heading_path, + excerpt=excerpt(result.content), + used=used, + review_pending=result.review_pending, + ) + + +class QueryMode: + name = "query" + + async def handle_turn( + self, conversation: Conversation, user_message: str, db: AsyncSession + ) -> AsyncIterator[ModeEvent]: + user = await db.get(User, conversation.user_id) + assert user is not None + + yield StateChanged(phase="searching") + try: + results = await search(db, user_message, user=user, top_k=TOP_K) + except LLMError: + # No embedding endpoint. The German full-text index finds documents + # on its own (keywords, not meaning), and the chat role is + # configured separately, so the turn can still end in a real answer. + results = await text_search(db, user_message, user=user, top_k=TOP_K) + else: + if results_are_low_confidence(results): + # A follow-up ("Hi", "and my earlier question?") loses the topic + # on its own, but the conversation's subject can recover it. + # Runs only on the low-confidence path, so a clear question pays + # no extra latency. (Eval: topic-summary 4/4 vs raw message 1/4.) + transcript = _topic_transcript(conversation, user_message) + if transcript: + topic = await summarize_transcript(transcript) + if topic and topic != user_message: + retry = await search(db, topic, user=user, top_k=TOP_K) + if not results_are_low_confidence(retry): + results = retry + + grounded = not results_are_low_confidence(results) + if grounded: + yield StateChanged(phase="results", count=len(results)) + else: + # Nothing solid to ground on: the answer gets no sources and the UI + # offers to capture the missing knowledge instead. + yield StateChanged(phase="no_answer", count=0) + # Every retrieved passage is reported for the "?" context inspector; + # `used` marks the ones that actually reached the prompt. On a no-answer + # they are all unused, which is exactly what explains "why no answer". + yield Sources(chunks=[_source(result, used=grounded) for result in results]) + + history = [ + {"role": message.role.value, "content": message.content} + for message in conversation.messages[-HISTORY_TURNS:] + if message.role in (MessageRole.user, MessageRole.assistant) + ] + # Only grounded passages ground the model; a no-answer sends none. + prompt_results = results if grounded else [] + # Cache-friendly order: the static system prompt and the history stay + # byte-identical across a conversation's turns (so the endpoint's prompt + # cache reuses them); only this turn's excerpts + question are new. + messages = [ + {"role": "system", "content": get_prompt("query_system")}, + *history, + { + "role": "user", + "content": render_context_turn(prompt_results, user_message), + }, + ] + + answered = False + try: + # Someone else may be holding every slot the endpoint has. Saying + # so beats a cursor that blinks for twenty seconds; the phase + # flips to "answering" the moment the first token arrives. + chat_base_url, _, _ = role_config("chat") + queued = endpoint_busy(chat_base_url) + yield StateChanged(phase="queued" if queued else "answering") + async for delta in chat_stream(messages, role="chat"): + if queued and not answered: + yield StateChanged(phase="answering") + answered = True + yield Token(text=delta) + except LLMError as exc: + if answered: + # Half an answer is already on screen: the router keeps it and + # reports the failure. There is nothing to fall back to. + raise + # No model at all. What retrieval found IS the reply now, as a plain + # list the user opens themselves. Nothing grounded anything, so the + # passages are re-sent unused (the second frame replaces the first). + yield Sources(chunks=[_source(result, used=False) for result in results]) + yield Degraded(code=exc.code) diff --git a/backend/app/modes/registry.py b/backend/app/modes/registry.py new file mode 100644 index 0000000..87fc328 --- /dev/null +++ b/backend/app/modes/registry.py @@ -0,0 +1,18 @@ +"""Mode registration — also the EE extension point (the insights mode +registers itself from ee/backend via ee_hooks).""" + +from app.modes.base import Mode + +_MODES: dict[str, Mode] = {} + + +def register_mode(mode: Mode) -> None: + _MODES[mode.name] = mode + + +def get_mode(name: str) -> Mode | None: + return _MODES.get(name) + + +def registered_modes() -> list[str]: + return sorted(_MODES) diff --git a/backend/app/prompts/__init__.py b/backend/app/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/prompts/defaults.py b/backend/app/prompts/defaults.py new file mode 100644 index 0000000..7cfce2d --- /dev/null +++ b/backend/app/prompts/defaults.py @@ -0,0 +1,89 @@ +"""The shipped system prompts, as code defaults. + +Every prompt Pablan sends has its base text here, keyed by a stable id. The +render functions in `app/modes/prompts.py` and `app/authoring/prompts.py` read +the *effective* value through `app/prompts/overrides.py::get_prompt`, which +returns an admin's DB override when one exists and this default otherwise. + +Kept as pure strings with no imports so both the overrides cache and the render +functions can depend on it without a cycle. Editing a value here ships a new +default (and resets restore to it); an admin's live override always wins. +""" + +# The query (RAG Q&A) assistant. Kept byte-identical per turn so the endpoint's +# prompt cache reuses it — a DB override only changes on an admin write, so that +# still holds (docs/architecture.md, prompt caching). +QUERY_SYSTEM = """\ +You are Pablan, this company's internal knowledge assistant. + +Answer in the language of the question. Company facts — processes, numbers, +names, responsibilities — come only from the excerpts you are given; say when +something is not documented rather than filling the gap. Be brief and concrete. +""" + +# Appended to the final user turn when retrieval found nothing relevant, so the +# assistant still answers a greeting or general question without pretending the +# answer is company policy. +QUERY_NO_SOURCES = """\ +The knowledge base has nothing relevant for this message. + +Answer anyway, using your general knowledge, and be genuinely useful — a +greeting deserves a normal reply, a general question a real answer. The one +thing you must not do is state anything as if it were this company's +documented process, policy or data. Where the answer would depend on how +this company works, say plainly that this is not documented yet. +""" + +# The default persona for section refinement (a template may override it per +# document); the mechanical rules the refined section must follow. +REFINE_PERSONA = ( + "You are a precise technical editor in a knowledge-management tool. You " + "turn rough notes into clear, matter-of-fact documentation." +) + +REFINE_RULES = ( + "Rules: reply in the language the section is written in. Return ONLY the " + "refined section as plain Markdown — no preamble, no explanation, no code " + "fence around the whole thing, and none of the other sections. Keep a " + "heading the section starts with unchanged. Improve clarity, grammar and " + "structure (use a list where the content is a sequence of steps), but " + "invent no facts: use only what the section already states. If the section " + "is already clear, change it little." +) + +# The instruction that frames the retrieved grounding block during refinement; +# the retrieved excerpts are appended after it. +GROUNDING_FRAMING = ( + "Related knowledge already documented elsewhere (use it only to stay " + "consistent and to reference where this section connects to it — do not " + "copy it in and add no facts from it that the notes above do not already " + "state):" +) + +# Condensing a conversation into a short search topic (the topic-summary path). +TOPIC_SUMMARY = ( + "You condense a conversation into a short search topic for a knowledge " + "base. Reply with a concise noun phrase (a few words) in the language of " + "the conversation, naming what it is about. No sentence, no preamble, no " + "quotes." +) + +# Suggesting a document title from its content. +TITLE = ( + "You suggest a concise, specific title for a knowledge document, in the " + "language of the document. Reply with the title only: a short noun phrase, " + "no quotes, no trailing punctuation." +) + +DEFAULTS: dict[str, str] = { + "query_system": QUERY_SYSTEM, + "query_no_sources": QUERY_NO_SOURCES, + "refine_persona": REFINE_PERSONA, + "refine_rules": REFINE_RULES, + "grounding_framing": GROUNDING_FRAMING, + "topic_summary": TOPIC_SUMMARY, + "title": TITLE, +} + +# Stable display/iteration order for the admin panel. +PROMPT_KEYS: tuple[str, ...] = tuple(DEFAULTS) diff --git a/backend/app/prompts/overrides.py b/backend/app/prompts/overrides.py new file mode 100644 index 0000000..d3f2258 --- /dev/null +++ b/backend/app/prompts/overrides.py @@ -0,0 +1,55 @@ +"""The effective system prompts, as the process sees them. + +Prompts default to code (`app/prompts/defaults.py`); an admin may override any +of them in `prompt_settings`, applied without a restart. This mirrors the LLM +settings override pattern, with two simplifications: the reset target is the +code default (prompts have no `.env` layer), and there is no bootstrap — a +missing row simply means "use the default". + +`get_prompt` is called while rendering a prompt, so it reads a module-level +cache rather than awaiting a query. The cache is filled at startup and refreshed +on every admin write. Single-process by design (`--workers 1`); a multi-worker +deployment would need a notification channel. +""" + +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import PromptSetting +from app.prompts.defaults import DEFAULTS + +logger = logging.getLogger("pablan.prompts") + +# Only holds the keys an admin has actually overridden. +_config: dict[str, str] = {} + + +async def load_config(db: AsyncSession) -> None: + """Re-read every override row into the cache. Call at startup and after any + admin write.""" + rows = (await db.execute(select(PromptSetting))).scalars().all() + _config.clear() + _config.update({row.key: row.content for row in rows if row.key in DEFAULTS}) + logger.info( + "prompt settings loaded", + extra={"event": "prompt_settings_loaded", "overridden": sorted(_config)}, + ) + + +def get_prompt(key: str) -> str: + """The effective prompt: an admin override if present, else the code default. + + `key` must be a known prompt (a `KeyError` here is a programming error, not + user input).""" + return _config.get(key) or DEFAULTS[key] + + +def is_overridden(key: str) -> bool: + return key in _config + + +def clear() -> None: + """Drop the cache — used by tests between cases.""" + _config.clear() diff --git a/backend/app/rag/__init__.py b/backend/app/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/rag/chunking.py b/backend/app/rag/chunking.py new file mode 100644 index 0000000..635d5f2 --- /dev/null +++ b/backend/app/rag/chunking.py @@ -0,0 +1,107 @@ +"""Markdown chunking along the heading hierarchy. + +Chunk size uses a character heuristic (~4 chars/token, target ~400 tokens); +no tokenizer dependency — precision is not required for chunk sizing, and a +real tokenizer would not match local model tokenizers anyway. Sections that +exceed the cap are split at paragraph boundaries, never inside code fences. +""" + +import re +from dataclasses import dataclass + +# ~400 tokens at the ~4 chars/token heuristic. +TARGET_CHUNK_CHARS = 1600 + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") + +HEADING_PATH_SEPARATOR = " › " + + +@dataclass +class ChunkData: + content: str + heading_path: str + + +@dataclass +class _Section: + path: list[str] + lines: list[str] + + @property + def text(self) -> str: + return "\n".join(self.lines).strip() + + +def _split_sections(content_md: str, title: str) -> list[_Section]: + sections: list[_Section] = [_Section(path=[title], lines=[])] + heading_stack: list[tuple[int, str]] = [] # (level, text) + in_fence = False + + for line in content_md.splitlines(): + if line.lstrip().startswith("```"): + in_fence = not in_fence + match = None if in_fence else HEADING_RE.match(line) + if match: + level = len(match.group(1)) + text = match.group(2).strip() + while heading_stack and heading_stack[-1][0] >= level: + heading_stack.pop() + heading_stack.append((level, text)) + path = [title, *(heading for _, heading in heading_stack)] + # Drop a leading H1 that just repeats the document title. + if len(path) > 1 and path[1] == title: + path = [title, *path[2:]] + sections.append(_Section(path=path, lines=[line])) + else: + sections[-1].lines.append(line) + + return [section for section in sections if section.text] + + +def _split_paragraphs(text: str) -> list[str]: + """Split at blank lines, but never inside a ``` fence.""" + paragraphs: list[str] = [] + current: list[str] = [] + in_fence = False + for line in text.splitlines(): + if line.lstrip().startswith("```"): + in_fence = not in_fence + if not line.strip() and not in_fence: + if current: + paragraphs.append("\n".join(current)) + current = [] + else: + current.append(line) + if current: + paragraphs.append("\n".join(current)) + return paragraphs + + +def _split_oversized(text: str) -> list[str]: + pieces: list[str] = [] + current = "" + for paragraph in _split_paragraphs(text): + candidate = f"{current}\n\n{paragraph}" if current else paragraph + if current and len(candidate) > TARGET_CHUNK_CHARS: + pieces.append(current) + current = paragraph + else: + current = candidate + if current: + pieces.append(current) + return pieces + + +def chunk_markdown(content_md: str, title: str) -> list[ChunkData]: + """Split a document into chunks; each chunk has exactly one heading path.""" + chunks: list[ChunkData] = [] + for section in _split_sections(content_md, title): + heading_path = HEADING_PATH_SEPARATOR.join(section.path) + text = section.text + if len(text) <= TARGET_CHUNK_CHARS: + chunks.append(ChunkData(content=text, heading_path=heading_path)) + else: + for piece in _split_oversized(text): + chunks.append(ChunkData(content=piece, heading_path=heading_path)) + return chunks diff --git a/backend/app/rag/indexing.py b/backend/app/rag/indexing.py new file mode 100644 index 0000000..ce3625d --- /dev/null +++ b/backend/app/rag/indexing.py @@ -0,0 +1,86 @@ +"""Chunk (re)generation for a document — chunks are disposable derivatives. + +A full re-index is always possible from documents alone; swapping +the embedding model is a reindex_all away (same dimension) or a migration +plus reindex_all (different dimension). +""" + +import logging +import time + +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.llm.client import embed +from app.metrics import metrics +from app.models import Chunk, Document +from app.rag.chunking import chunk_markdown + +logger = logging.getLogger("pablan.rag") + +EMBED_BATCH_SIZE = 32 + + +def embedding_text(heading_path: str, content: str) -> str: + """What is actually embedded for a chunk: its heading path, then its text. + + A section says "Solldruck 180 bar" and never repeats which machine it + belongs to, so without its path the chunk is unreachable by the name the + asker actually uses. What is STORED as `content` stays the raw section — + the path is context for the vector, not part of the document. + """ + return f"{heading_path}\n\n{content}" + + +async def reindex_document(db: AsyncSession, document: Document) -> int: + """Delete and regenerate all chunks for one document. Returns the count.""" + started = time.monotonic() + chunks_data = chunk_markdown(document.content_md, document.title) + + vectors: list[list[float]] = [] + for batch_start in range(0, len(chunks_data), EMBED_BATCH_SIZE): + batch = chunks_data[batch_start : batch_start + EMBED_BATCH_SIZE] + vectors.extend( + await embed( + [embedding_text(chunk.heading_path, chunk.content) for chunk in batch] + ) + ) + + await db.execute(delete(Chunk).where(Chunk.document_id == document.id)) + for index, (data, vector) in enumerate(zip(chunks_data, vectors, strict=True)): + db.add( + Chunk( + document_id=document.id, + chunk_index=index, + content=data.content, + embedding=vector, + meta={ + "heading_path": data.heading_path, + # Denormalized for display/filtering — NEVER for + # permission checks (can be stale until the next reindex). + "department_id": ( + str(document.department_id) if document.department_id else None + ), + "visibility": document.visibility, + }, + ) + ) + await db.flush() + + duration = time.monotonic() - started + metrics.inc("chunks_indexed_total", value=len(chunks_data)) + metrics.observe("indexing_seconds", duration) + logger.info( + "document indexed", + extra={ + "event": "document_indexed", + "document_id": str(document.id), + "chunk_count": len(chunks_data), + "duration_ms": round(duration * 1000), + }, + ) + return len(chunks_data) + + +async def remove_chunks(db: AsyncSession, document_id) -> None: + await db.execute(delete(Chunk).where(Chunk.document_id == document_id)) diff --git a/backend/app/rag/permissions.py b/backend/app/rag/permissions.py new file mode 100644 index 0000000..aeaefe9 --- /dev/null +++ b/backend/app/rag/permissions.py @@ -0,0 +1,109 @@ +"""Single source of truth for who may read which document. + +Used by BOTH the documents API and retrieval, so the permission filter that +runs before the LLM can never drift from what the API +exposes. Always evaluated against the live documents table — never against +denormalized chunk meta, which can be stale between edits and reindexing. +""" + +from sqlalchemy import ColumnElement, and_, exists, or_, select, true + +from app.models import ( + DocPermission, + Document, + DocumentStatus, + DocumentVisibility, + ReviewRequest, + User, +) + + +def searchable_documents_filter(user: User) -> ColumnElement[bool]: + """Published documents the user may read. + + Rules: public to everyone; department to members of the owning + department; restricted only via doc_permissions grants. Authors always + see their own documents. + """ + clauses: list[ColumnElement[bool]] = [ + Document.visibility == DocumentVisibility.public, + Document.author_id == user.id, + ] + if user.department_id is not None: + clauses.append( + and_( + Document.visibility == DocumentVisibility.department, + Document.department_id == user.department_id, + ) + ) + clauses.append( + exists( + select(DocPermission.document_id).where( + DocPermission.document_id == Document.id, + DocPermission.department_id == user.department_id, + ) + ) + ) + return and_(Document.status == DocumentStatus.published, or_(*clauses)) + + +def readable_documents_filter(user: User) -> ColumnElement[bool]: + """Searchable documents plus the unpublished ones this user owns or was + asked to check. + + Being asked IS the grant: a reviewer must be able to open the draft they + were pointed at. The clause lives here only, never in + `searchable_documents_filter`, so an unpublished document still never + reaches chat/search retrieval.""" + return or_( + searchable_documents_filter(user), + Document.author_id == user.id, + open_review_for(user), + ) + + +def open_review_for(user: User) -> ColumnElement[bool]: + """This user has an unanswered request to check the document.""" + return exists( + select(ReviewRequest.document_id).where( + ReviewRequest.document_id == Document.id, + ReviewRequest.reviewer_id == user.id, + ReviewRequest.resolved_at.is_(None), + ) + ) + + +def has_open_review() -> ColumnElement[bool]: + """Anyone has an unanswered question about the document — what marks it as + "may not be right yet" wherever it is shown, including chat sources.""" + return exists( + select(ReviewRequest.document_id).where( + ReviewRequest.document_id == Document.id, + ReviewRequest.resolved_at.is_(None), + ) + ) + + +def document_reader_filter(document: Document) -> ColumnElement[bool]: + """A `User`-table filter for who may read `document` AS IF it were + published — the candidate set for assigning a reviewer. Inverts the read + rules of `searchable_documents_filter` (author, public, owning department, + granted departments), ignoring status so a still-pending document can be + handed to a reviewer who will then be able to see it (the reviewer clause + in `readable_documents_filter`).""" + clauses: list[ColumnElement[bool]] = [User.id == document.author_id] + if document.visibility == DocumentVisibility.public: + clauses.append(true()) + elif ( + document.visibility == DocumentVisibility.department + and document.department_id is not None + ): + clauses.append(User.department_id == document.department_id) + clauses.append( + User.department_id.in_( + select(DocPermission.department_id).where( + DocPermission.document_id == document.id + ) + ) + ) + return or_(*clauses) diff --git a/backend/app/rag/retrieval.py b/backend/app/rag/retrieval.py new file mode 100644 index 0000000..ea50971 --- /dev/null +++ b/backend/app/rag/retrieval.py @@ -0,0 +1,277 @@ +"""Hybrid retrieval: permission filter BEFORE anything else, then vector + +German full-text candidates merged with Reciprocal Rank Fusion. + +There is no search without a user — the permission CTE is part of the one +SQL statement, so an unauthorized chunk is structurally impossible to +retrieve. Queries are user content and are never logged. +""" + +import logging +import time +import uuid +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import Text, cast, func, literal, select, union +from sqlalchemy.dialects.postgresql import REGCONFIG +from sqlalchemy.ext.asyncio import AsyncSession + +from app.llm.client import embed +from app.metrics import metrics +from app.models import Chunk, Document, User +from app.rag.permissions import has_open_review, searchable_documents_filter + +logger = logging.getLogger("pablan.rag") + +RRF_K = 60 +CANDIDATES_PER_SOURCE = 20 + +# Calibrated against bge-m3 (2026-07): matched top +# hits land at cosine distance ~0.34-0.45, unrelated queries at 0.50+. +NO_ANSWER_MIN_DISTANCE = 0.45 + + +@dataclass +class SearchResult: + chunk_id: uuid.UUID + document_id: uuid.UUID + title: str + heading_path: str + content: str + score: float + vector_distance: float | None + fts_match: bool + # An unanswered request to check this document. Travels with every hit so + # an answer can mark the source it leaned on as not-yet-settled. + review_pending: bool = False + + +async def search( + db: AsyncSession, + query: str, + *, + user: User, + top_k: int = 5, +) -> list[SearchResult]: + started = time.monotonic() + query_vector = (await embed([query]))[0] + + allowed = ( + select( + Document.id, + Document.title, + has_open_review().label("review_pending"), + ) + .where(searchable_documents_filter(user)) + .cte("allowed") + ) + + distance = Chunk.embedding.cosine_distance(query_vector) + vec = ( + select( + Chunk.id.label("chunk_id"), + func.row_number().over(order_by=distance).label("rank"), + distance.label("distance"), + ) + .join(allowed, allowed.c.id == Chunk.document_id) + .order_by(distance) + .limit(CANDIDATES_PER_SOURCE) + .subquery("vec") + ) + + tsquery = func.websearch_to_tsquery(cast(literal("german"), REGCONFIG), query) + fts_order = func.ts_rank_cd(Chunk.tsv, tsquery).desc() + fts = ( + select( + Chunk.id.label("chunk_id"), + func.row_number().over(order_by=fts_order).label("rank"), + ) + .join(allowed, allowed.c.id == Chunk.document_id) + .where(Chunk.tsv.op("@@")(tsquery)) + .order_by(fts_order) + .limit(CANDIDATES_PER_SOURCE) + .subquery("fts") + ) + + candidate_ids = union(select(vec.c.chunk_id), select(fts.c.chunk_id)).subquery( + "ids" + ) + score = ( + func.coalesce(1.0 / (RRF_K + vec.c.rank), 0.0) + + func.coalesce(1.0 / (RRF_K + fts.c.rank), 0.0) + ).label("score") + + rows = ( + await db.execute( + select( + Chunk.id, + Chunk.document_id, + Chunk.content, + Chunk.meta, + allowed.c.title, + allowed.c.review_pending, + score, + vec.c.distance, + fts.c.rank.label("fts_rank"), + ) + .join(candidate_ids, candidate_ids.c.chunk_id == Chunk.id) + .join(allowed, allowed.c.id == Chunk.document_id) + .outerjoin(vec, vec.c.chunk_id == Chunk.id) + .outerjoin(fts, fts.c.chunk_id == Chunk.id) + .order_by(score.desc()) + .limit(top_k) + ) + ).all() + + results = [ + SearchResult( + chunk_id=row.id, + document_id=row.document_id, + title=row.title, + heading_path=heading_path(row.meta), + content=row.content, + score=float(row.score), + vector_distance=float(row.distance) if row.distance is not None else None, + fts_match=row.fts_rank is not None, + review_pending=bool(row.review_pending), + ) + for row in rows + ] + + duration = time.monotonic() - started + metrics.inc("retrieval_searches_total") + metrics.observe("retrieval_seconds", duration) + metrics.observe("retrieval_results", float(len(results))) + for result in results: + source = ( + "both" + if result.fts_match and result.vector_distance is not None + else ("fts" if result.fts_match else "vector") + ) + metrics.inc("retrieval_result_source_total", {"source": source}) + logger.info( + "retrieval", + extra={ + "event": "retrieval", + "duration_ms": round(duration * 1000), + "result_count": len(results), + "top_k": top_k, + }, + ) + return results + + +def _any_term_tsquery(query: str) -> Any: + """The fallback's query: the lexemes `websearch_to_tsquery` produces, but + ORed instead of ANDed. + + With no vector half to carry the recall, an AND query answers a natural + question ("Wie läuft die Qualitätsprüfung im Wareneingang?") with nothing + at all unless one single chunk happens to contain every word of it. ORing + keeps the question usable and leaves the ordering to `ts_rank_cd`, which + is what ranks a chunk matching more of the terms higher. Only the AND + operators between groups are rewritten, so quoted phrases and exclusions + survive. A query of nothing but stop words rewrites to an empty string, + and NULLIF turns that into a query that matches nothing rather than a + syntax error. + """ + websearch = func.websearch_to_tsquery(cast(literal("german"), REGCONFIG), query) + lexemes = func.nullif(func.replace(cast(websearch, Text), " & ", " | "), "") + return func.to_tsquery(cast(literal("german"), REGCONFIG), lexemes) + + +async def text_search( + db: AsyncSession, + query: str, + *, + user: User, + top_k: int = 5, +) -> list[SearchResult]: + """The full-text half of `search()` alone: German `tsvector` matching over + the GIN index (an inverted index), with no embedding call. + + This is what keeps the knowledge base searchable when no model answers at + the configured endpoint. It finds less than the hybrid path (keywords, not + meaning), so it is a fallback the user is told about, never a silent + substitute. Same permission CTE as everything else — there is no search + without a user. + + Terms are ORed here while the hybrid path ANDs them (`_any_term_tsquery`): + alone, a full-sentence question must not come back empty, and in the + hybrid path the AND is what makes `fts_match` mean "the words are really + in there" for the no-answer signal. + """ + started = time.monotonic() + allowed = ( + select( + Document.id, + Document.title, + has_open_review().label("review_pending"), + ) + .where(searchable_documents_filter(user)) + .cte("allowed") + ) + tsquery = _any_term_tsquery(query) + rank = func.ts_rank_cd(Chunk.tsv, tsquery) + rows = ( + await db.execute( + select( + Chunk.id, + Chunk.document_id, + Chunk.content, + Chunk.meta, + allowed.c.title, + allowed.c.review_pending, + rank.label("rank"), + ) + .join(allowed, allowed.c.id == Chunk.document_id) + .where(Chunk.tsv.op("@@")(tsquery)) + .order_by(rank.desc()) + .limit(top_k) + ) + ).all() + + results = [ + SearchResult( + chunk_id=row.id, + document_id=row.document_id, + title=row.title, + heading_path=heading_path(row.meta), + content=row.content, + score=float(row.rank), + vector_distance=None, + fts_match=True, + review_pending=bool(row.review_pending), + ) + for row in rows + ] + + duration = time.monotonic() - started + metrics.inc("retrieval_text_searches_total") + metrics.observe("retrieval_seconds", duration) + logger.info( + "retrieval (text only)", + extra={ + "event": "retrieval_text", + "duration_ms": round(duration * 1000), + "result_count": len(results), + "top_k": top_k, + }, + ) + return results + + +def heading_path(meta: dict[str, Any] | None) -> str: + return (meta or {}).get("heading_path", "") + + +def results_are_low_confidence(results: list[SearchResult]) -> bool: + """No-answer signal: no keyword match anywhere and the best vector + candidate is far away. Callers should not present such results as + grounding.""" + if not results: + return True + top = results[0] + return not top.fts_match and ( + top.vector_distance is None or top.vector_distance >= NO_ANSWER_MIN_DISTANCE + ) diff --git a/backend/app/rag/similarity.py b/backend/app/rag/similarity.py new file mode 100644 index 0000000..6d584a3 --- /dev/null +++ b/backend/app/rag/similarity.py @@ -0,0 +1,183 @@ +"""Similarity: "what else is close to this text", with a threshold. + +Deliberately NOT the hybrid path. RRF produces a fusion rank, not a +similarity, and its `vector_distance` is None for hits that surfaced only +through full text — a threshold needs a comparable number. What both paths DO +share is the permission filter: the same `allowed` CTE, so a suggestion can +never point at something the caller may not read. + +Two callers, two calibrated limits: a refinement grounds on loosely related +material, while a duplicate check may only propose merging on a close match. +""" + +import logging +import time +import uuid +from dataclasses import dataclass + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.llm.client import embed +from app.metrics import metrics +from app.models import Chunk, Document, User +from app.rag.permissions import searchable_documents_filter +from app.rag.retrieval import CANDIDATES_PER_SOURCE, heading_path + +logger = logging.getLogger("pablan.rag") + +# One mechanic at two moments: during capture a loose limit is useful +# because a near-miss still makes good context, while at review time only a +# high-confidence match may propose merging into an existing document. +# CHANGING THE EMBEDDING MODEL MEANS RE-MEASURING ALL THREE constants; +# tests/evals/test_duplicate_eval.py prints the numbers to do it with. +# +# Measured against bge-m3 on the fixture corpus (2026-07-20): drafts that +# duplicate an existing document land at 0.116-0.274, genuinely new topics +# at 0.402-0.486. 0.35 sits in that gap. The upper end of the duplicate +# range comes from REAL capture drafts, which are compressed notes rather +# than full prose and therefore sit further from their source than a +# hand-written paraphrase does — calibrating on paraphrases alone gives a +# threshold that misses real duplicates (it did: 0.25 missed one at 0.256). +CAPTURE_CONTEXT_MAX_DISTANCE = 0.45 +DUPLICATE_MAX_DISTANCE = 0.35 + + +@dataclass +class SimilarChunk: + chunk_id: uuid.UUID + document_id: uuid.UUID + title: str + heading_path: str + content: str + distance: float + + +@dataclass +class SimilarDocument: + document_id: uuid.UUID + title: str + distance: float # the closest chunk of that document + + +async def similar_chunks( + db: AsyncSession, + text: str, + *, + user: User, + top_k: int = 5, + max_distance: float, + exclude_builtin: bool = False, + exclude_document_id: uuid.UUID | None = None, +) -> list[SimilarChunk]: + """Pure vector neighbours of a text, permission-filtered like everything + else — the same `allowed` CTE `search()` uses. + + Deliberately NOT the hybrid path: RRF produces a fusion rank, not a + similarity, and its `vector_distance` is None for hits that surfaced + only through full text. A threshold needs a comparable number. + + `max_distance` is keyword-only and has no default on purpose: every + caller names one of the two calibrated constants, so "similar" means + exactly two things in this product and both are written down. + + `exclude_builtin` drops Pablan's own help pages. They are answerable + through query mode on purpose (the product documents itself), but a + capture or duplicate check asks "what does the COMPANY already know" — + proposing to extend a help page, or telling an author their topic is + "already documented" because a help page mentions it, is wrong. + """ + started = time.monotonic() + vector = (await embed([text]))[0] + + allowed_filter = searchable_documents_filter(user) + if exclude_builtin: + allowed_filter = and_(allowed_filter, Document.is_builtin.is_(False)) + if exclude_document_id is not None: + # A document must never ground on itself (the extend flow re-opens a + # published document and would otherwise retrieve its own chunks). + allowed_filter = and_(allowed_filter, Document.id != exclude_document_id) + allowed = select(Document.id, Document.title).where(allowed_filter).cte("allowed") + distance = Chunk.embedding.cosine_distance(vector) + + # Threshold in Python, after ORDER BY ... LIMIT: a distance predicate in + # WHERE fights the HNSW index, ordering and limiting is what it serves. + rows = ( + await db.execute( + select( + Chunk.id, + Chunk.document_id, + Chunk.content, + Chunk.meta, + allowed.c.title, + distance.label("distance"), + ) + .join(allowed, allowed.c.id == Chunk.document_id) + .order_by(distance) + .limit(top_k) + ) + ).all() + + results = [ + SimilarChunk( + chunk_id=row.id, + document_id=row.document_id, + title=row.title, + heading_path=heading_path(row.meta), + content=row.content, + distance=float(row.distance), + ) + for row in rows + if float(row.distance) <= max_distance + ] + + duration = time.monotonic() - started + metrics.inc("similarity_searches_total") + metrics.observe("similarity_seconds", duration) + logger.info( + "similarity", + extra={ + "event": "similarity", + "duration_ms": round(duration * 1000), + "candidate_count": len(rows), + "result_count": len(results), + "top_k": top_k, + }, + ) + return results + + +async def similar_documents( + db: AsyncSession, + text: str, + *, + user: User, + top_k: int = 3, + max_distance: float, + exclude_builtin: bool = False, +) -> list[SimilarDocument]: + """Documents near a text, ranked by their closest chunk. + + Overfetches chunks and groups them, so this is literally the same search + as `similar_chunks` — one notion of "similar" in the product, not two + implementations that drift apart. + """ + chunks = await similar_chunks( + db, + text, + user=user, + top_k=CANDIDATES_PER_SOURCE, + max_distance=max_distance, + exclude_builtin=exclude_builtin, + ) + best: dict[uuid.UUID, SimilarDocument] = {} + for chunk in chunks: # distance-ordered, so the first hit per document wins + best.setdefault( + chunk.document_id, + SimilarDocument( + document_id=chunk.document_id, + title=chunk.title, + distance=chunk.distance, + ), + ) + return list(best.values())[:top_k] diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..376a0ce --- /dev/null +++ b/backend/app/seed.py @@ -0,0 +1,364 @@ +"""Dev seed data: departments, users and the fixture corpus as documents with +a plausible past. Never runs in production. + +Seeded documents are not inserted as finished rows — they are given the +history they would have if someone had written them in the app: an empty +draft, one edit per section as the author works down the page, the publish, +and the questions colleagues asked afterwards. Without that, every history +view, diff and "recently changed" list in the dev stack is empty or lies. +""" + +import asyncio +import re +import sys +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.config import get_settings +from app.db import async_session_factory, engine +from app.ingestion.handlers import INDEX_DOCUMENT +from app.ingestion.queue import enqueue +from app.models import ( + Department, + DocPermission, + Document, + DocumentEvent, + DocumentEventAction, + DocumentStatus, + ReviewRequest, + User, + UserRole, +) + +if TYPE_CHECKING: # the corpus is a dev-only import, see _seed_corpus + from tests.fixtures.loader import CorpusDoc + +DEV_PASSWORD = "pablan-dev" + +DEPARTMENTS = ["Engineering", "Sales", "Administration"] + +# The dev team, one per department, so the permission scenarios the e2e +# suite relies on stay intact: an admin in Administration, a member in +# Engineering, a member in Sales. +USERS = [ + ("florian@pablan.dev", "Florian", UserRole.admin, "Administration"), + ("pablo@pablan.dev", "Pablo", UserRole.member, "Engineering"), + ("max@pablan.dev", "Max", UserRole.member, "Sales"), +] + +# Which seeded user authors a department's corpus documents. +AUTHORS_BY_DEPARTMENT = { + "Engineering": "pablo@pablan.dev", + "Sales": "max@pablan.dev", + "Administration": "florian@pablan.dev", +} + +# Deliberately authorless: it doubles as the demo for the "department" access +# reason (a member sees it without owning it) and, being the knowledge of +# someone who has since left, fits the offboarding theme. +AUTHORLESS_SLUG = "wartungsplan-cnc-f350" + +# Documents whose life stops before the publish: unpublished work in progress, +# one per author, so whoever logs in finds their own drafts waiting on the +# home page. +DRAFT_SLUGS = { + "netzwerk-produktions-it", # Engineering + "messevorbereitung", # Sales + "it-onboarding-arbeitsplatz", # Administration +} + +# Published once, then retired — so the archive is not an empty concept in the +# dev stack. +ARCHIVED_SLUGS = {"edi-rechnungen"} + +# How far back the corpus starts and how far apart the documents were written. +# Deterministic rather than random: the "recently changed" order stays stable +# across re-seeds, and the oldest documents are the ones that look oldest. +CORPUS_STARTS_DAYS_AGO = 120 +DAYS_BETWEEN_DOCUMENTS = 6 + + +@dataclass(frozen=True) +class SeededReview: + """A "please check this" the author sent a colleague. + + Open ones are the interesting case: the document is published and readable + and still carries an unanswered question, which is exactly what every + surface — list, detail page, chat sources — has to mark. + """ + + reviewer: str + question: str + answered: bool = False + # What the reviewer corrected, as (current text, the text it replaced). + # Applied in reverse to every version before the answer, so the history + # holds a real diff and the answer is visibly a fix, not a rubber stamp. + correction: tuple[str, str] | None = None + + +REVIEWS = { + # Published, public, unanswered: the case a reader most needs to see. + "urlaubsantrag-prozess": SeededReview( + reviewer="pablo@pablan.dev", + question=( + "Stimmt das für die Fertigung noch so, dass pro Schicht maximal " + "zwei Personen gleichzeitig Urlaub haben dürfen?" + ), + ), + # On a draft: being asked is what lets a colleague see it at all. + "messevorbereitung": SeededReview( + reviewer="florian@pablan.dev", + question="Passt der Budgetrahmen so, bevor ich das veröffentliche?", + ), + # Answered — and the reviewer fixed the number before answering. + "rabattrichtlinie": SeededReview( + reviewer="florian@pablan.dev", + question="Gilt für Ersatzteile weiterhin die 3-%-Grenze?", + answered=True, + correction=( + "- Bis 5 %: eigenverantwortlich durch den Vertriebsmitarbeiter", + "- Bis 3 %: eigenverantwortlich durch den Vertriebsmitarbeiter", + ), + ), +} + + +def _seed_order(doc: "CorpusDoc") -> tuple[int, str]: + """The order the corpus was "written" in, oldest first. + + Not alphabetical: the knowledge of someone who has left is the oldest + thing in the base, and work still in draft has to be the most recent. + """ + if doc.slug == AUTHORLESS_SLUG: + return (0, doc.slug) + if doc.slug in DRAFT_SLUGS: + return (2, doc.slug) + return (1, doc.slug) + + +def _writing_steps(content_md: str) -> list[str]: + """The document as it grew: the empty draft it starts as, then one state + per section — the shape the writing editor produces, where a section is + refined and saved before the next one is started.""" + sections = re.split(r"(?m)^(?=## )", content_md) + return [""] + ["".join(sections[: index + 1]) for index in range(len(sections))] + + +async def _get_or_create_department(db: AsyncSession, name: str) -> Department: + existing = ( + await db.execute(select(Department).where(Department.name == name)) + ).scalar_one_or_none() + if existing is not None: + return existing + department = Department(name=name) + db.add(department) + await db.flush() + return department + + +async def seed() -> None: + async with async_session_factory() as db: + departments = { + name: await _get_or_create_department(db, name) for name in DEPARTMENTS + } + + created = 0 + for email, name, role, department_name in USERS: + existing = ( + await db.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if existing is not None: + continue + db.add( + User( + email=email, + name=name, + role=role, + password_hash=hash_password(DEV_PASSWORD), + department_id=departments[department_name].id, + ) + ) + created += 1 + + documents_created = await _seed_corpus(db, departments) + await db.commit() + + await engine.dispose() + print(f"Seeded {len(DEPARTMENTS)} departments, {created} new users.") + print(f"Dev logins (password: {DEV_PASSWORD!r}):") + for email, _, role, department in USERS: + print(f" {email} ({role}, {department})") + print( + f"Seeded {documents_created} new corpus documents with their history " + f"({len(DRAFT_SLUGS)} drafts, {len(REVIEWS)} review requests); " + "index jobs enqueued (processed once the backend runs)." + ) + + +async def _seed_corpus(db: AsyncSession, departments: dict[str, Department]) -> int: + # Dev-only import: the corpus lives with the test fixtures on purpose — + # seeds and tests draw from the same product asset. + from tests.fixtures.loader import load_corpus + + users_by_email = {u.email: u for u in (await db.execute(select(User))).scalars()} + created = 0 + for index, doc in enumerate(sorted(load_corpus(), key=_seed_order)): + existing = ( + await db.execute( + select(Document.id).where(Document.meta["slug"].astext == doc.slug) + ) + ).first() + if existing is not None: + continue + document = await _seed_document( + db, + doc, + department=departments[doc.department], + users_by_email=users_by_email, + started_at=datetime.now(UTC) + - timedelta(days=CORPUS_STARTS_DAYS_AGO - index * DAYS_BETWEEN_DOCUMENTS), + ) + for grant in doc.grants: + db.add( + DocPermission( + document_id=document.id, + department_id=departments[grant].id, + ) + ) + if document.status == DocumentStatus.published: + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) + created += 1 + return created + + +async def _seed_document( + db: AsyncSession, + doc: "CorpusDoc", + *, + department: Department, + users_by_email: dict[str, User], + started_at: datetime, +) -> Document: + """One corpus document plus the trail of everything that happened to it.""" + author: User | None = users_by_email[AUTHORS_BY_DEPARTMENT[doc.department]] + if doc.slug == AUTHORLESS_SLUG: + author = None + + slug = doc.slug + content = doc.content_md + review = REVIEWS.get(slug) + is_draft = slug in DRAFT_SLUGS + + document = Document( + title=doc.title, + status=DocumentStatus.draft if is_draft else DocumentStatus.published, + visibility=doc.visibility, + content_md=content, + meta={"slug": slug}, + author_id=author.id if author else None, + department_id=department.id, + reviews=[], + ) + db.add(document) + await db.flush() + + at = started_at + + def happened( + action: DocumentEventAction, + actor: User | None, + snapshot: str | None = None, + *, + after: timedelta = timedelta(), + ) -> datetime: + nonlocal at + at += after + db.add( + DocumentEvent( + document_id=document.id, + actor_id=actor.id if actor else None, + action=action, + content_md=snapshot, + title=document.title if snapshot is not None else None, + visibility=document.visibility, + meta=document.meta if snapshot is not None else None, + created_at=at, + updated_at=at, + ) + ) + return at + + # The text as it stood before the reviewer's correction — everything up to + # their answer holds the old wording. + written = content + if review and review.correction: + current, previous = review.correction + if current not in content: + raise ValueError(f"correction text not found in {slug}: {current!r}") + written = content.replace(current, previous) + + steps = _writing_steps(written) + happened(DocumentEventAction.created, author, steps[0]) + for step in steps[1:]: + happened(DocumentEventAction.edited, author, step, after=timedelta(minutes=40)) + + if not is_draft: + happened(DocumentEventAction.published, author, after=timedelta(days=1)) + + if review: + reviewer = users_by_email[review.reviewer] + asked_at = happened( + DocumentEventAction.review_requested, author, after=timedelta(days=3) + ) + resolved_at = None + if review.answered: + if review.correction: + happened( + DocumentEventAction.edited, + reviewer, + content, + after=timedelta(days=1), + ) + resolved_at = happened( + DocumentEventAction.review_resolved, + reviewer, + after=timedelta(minutes=20), + ) + db.add( + ReviewRequest( + document_id=document.id, + requester_id=author.id if author else None, + reviewer_id=reviewer.id, + question=review.question, + resolved_at=resolved_at, + resolved_by_id=reviewer.id if resolved_at else None, + created_at=asked_at, + updated_at=resolved_at or asked_at, + ) + ) + + if slug in ARCHIVED_SLUGS: + document.status = DocumentStatus.archived + happened(DocumentEventAction.archived, author, after=timedelta(days=30)) + + document.created_at = started_at + document.updated_at = at + return document + + +def main() -> None: + if get_settings().env == "production": + sys.exit( + "Refusing to seed: PABLAN_ENV=production. " + "Seed data contains known dev credentials." + ) + asyncio.run(seed()) + + +if __name__ == "__main__": + main() diff --git a/backend/app/template_catalog.py b/backend/app/template_catalog.py new file mode 100644 index 0000000..b7cc041 --- /dev/null +++ b/backend/app/template_catalog.py @@ -0,0 +1,181 @@ +"""The shipped template catalog. + +`templates/` holds blueprints, not active templates. A blueprint is inert +product content: it sits in the catalog until an admin adds it, and adding +it produces an ordinary row in `templates` that is theirs, editable, +renameable, deletable, and never overwritten by a later deploy. + +That is the difference to the built-in help documents (`help_import.py`), +which ARE re-imported on every start and stay read-only: those describe how +Pablan works, so the product owns them. A template describes how a company +documents its own knowledge, so the company owns it. + +**The only automatic write to `templates` is `seed_starter_templates`, and +it runs exclusively against an empty table.** Everything else goes through +an explicit admin action in `api/templates.py`. This is load-bearing: a +startup upsert from the catalog would silently discard an admin's edits the +next time we improved a shipped blueprint. + +File naming: `..yaml` (`prozess.de.yaml`). A file +without a locale suffix is treated as belonging to the default locale, so +a customer can drop their own YAML into the directory without learning the +convention. See docs/authoring-templates.md. +""" + +import logging +from dataclasses import dataclass +from pathlib import Path + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models import Template +from app.template_import import TemplateImportError, parse_template, upsert_template + +logger = logging.getLogger("pablan.templates") + +SUPPORTED_LOCALES = ("de", "en") + +# What a brand-new instance starts with, in its default language: the four +# occasions on which anyone actually writes something down. Write it down +# (no structure at all — a blank page beats three generic headings), how we +# do this, what broke, and who you are. Everything more specific — a +# machine, a decision, a project review — is a deliberate add from the +# catalog, because a picker of ten options is a picker nobody reads. +STARTER_TEMPLATE_IDS = ( + "notiz", + "prozess", + "stoerung", + "person", +) + + +@dataclass(frozen=True) +class CatalogEntry: + """A blueprint as it sits on disk, never a DB row.""" + + id: str + locale: str + name: str + description: str + version: str + sections: int + source: str + + +def _split_stem(stem: str) -> tuple[str, str]: + """`prozess.de` -> (`prozess`, `de`); a stem without a + known locale suffix belongs to the default locale.""" + base, _, suffix = stem.rpartition(".") + if base and suffix in SUPPORTED_LOCALES: + return base, suffix + return stem, get_settings().default_locale + + +def load_catalog() -> list[CatalogEntry]: + """Parse every blueprint in the catalog directory. + + Read per call rather than cached: the directory is small, and a + self-hosted admin who drops a YAML file in it should not have to + restart the server to see it. + """ + directory = Path(get_settings().templates_dir) + if not directory.is_dir(): + logger.info( + "no template catalog directory", + extra={"event": "catalog_missing", "directory": str(directory)}, + ) + return [] + + entries: list[CatalogEntry] = [] + for path in sorted(directory.glob("*.yaml")): + source = path.read_text() + try: + template = parse_template(source) + except TemplateImportError as exc: + # A broken blueprint must not take the catalog down with it. + logger.error( + "catalog blueprint invalid", + extra={ + "event": "catalog_invalid", + "file": path.name, + "error": str(exc), + }, + ) + continue + _base, from_name = _split_stem(path.stem) + entries.append( + CatalogEntry( + id=template.id, + # The YAML says what language it is written in; the filename + # suffix is the human-facing convention and the fallback. + locale=template.locale or from_name, + name=template.name, + description=template.description or "", + version=template.version, + sections=len(template.sections), + source=source, + ) + ) + return entries + + +def catalog_for_locale(locale: str | None = None) -> list[CatalogEntry]: + """One entry per blueprint id, in `locale` where a variant exists. + + A blueprint with no variant in the requested language still shows up in + whatever language it has — a missing translation must not hide a + template from the admin who needs it. + """ + wanted = locale or get_settings().default_locale + best: dict[str, CatalogEntry] = {} + for entry in load_catalog(): + current = best.get(entry.id) + if current is None or (entry.locale == wanted and current.locale != wanted): + best[entry.id] = entry + return sorted(best.values(), key=lambda entry: entry.name) + + +def get_catalog_entry( + catalog_id: str, locale: str | None = None +) -> CatalogEntry | None: + return next( + (entry for entry in catalog_for_locale(locale) if entry.id == catalog_id), + None, + ) + + +async def seed_starter_templates(db: AsyncSession) -> int: + """Give a brand-new instance something to capture with. + + Only ever runs against an EMPTY templates table. Once an admin has + curated the list, added blueprints, deleted a starter, renamed things, + that curation is the truth and startup must not re-litigate it. This + is the ONLY automatic write to the table. + """ + existing = (await db.execute(select(func.count(Template.id)))).scalar_one() + if existing: + return 0 + + locale = get_settings().default_locale + by_id = {entry.id: entry for entry in catalog_for_locale(locale)} + + seeded = 0 + for catalog_id in STARTER_TEMPLATE_IDS: + entry = by_id.get(catalog_id) + if entry is None: + logger.error( + "starter template missing from catalog", + extra={"event": "catalog_starter_missing", "template": catalog_id}, + ) + continue + await upsert_template(db, parse_template(entry.source)) + seeded += 1 + + await db.commit() + logger.info( + "starter templates seeded", + extra={"event": "templates_seeded", "count": seeded, "locale": locale}, + ) + return seeded diff --git a/backend/app/template_import.py b/backend/app/template_import.py new file mode 100644 index 0000000..7688d3d --- /dev/null +++ b/backend/app/template_import.py @@ -0,0 +1,62 @@ +"""Template import: YAML → validated config → templates table. + +Every row in `templates` is the customer's own, whichever way it got there +— pasted YAML, a fork, or added from the shipped catalog +(`template_catalog.py`). None of them is read-only. +""" + +import logging + +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.authoring.schema import AuthoringTemplate +from app.models import Template + +logger = logging.getLogger("pablan.templates") + + +class TemplateImportError(Exception): + """Sanitized import failure — safe to surface to an admin.""" + + +def parse_template(source: str) -> AuthoringTemplate: + try: + raw = yaml.safe_load(source) + except yaml.YAMLError as exc: + raise TemplateImportError(f"Invalid YAML: {type(exc).__name__}") from None + if not isinstance(raw, dict): + raise TemplateImportError("Template must be a YAML mapping.") + try: + return AuthoringTemplate.model_validate(raw) + except ValueError as exc: + first_error = str(exc).splitlines()[1] if "\n" in str(exc) else str(exc) + raise TemplateImportError( + f"Template failed validation: {first_error.strip()}" + ) from None + + +async def upsert_template( + db: AsyncSession, template: AuthoringTemplate +) -> tuple[Template, bool]: + """Insert or update by the template's config id. Returns (row, created).""" + existing = ( + await db.execute( + select(Template).where(Template.config["id"].astext == template.id) + ) + ).scalar_one_or_none() + config = template.model_dump() + if existing is not None: + existing.name = template.name + existing.version = template.version + existing.config = config + return existing, False + row = Template( + name=template.name, + version=template.version, + config=config, + ) + db.add(row) + await db.flush() + return row, True diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..d85e712 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "pablan-backend" +version = "0.1.0" +description = "Pablan backend — FastAPI app: interviews, RAG, knowledge management" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "alembic>=1.18.5", + "argon2-cffi>=25.1.0", + "asyncpg>=0.31.0", + "fastapi>=0.115", + "openai>=2.46.0", + "pgvector>=0.5.0", + "pydantic-settings>=2.14.2", + "pyyaml>=6.0.3", + "sqlalchemy[asyncio]>=2.0.51", + "uvicorn[standard]>=0.34", +] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=8", + "pytest-asyncio>=1.4.0", + "ruff>=0.11", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +pythonpath = ["."] +# Evals need live LLM endpoints; the normal suite must run without them. +addopts = "-m 'not eval'" +markers = [ + "eval: LLM/retrieval evals against the configured endpoints (make eval)", +] + +[tool.ruff] +target-version = "py312" + +[tool.ruff.lint] +extend-select = ["I", "B"] diff --git a/backend/scripts/check-no-ui-strings.py b/backend/scripts/check-no-ui-strings.py new file mode 100644 index 0000000..c120645 --- /dev/null +++ b/backend/scripts/check-no-ui-strings.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""The backend never renders UI-language strings (CLAUDE.md). + +API errors are `{detail, code}` and the frontend translates by `code`; SSE +`state` events carry counts and markers, and the frontend writes the +sentence. That contract is only worth anything if it is checked: the moment +one German error message ships from the backend, an English interface has a +German sentence in it that no message file can reach. + +This looks for German text in the places that reach a user: `ApiError` +messages and `detail` fields. Prompts, template content, the fixture corpus +and comments are deliberately out of scope, because those are CONTENT and +German is correct there. +""" + +import re +import sys +from pathlib import Path + +APP = Path(__file__).resolve().parents[1] / "app" + +# Words that do not appear in English but are common in German UI copy. +# Umlauts alone are too weak (proper nouns), and a full language detector +# would be a dependency for a rule this narrow. +GERMAN_MARKERS = re.compile( + r"\b(" + r"nicht|nichts|kein|keine|keinen|wurde|wurden|werden|wird|" + r"bitte|erneut|konnte|könnte|müssen|muss|darfst|kannst|" + r"deine|deinem|deiner|dein|Ihre|Ihrem|" + r"Fehler|Anfrage|Dokument|Abteilung|Benutzer|Gespräch|Vorlage" + r")\b", + re.IGNORECASE, +) + +# Only the strings that can reach a user, not every literal in the file. +USER_FACING = re.compile( + r"""ApiError\s*\(\s*\d+\s*,\s*(?P["'])(?P.*?)(?P=q)""" + r"""|detail\s*=\s*(?P["'])(?P.*?)(?P=q2)""", + re.DOTALL, +) + + +def main() -> int: + problems: list[str] = [] + for path in sorted(APP.rglob("*.py")): + source = path.read_text(encoding="utf-8") + for match in USER_FACING.finditer(source): + text = match.group("text") or match.group("text2") or "" + if not GERMAN_MARKERS.search(text): + continue + line = source[: match.start()].count("\n") + 1 + relative = path.relative_to(APP.parent) + problems.append( + f"{relative}:{line}: user-facing string looks German: " + f"{text[:70]!r}. The backend returns codes, the frontend " + f"writes the sentence (docs/i18n.md)." + ) + + if problems: + print("check-no-ui-strings: FAILED", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + return 1 + + print("check-no-ui-strings: no UI-language strings in backend responses") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9daf22b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,176 @@ +import asyncio +from collections.abc import AsyncIterator, Iterator +from pathlib import Path + +import httpx +import pytest +from alembic.config import Config +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.engine import make_url +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.pool import NullPool + +from alembic import command +from app.auth.passwords import hash_password +from app.config import get_settings +from app.db import get_db +from app.llm import client as llm_client +from app.main import app +from app.metrics import metrics +from app.models import Department, User, UserRole +from tests.fake_openai import FakeOpenAI + +# Tests run against a dedicated database, never the dev one. +TEST_DB_NAME = "pablan_test" + +# Every table a test may write to — a new table must be added here, or +# state leaks into the next test. +_TABLES = ( + "messages, conversations, doc_permissions, document_events, chunks, " + "documents, auth_sessions, users, templates, jobs, departments, " + "llm_settings, prompt_settings" +) + + +@pytest.fixture(scope="session") +def test_db_url() -> str: + """Drop, recreate and migrate the test database once per test session.""" + admin_url = make_url(get_settings().database_url) + assert admin_url.database != TEST_DB_NAME + test_url = admin_url.set(database=TEST_DB_NAME) + + async def prepare() -> None: + engine = create_async_engine( + admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool + ) + async with engine.connect() as conn: + await conn.execute( + text(f"DROP DATABASE IF EXISTS {TEST_DB_NAME} WITH (FORCE)") + ) + await conn.execute(text(f"CREATE DATABASE {TEST_DB_NAME}")) + await engine.dispose() + + # Sync fixture: no event loop is running here, so asyncio.run is safe — + # as is alembic's command API (env.py calls asyncio.run itself). + asyncio.run(prepare()) + + # str(URL) masks the password as "***" — render it explicitly. + url_string = test_url.render_as_string(hide_password=False) + config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")) + config.set_main_option("sqlalchemy.url", url_string.replace("%", "%%")) + command.upgrade(config, "head") + return url_string + + +@pytest.fixture +async def db_engine(test_db_url: str) -> AsyncIterator[AsyncEngine]: + engine = create_async_engine(test_db_url, poolclass=NullPool) + yield engine + await engine.dispose() + + +@pytest.fixture(autouse=True) +async def _clean_tables(db_engine: AsyncEngine) -> None: + async with db_engine.begin() as conn: + await conn.execute(text(f"TRUNCATE TABLE {_TABLES} CASCADE")) + # The prompt-override cache is module-level and outlives a truncated + # prompt_settings, so drop it too or an override leaks into the next test. + from app.prompts import overrides as prompt_overrides + + prompt_overrides.clear() + + +@pytest.fixture +async def db(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + factory = async_sessionmaker(db_engine, expire_on_commit=False) + async with factory() as session: + yield session + + +@pytest.fixture +async def client(db_engine: AsyncEngine) -> AsyncIterator[AsyncClient]: + factory = async_sessionmaker(db_engine, expire_on_commit=False) + + async def override_get_db() -> AsyncIterator[AsyncSession]: + async with factory() as session: + yield session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + app.dependency_overrides.clear() + + +@pytest.fixture +async def seeded_user(db: AsyncSession) -> User: + department = Department(name="Engineering") + db.add(department) + await db.flush() + user = User( + email="pablo@test.dev", + name="Pablo Test", + role=UserRole.member, + password_hash=hash_password("secret123"), + department_id=department.id, + ) + db.add(user) + await db.commit() + return user + + +@pytest.fixture +async def seeded_admin(db: AsyncSession) -> User: + department = Department(name="Administration") + db.add(department) + await db.flush() + user = User( + email="florian@test.dev", + name="Florian Test", + role=UserRole.admin, + password_hash=hash_password("secret123"), + department_id=department.id, + ) + db.add(user) + await db.commit() + return user + + +@pytest.fixture(autouse=True) +def _reset_metrics() -> Iterator[None]: + metrics.reset() + yield + + +@pytest.fixture +def fake_embed(monkeypatch: pytest.MonkeyPatch) -> None: + """Deterministic embeddings instead of llm.client.embed — indexing and + retrieval tests never need a live embedding endpoint.""" + from app.rag import indexing, retrieval, similarity + from tests.embedding_stub import fake_embed as stub + + # Every module that embeds: patching one and forgetting another shows up + # as a live endpoint call in a test that was supposed to be offline. + monkeypatch.setattr(indexing, "embed", stub) + monkeypatch.setattr(retrieval, "embed", stub) + monkeypatch.setattr(similarity, "embed", stub) + + +@pytest.fixture +def fake_llm(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeOpenAI]: + """Route the LLM client at a fake OpenAI-compatible ASGI app.""" + fake = FakeOpenAI() + monkeypatch.setattr( + llm_client, + "_http_client_factory", + lambda: httpx.AsyncClient(transport=ASGITransport(app=fake.app)), + ) + llm_client._client_for.cache_clear() + yield fake + llm_client._client_for.cache_clear() diff --git a/backend/tests/embedding_stub.py b/backend/tests/embedding_stub.py new file mode 100644 index 0000000..8f138e6 --- /dev/null +++ b/backend/tests/embedding_stub.py @@ -0,0 +1,24 @@ +"""Deterministic fake embeddings for indexing/retrieval tests. + +Identical text maps to an identical unit vector (cosine distance 0); +unrelated texts land near-orthogonal. Semantics are exercised by the evals +against the real endpoint — these tests cover the SQL plumbing. +""" + +import hashlib +import math +import random + +from app.models import EMBEDDING_DIM + + +def deterministic_embedding(text: str) -> list[float]: + seed = hashlib.sha256(text.encode()).digest() + rng = random.Random(seed) + vector = [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIM)] + norm = math.sqrt(sum(value * value for value in vector)) + return [value / norm for value in vector] + + +async def fake_embed(texts: list[str], *, role: str = "embedding") -> list[list[float]]: + return [deterministic_embedding(text) for text in texts] diff --git a/backend/tests/evals/test_refine_eval.py b/backend/tests/evals/test_refine_eval.py new file mode 100644 index 0000000..db11ec0 --- /dev/null +++ b/backend/tests/evals/test_refine_eval.py @@ -0,0 +1,88 @@ +"""Section-refinement eval (`make eval`). + +Runs against the CONFIGURED chat endpoint — it must be live. Proves the core +of writing-first capture on a real model: a rough section becomes mature +prose, its heading is kept, ONLY that section comes back (FIM), the language +is preserved, and no load-bearing fact is dropped. +""" + +import pytest + +from app.authoring.prompts import render_refine_prompt +from app.llm.client import chat_stream + +pytestmark = pytest.mark.eval + +# Same flag the /refine endpoint uses: turn off the reasoning channel so the +# call is fast and the eval measures the answer, not the thinking. +_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}} + +PREFIX = "## Zweck\nDieser Ablauf beschreibt die monatliche Rechnungsstellung." +SECTION = ( + "## Ablauf\n" + "also man macht das am monatsanfang. erst die stunden exportieren, dann in " + "die vorlage kopieren und per mail raus. bei einer PO muss die nummer drauf." +) +SUFFIX = "## Fallstricke" + + +async def _refine( + section: str, + prefix: str, + suffix: str, + knowledge: list[str] | None = None, +) -> str: + messages = render_refine_prompt( + section, + prefix=prefix, + suffix=suffix, + persona="Du bist ein präziser Fachredakteur, der Abläufe dokumentiert.", + hint="Die Schritte in Reihenfolge, als Liste.", + knowledge=knowledge, + ) + parts: list[str] = [] + async for token in chat_stream( + messages, role="chat", temperature=0.4, extra_body=_NO_THINKING + ): + parts.append(token) + return "".join(parts).strip() + + +async def test_refinement_matures_only_the_active_section() -> None: + out = await _refine(SECTION, PREFIX, SUFFIX) + + assert out, "refinement returned nothing" + # Kept the section's own heading. + assert out.lstrip().startswith("## Ablauf") + # ONLY this section: the surrounding headings must not be re-emitted. + assert "## Zweck" not in out + assert "## Fallstricke" not in out + # Used the input and kept the load-bearing PO fact. + assert any(token in out for token in ("PO", "Purchase", "Bestell")) + # Language preserved (German): a switch to English would be a regression. + lowered = out.lower() + assert any(word in lowered for word in (" der ", " die ", " und ", " wird ")) + + +# A related document the company already has. It shares the topic but carries a +# distinctive fact the section itself never mentions. +GROUNDING = [ + 'From "Zahlungsbedingungen" (Fristen): Rechnungen sind binnen 14 Tagen ' + "fällig, mit zwei Prozent Skonto bei Zahlung binnen sieben Tagen." +] + + +async def test_grounding_informs_without_being_copied_in() -> None: + out = await _refine(SECTION, PREFIX, SUFFIX, knowledge=GROUNDING) + + assert out, "refinement returned nothing" + # Grounding does not break the one-section contract. + assert out.lstrip().startswith("## Ablauf") + assert "## Zweck" not in out and "## Fallstricke" not in out + # The section keeps its own load-bearing fact. + assert any(token in out for token in ("PO", "Purchase", "Bestell")) + # Grounding is a reference, not a fact source: the related document's own + # detail must not be imported into this section, and the reference framing + # must not be echoed back. + assert "Skonto" not in out + assert "Zahlungsbedingungen" not in out diff --git a/backend/tests/evals/test_retrieval_eval.py b/backend/tests/evals/test_retrieval_eval.py new file mode 100644 index 0000000..eb8b512 --- /dev/null +++ b/backend/tests/evals/test_retrieval_eval.py @@ -0,0 +1,101 @@ +"""Retrieval quality eval over the golden query set (`make eval`). + +Runs against the CONFIGURED embedding endpoint — it must be live. The +corpus is indexed as public documents for a single eval user: this measures +retrieval QUALITY; permission behavior is covered by the unit tests. +""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.models import ( + Department, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.indexing import reindex_document +from app.rag.retrieval import results_are_low_confidence, search +from tests.fixtures.loader import load_corpus, load_golden_queries + +pytestmark = pytest.mark.eval + +RECALL_FLOOR = 0.8 # baseline 2026-07: 25/25 = 1.00 + + +async def test_retrieval_golden_set(db: AsyncSession) -> None: + corpus = load_corpus() + queries = load_golden_queries() + + department = Department(name="Eval") + db.add(department) + await db.flush() + user = User( + email="eval@test.dev", + name="Eval User", + role=UserRole.member, + password_hash=hash_password("eval-only"), + department_id=department.id, + ) + db.add(user) + await db.flush() + + slug_by_document_id = {} + for doc in corpus: + document = Document( + title=doc.title, + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md=doc.content_md, + meta={"slug": doc.slug}, + author_id=user.id, + department_id=department.id, + ) + db.add(document) + await db.flush() + slug_by_document_id[document.id] = doc.slug + await reindex_document(db, document) # real embeddings + await db.commit() + + hits = 0 + expected_total = 0 + misses: list[str] = [] + no_answer_violations: list[str] = [] + report: list[str] = [] + + for entry in queries: + results = await search(db, entry["query"], user=user, top_k=5) + top_slugs = [slug_by_document_id[r.document_id] for r in results] + if entry["expected"]: + expected_total += 1 + hit = any(slug in entry["expected"] for slug in top_slugs) + hits += int(hit) + if not hit: + misses.append(entry["query"]) + report.append( + f"{'HIT ' if hit else 'MISS'} {entry['query'][:58]!r} -> {top_slugs[:3]}" + ) + else: + top = results[0] if results else None + fts = top.fts_match if top else False + distance = top.vector_distance if top else None + report.append( + f"NOANS {entry['query'][:58]!r} fts={fts} distance={distance:.3f}" + if distance is not None + else f"NOANS {entry['query'][:58]!r} fts={fts} distance=None" + ) + confident_nothing = results_are_low_confidence(results) + if not confident_nothing: + no_answer_violations.append(entry["query"]) + + recall = hits / expected_total + print("\n".join(report)) + print(f"\nrecall@5: {hits}/{expected_total} = {recall:.2f}") + + assert recall >= RECALL_FLOOR, f"recall {recall:.2f} below floor; misses: {misses}" + assert not no_answer_violations, ( + f"no-answer queries returned confident results: {no_answer_violations}" + ) diff --git a/backend/tests/evals/test_self_knowledge_eval.py b/backend/tests/evals/test_self_knowledge_eval.py new file mode 100644 index 0000000..e10f763 --- /dev/null +++ b/backend/tests/evals/test_self_knowledge_eval.py @@ -0,0 +1,153 @@ +"""Does Pablan find its own help pages when asked about itself? (`make eval`) + +Runs against the CONFIGURED embedding endpoint — it must be live. + +The help pages under `help/` are imported into `documents` on every start and +are searchable like anything else, which is what lets Pablan answer "how do I +share a document?" from its own retrieval path instead of from a hardcoded +FAQ. That only works if the question actually retrieves the right page, and +the pages are written in one voice about one product, so they compete with +each other far more than the corpus documents do. This measures exactly that: +a question a user would type, against the help pages as shipped. + +The company corpus is indexed alongside them on purpose — an instance is +never only help pages, and "how do I report a fault?" must not pull the help +page about writing documents. +""" + +from pathlib import Path + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.config import get_settings +from app.help_import import parse_help_document +from app.models import ( + Department, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.indexing import reindex_document +from app.rag.retrieval import results_are_low_confidence, search +from tests.fixtures.loader import load_corpus + +pytestmark = pytest.mark.eval + +RECALL_FLOOR = 0.8 # baseline 2026-08: 10/10 = 1.00 + +# Questions in the words a user would use, and the help page (`key` in the +# file's frontmatter) that has to be in the top 5. +QUESTIONS: list[tuple[str, set[str]]] = [ + ("Wie funktioniert Pablan?", {"pablan-ueberblick"}), + ("Was ist der Unterschied zwischen Fragen und Festhalten?", {"pablan-ueberblick"}), + ( + "Wie teile ich ein Dokument mit einer anderen Abteilung?", + {"dokumente-und-sichtbarkeit"}, + ), + ("Was bedeutet der Status Entwurf?", {"dokumente-und-sichtbarkeit"}), + ( + "Wie bitte ich eine Kollegin, ein Dokument zu prüfen?", + {"dokumente-und-sichtbarkeit", "wissen-festhalten"}, + ), + ("Warum sehe ich unter einer Antwort Quellen?", {"fragen-und-antworten"}), + ("Wie schreibe ich ein Dokument mit der Assistenz?", {"wissen-festhalten"}), + ("Wo stelle ich die Sprachmodell-Endpunkte ein?", {"administration"}), + ("Wie lege ich eine neue Abteilung an?", {"administration"}), + ("Wo finde ich das Profil einer Kollegin?", {"kolleginnen-und-profil"}), +] + +# A question about the company, asked in an instance that also has help pages: +# the help must stay out of the way. +COMPANY_QUESTIONS = [ + "Welcher Solldruck gilt für die Hydraulikpresse?", + "Wie beantrage ich Urlaub?", + "Was bedeutet Fehlercode E-203?", +] + + +async def test_help_pages_answer_questions_about_the_product( + db: AsyncSession, +) -> None: + department = Department(name="Eval") + db.add(department) + await db.flush() + user = User( + email="eval@test.dev", + name="Eval User", + role=UserRole.member, + password_hash=hash_password("eval-only"), + department_id=department.id, + ) + db.add(user) + await db.flush() + + help_key_by_document_id: dict = {} + for path in sorted(Path(get_settings().help_dir).glob("*.md")): + key, title, body = parse_help_document(path.read_text()) + document = Document( + title=title, + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md=body, + meta={"help": key}, + is_builtin=True, + ) + db.add(document) + await db.flush() + help_key_by_document_id[document.id] = key + await reindex_document(db, document) + + # The company corpus, so the help pages have real competition. + for doc in load_corpus(): + document = Document( + title=doc.title, + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md=doc.content_md, + meta={"slug": doc.slug}, + author_id=user.id, + department_id=department.id, + ) + db.add(document) + await db.flush() + await reindex_document(db, document) + await db.commit() + + hits = 0 + report: list[str] = [] + for question, expected in QUESTIONS: + results = await search(db, question, user=user, top_k=5) + found = [help_key_by_document_id.get(result.document_id) for result in results] + hit = any(key in expected for key in found if key) + hits += int(hit) + report.append( + f"{'HIT ' if hit else 'MISS'} {question[:52]!r} -> {[f for f in found][:3]}" + ) + + # A product question must also be ANSWERABLE, not just retrieved: a + # low-confidence result set is presented as "nothing documented", which + # for a question about Pablan itself is simply wrong. + unanswered = [] + for question, _ in QUESTIONS: + results = await search(db, question, user=user, top_k=5) + if results_are_low_confidence(results): + unanswered.append(question) + + leaked = [] + for question in COMPANY_QUESTIONS: + results = await search(db, question, user=user, top_k=5) + top = results[0] if results else None + if top is not None and top.document_id in help_key_by_document_id: + leaked.append(f"{question!r} -> {help_key_by_document_id[top.document_id]}") + + recall = hits / len(QUESTIONS) + print("\n" + "\n".join(report)) + print(f"\nself-knowledge recall@5: {hits}/{len(QUESTIONS)} = {recall:.2f}") + + assert not unanswered, f"asked about itself and had no answer: {unanswered}" + assert not leaked, f"a help page outranked the company documents: {leaked}" + assert recall >= RECALL_FLOOR, f"self-knowledge recall {recall:.2f}" diff --git a/backend/tests/evals/test_topic_retrieval_eval.py b/backend/tests/evals/test_topic_retrieval_eval.py new file mode 100644 index 0000000..0147626 --- /dev/null +++ b/backend/tests/evals/test_topic_retrieval_eval.py @@ -0,0 +1,111 @@ +"""Topic-summary vs raw-message retrieval (`make eval`). + +Query mode retrieves over the last user message today. On a topic-losing +follow-up ("Hi", "what was my first question") that message finds nothing, even +when the conversation is clearly about a documented subject. An LLM topic +summary of the whole conversation should recover it. This eval measures how +much better — the number that decides whether query mode should adopt it. +Runs against the CONFIGURED embedding + utility endpoints. +""" + +import pytest +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.authoring.prompts import render_topic_summary_prompt +from app.llm.client import chat_json +from app.models import ( + Department, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.indexing import reindex_document +from app.rag.retrieval import search +from tests.fixtures.loader import load_conversation_snippets, load_corpus + +pytestmark = pytest.mark.eval + +_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}} + + +class _Topic(BaseModel): + topic: str + + +def _transcript(messages: list[dict[str, str]]) -> str: + return "\n".join(f"{message['role']}: {message['content']}" for message in messages) + + +async def _hit(db: AsyncSession, query: str, user: User, slug_by_id, expected) -> bool: + results = await search(db, query, user=user, top_k=5) + slugs = {slug_by_id.get(result.document_id) for result in results} + return bool(expected & slugs) + + +async def test_topic_summary_beats_the_raw_message(db: AsyncSession) -> None: + corpus = load_corpus() + snippets = load_conversation_snippets() + + department = Department(name="Eval") + db.add(department) + await db.flush() + user = User( + email="eval@test.dev", + name="Eval User", + role=UserRole.member, + password_hash=hash_password("eval-only"), + department_id=department.id, + ) + db.add(user) + await db.flush() + + slug_by_id: dict = {} + for doc in corpus: + document = Document( + title=doc.title, + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md=doc.content_md, + author_id=user.id, + department_id=department.id, + meta={"slug": doc.slug}, + ) + db.add(document) + await db.flush() + await reindex_document(db, document) + slug_by_id[document.id] = doc.slug + await db.commit() + + raw_hits = 0 + topic_hits = 0 + for snippet in snippets: + expected = set(snippet["expected"]) + last = snippet["messages"][-1]["content"] + raw_hit = await _hit(db, last, user, slug_by_id, expected) + + topic = ( + await chat_json( + render_topic_summary_prompt(_transcript(snippet["messages"])), + _Topic, + extra_body=_NO_THINKING, + ) + ).topic + topic_hit = await _hit(db, topic, user, slug_by_id, expected) + + raw_hits += int(raw_hit) + topic_hits += int(topic_hit) + print( + f"[{'HIT ' if topic_hit else 'MISS'}] expected={expected} " + f"raw={'hit' if raw_hit else 'miss'} topic={topic!r}" + ) + + n = len(snippets) + print(f"\nraw recall {raw_hits}/{n} | topic-summary recall {topic_hits}/{n}") + # The whole point: a topic summary must not do worse than the raw message, + # and must actually recover the documented subject on these follow-ups. + assert topic_hits >= raw_hits + assert topic_hits >= max(1, raw_hits + 1) diff --git a/backend/tests/fake_openai.py b/backend/tests/fake_openai.py new file mode 100644 index 0000000..edb6f8a --- /dev/null +++ b/backend/tests/fake_openai.py @@ -0,0 +1,151 @@ +"""Minimal fake OpenAI-compatible server as an ASGI app (no real LLM). + +Wire it into the client via httpx.ASGITransport — see the fake_llm fixture. +Chat behavior is scripted through `chat_responses`; each entry is one of: + {"content": "..."} plain completion content + {"chunks": ["a", "b"]} streamed delta pieces + {"status": 500} induced HTTP error +An empty script falls back to `default_content`. +""" + +import json +from dataclasses import dataclass, field +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse + +_USAGE = {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10} + + +@dataclass +class FakeOpenAI: + chat_responses: list[dict[str, Any]] = field(default_factory=list) + default_content: str = "pong" + embedding_dim: int = 8 + # What GET /v1/models reports; None means the route is missing (404). + served_models: list[str] | None = field( + default_factory=lambda: ["gemma-3-27b", "bge-m3"] + ) + requests: list[dict[str, Any]] = field(default_factory=list) + + def __post_init__(self) -> None: + self.app = self._build_app() + + def _build_app(self) -> FastAPI: + app = FastAPI() + + @app.post("/v1/chat/completions") + async def chat_completions(request: Request) -> Any: + body = await request.json() + self.requests.append(body) + plan = ( + self.chat_responses.pop(0) + if self.chat_responses + else {"content": self.default_content} + ) + if "status" in plan: + return JSONResponse( + {"error": {"message": "induced failure", "type": "server_error"}}, + status_code=plan["status"], + ) + content = plan.get("content", self.default_content) + if body.get("stream"): + pieces = plan.get("chunks", [content]) + include_usage = bool( + (body.get("stream_options") or {}).get("include_usage") + ) + return StreamingResponse( + self._stream(body["model"], pieces, include_usage), + media_type="text/event-stream", + ) + return JSONResponse( + { + "id": "cmpl-fake", + "object": "chat.completion", + "created": 0, + "model": body["model"], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": _USAGE, + } + ) + + @app.post("/v1/embeddings") + async def embeddings(request: Request) -> Any: + body = await request.json() + self.requests.append(body) + inputs = body["input"] + if isinstance(inputs, str): + inputs = [inputs] + return JSONResponse( + { + "object": "list", + "model": body["model"], + "data": [ + { + "object": "embedding", + "index": i, + "embedding": [float(i)] * self.embedding_dim, + } + for i in range(len(inputs)) + ], + "usage": { + "prompt_tokens": len(inputs), + "total_tokens": len(inputs), + }, + } + ) + + @app.get("/v1/models") + async def models() -> Any: + # `served_models = None` mimics an endpoint without the route, + # which plenty of OpenAI-compatible servers genuinely lack. + if self.served_models is None: + return JSONResponse( + {"error": {"message": "not found", "type": "invalid_request"}}, + status_code=404, + ) + return JSONResponse( + { + "object": "list", + "data": [ + {"id": name, "object": "model", "owned_by": "fake"} + for name in self.served_models + ], + } + ) + + return app + + @staticmethod + async def _stream(model: str, pieces: list[str], include_usage: bool) -> Any: + def chunk(delta: dict[str, Any], finish: str | None) -> str: + payload = { + "id": "cmpl-fake", + "object": "chat.completion.chunk", + "created": 0, + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + return f"data: {json.dumps(payload)}\n\n" + + for piece in pieces: + yield chunk({"content": piece}, None) + yield chunk({}, "stop") + if include_usage: + final = { + "id": "cmpl-fake", + "object": "chat.completion.chunk", + "created": 0, + "model": model, + "choices": [], + "usage": _USAGE, + } + yield f"data: {json.dumps(final)}\n\n" + yield "data: [DONE]\n\n" diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/fixtures/conversation_snippets.yaml b/backend/tests/fixtures/conversation_snippets.yaml new file mode 100644 index 0000000..ebb00ee --- /dev/null +++ b/backend/tests/fixtures/conversation_snippets.yaml @@ -0,0 +1,41 @@ +# Short chats whose LAST message loses the topic (a greeting, a meta-question), +# even though the conversation is clearly about a documented subject. Used by +# tests/evals/test_topic_retrieval_eval.py to compare retrieval over an LLM +# topic summary against retrieval over the raw last message. `expected` is the +# corpus slug the conversation is really about. German product content. + +- messages: + - role: user + content: "Wie beantrage ich Urlaub?" + - role: assistant + content: "Urlaub beantragst du digital im Personalportal unter Abwesenheit, neuer Antrag." + - role: user + content: "Und was war eigentlich meine erste Frage?" + expected: [urlaubsantrag-prozess] + +- messages: + - role: user + content: "Wie oft muss die CNC-Fräse F-350 gewartet werden?" + - role: assistant + content: "Die Intervalle stehen im Wartungsplan der F-350, gestaffelt nach Betriebsstunden." + - role: user + content: "Alles klar, danke dir." + expected: [wartungsplan-cnc-f350] + +- messages: + - role: user + content: "Wie rechne ich eine Dienstreise ab?" + - role: assistant + content: "Reisekosten reichst du mit allen Belegen über das übliche Formular ein." + - role: user + content: "Hi" + expected: [reisekosten] + +- messages: + - role: user + content: "Was bedeutet der Fehlercode an der S7-Steuerung in Halle 2?" + - role: assistant + content: "Die Fehlercodes der S7 sind dokumentiert, jeder Code hat eine Ursache und Behebung." + - role: user + content: "Kannst du das nochmal wiederholen?" + expected: [fehlercodes-sps-s7] diff --git a/backend/tests/fixtures/corpus/angebotskalkulation.md b/backend/tests/fixtures/corpus/angebotskalkulation.md new file mode 100644 index 0000000..7924037 --- /dev/null +++ b/backend/tests/fixtures/corpus/angebotskalkulation.md @@ -0,0 +1,35 @@ +--- +id: angebotskalkulation +title: "Angebotskalkulation Sondermaschinen" +department: Sales +visibility: restricted +grants: [Sales] +--- + +# Angebotskalkulation Sondermaschinen + +Vertraulich — enthält unsere Kalkulationslogik und Zuschlagssätze. + +## Kalkulationsschema + +Basis ist die Stückliste aus der Konstruktion plus die geplanten +Fertigungsstunden der Arbeitsvorbereitung (AV). Darauf kommen: + +- Materialgemeinkosten: 12 % +- Fertigungsstundensatz: laut aktueller Satztabelle im Laufwerk + V:\Kalkulation (wird jährlich zum 1. März aktualisiert) +- Engineering-Stunden werden separat ausgewiesen, nie in den + Maschinenpreis eingerechnet +- Projektzuschlag für Risiko: 5 % bei Standardnähe, 15 % bei Neuland + +## Untergrenzen + +Angebote unter Deckungsbeitrag 2 gehen nicht raus. Ausnahmen genehmigt +ausschließlich die Geschäftsführung schriftlich — „strategischer Kunde" +ist kein Selbstbedienungsargument. + +## Gültigkeit und Nachkalkulation + +Angebote gelten 60 Tage. Nach Auftragsabschluss macht die AV eine +Nachkalkulation; Abweichungen über 10 % werden im Vertriebsmeeting +besprochen, damit die Sätze realistisch bleiben. diff --git a/backend/tests/fixtures/corpus/crm-leitfaden.md b/backend/tests/fixtures/corpus/crm-leitfaden.md new file mode 100644 index 0000000..5cd7bc7 --- /dev/null +++ b/backend/tests/fixtures/corpus/crm-leitfaden.md @@ -0,0 +1,37 @@ +--- +id: crm-leitfaden +title: "CRM-Pflege: Leitfaden für den Vertrieb" +department: Sales +visibility: department +--- + +# CRM-Pflege: Leitfaden für den Vertrieb + +Unser CRM ist nur so gut wie seine Daten. Angebote ohne gepflegte +Kontakthistorie sind im Urlaubsfall für niemanden nachvollziehbar. + +## Pflichtfelder je Kontakt + +- Firma, Ansprechpartner mit Funktion +- Branche und Maschinenpark (Freitextfeld „Ausstattung") +- Nächster vereinbarter Schritt mit Datum + +## Aktivitäten erfassen + +Jedes Telefonat und jeder Besuch wird noch am selben Tag als Aktivität +erfasst. Kurzform reicht: Anlass, Ergebnis, nächster Schritt. E-Mails +zieht das CRM automatisch, wenn die Adresse am Kontakt hinterlegt ist. + +## Verkaufschancen + +Eine Verkaufschance wird angelegt, sobald der Kunde ein konkretes +Projekt nennt — nicht erst beim Angebot. Phasen: Anfrage, Angebot, +Verhandlung, Auftrag/Verloren. Beim Schließen als „Verloren" immer den +Grund auswählen; die Auswertung geht quartalsweise an die +Geschäftsführung. + +## Wiedervorlagen + +Wiedervorlagen gehören ins CRM, nicht in Outlook. Der Montagsbericht +zieht automatisch alle überfälligen Wiedervorlagen — wer seine Liste +leer hält, taucht dort nicht auf. diff --git a/backend/tests/fixtures/corpus/datenschutz-grundlagen.md b/backend/tests/fixtures/corpus/datenschutz-grundlagen.md new file mode 100644 index 0000000..fc46b4b --- /dev/null +++ b/backend/tests/fixtures/corpus/datenschutz-grundlagen.md @@ -0,0 +1,33 @@ +--- +id: datenschutz-grundlagen +title: "Datenschutz im Arbeitsalltag" +department: Administration +visibility: public +--- + +# Datenschutz im Arbeitsalltag + +Kurzfassung der wichtigsten DSGVO-Regeln für den Alltag bei Nordwind. +Die vollständige Datenschutzrichtlinie liegt im Intranet; Ansprechpartner +ist der externe Datenschutzbeauftragte (Kontakt am Schwarzen Brett). + +## Grundregeln + +- Personenbezogene Daten nur erheben, wenn sie für die Aufgabe nötig sind +- Keine Kundendaten auf private Geräte oder in private Cloud-Speicher +- Bildschirm sperren beim Verlassen des Platzes (Windows-Taste + L) +- Unterlagen mit Personenbezug in den Datenschutztonnen entsorgen, nicht + im Papiermüll + +## E-Mail und Verteiler + +Bei Rundmails an externe Empfänger immer BCC verwenden. Bewerbungen +werden ausschließlich von der Personalabteilung weitergeleitet — auch +intern nicht „mal eben" an Kollegen schicken. + +## Datenpannen melden + +Verlorener Laptop, falsch versendete E-Mail mit Personendaten, +verdächtige Anmeldungen: sofort an it@nordwind-maschinenbau.example +UND die Personalabteilung melden. Die 72-Stunden-Meldefrist der DSGVO +beginnt, sobald irgendjemand im Unternehmen von der Panne weiß. diff --git a/backend/tests/fixtures/corpus/edi-rechnungen.md b/backend/tests/fixtures/corpus/edi-rechnungen.md new file mode 100644 index 0000000..e650b43 --- /dev/null +++ b/backend/tests/fixtures/corpus/edi-rechnungen.md @@ -0,0 +1,38 @@ +--- +id: edi-rechnungen +title: "EDI-Rechnungsversand an Großkunden" +department: Administration +visibility: department +--- + +# EDI-Rechnungsversand an Großkunden + +EDI steht für Electronic Data Interchange — den elektronischen +Datenaustausch strukturierter Belege direkt zwischen den ERP-Systemen. +Drei Großkunden erhalten ihre Rechnungen ausschließlich per EDI; +Papier- oder PDF-Rechnungen werden dort automatisch abgewiesen. + +## Angebundene Kunden + +- Bremer & Söhne: EDIFACT INVOIC über den Provider Retarus +- MK Antriebstechnik: ZUGFeRD-PDF per E-Mail an deren Rechnungseingang +- Feldmann Gruppe: XRechnung über das Kundenportal + +## Tagesablauf + +Der Rechnungslauf erzeugt die EDI-Nachrichten automatisch um 17:00 Uhr. +Danach im ERP unter „EDI-Monitor" prüfen, ob alle Nachrichten den Status +„übertragen" haben. + +## Fehlerbehandlung + +Bleibt eine Nachricht auf „fehlerhaft" stehen: + +1. Fehlertext im EDI-Monitor öffnen — meist fehlt die Bestellnummer des + Kunden auf der Auftragsposition +2. Auftrag korrigieren und die Nachricht erneut auslösen +3. Bei Übertragungsfehlern (Provider nicht erreichbar) eine Stunde + warten, dann erneut senden; danach Ticket beim Provider öffnen + +Unklare Fälle bitte nicht liegen lassen: Bei Bremer & Söhne führt jede +verspätete Rechnung zu Skontoabzug-Diskussionen. diff --git a/backend/tests/fixtures/corpus/fehlercodes-sps-s7.md b/backend/tests/fixtures/corpus/fehlercodes-sps-s7.md new file mode 100644 index 0000000..7cd7d3a --- /dev/null +++ b/backend/tests/fixtures/corpus/fehlercodes-sps-s7.md @@ -0,0 +1,38 @@ +--- +id: fehlercodes-sps-s7 +title: "Fehlercodes der S7-Steuerung (Halle 2)" +department: Engineering +visibility: department +--- + +# Fehlercodes der S7-Steuerung (Halle 2) + +Die Anlagen in Halle 2 melden Störungen über das Panel mit E-Nummern. +Die SPS (speicherprogrammierbare Steuerung) schreibt zusätzlich ein +Diagnosepuffer-Protokoll, das bei Servicefällen exportiert werden muss. + +## Häufige Fehlercodes + +| Code | Bedeutung | Sofortmaßnahme | +|-------|------------------------------------|----------------| +| E-101 | Not-Aus-Kreis unterbrochen | Alle Not-Aus-Taster prüfen, dann quittieren | +| E-115 | Türkontakt Schutzhaube offen | Haube schließen, Kontakt auf Verschleiß prüfen | +| E-203 | Hydraulikdruck unter Sollwert | Aggregat prüfen, siehe Dokument HP-20 | +| E-207 | Kühlmitteldurchfluss zu gering | Filter am Kühlmittelkreislauf tauschen | +| E-311 | Werkzeugwechsler Timeout | Späne im Greifer? Wechsler im Handbetrieb freifahren | +| E-408 | Kommunikation zum Panel gestört | PROFINET-Stecker am Schaltschrank prüfen | + +## Quittieren von Störungen + +Störungen werden am Panel mit der blauen Taste quittiert. E-101 und +E-115 sind sicherheitsgerichtet und erfordern zusätzlich die Freigabe +durch den Schichtleiter mit dem Schlüsselschalter. + +## Diagnosepuffer exportieren + +1. Am Panel: Menü „Service" → „Diagnose" → „Export USB" +2. USB-Stick aus dem Schaltschrank-Fach verwenden (kein privater Stick!) +3. Datei an instandhaltung@nordwind-maschinenbau.example mailen + +Bei wiederkehrenden E-203-Meldungen bitte immer auch den Ölstand des +Hydraulikaggregats dokumentieren, bevor der Servicetechniker kommt. diff --git a/backend/tests/fixtures/corpus/hydraulik-presse-hp20.md b/backend/tests/fixtures/corpus/hydraulik-presse-hp20.md new file mode 100644 index 0000000..9f07479 --- /dev/null +++ b/backend/tests/fixtures/corpus/hydraulik-presse-hp20.md @@ -0,0 +1,40 @@ +--- +id: hydraulik-presse-hp20 +title: "Hydraulikpresse HP-20: Anfahren und Störungen" +department: Engineering +visibility: department +--- + +# Hydraulikpresse HP-20: Anfahren und Störungen + +Die HP-20 wird nur von eingewiesenem Personal gefahren. Die Einweisung +dokumentiert der Schichtleiter im Schulungsordner. + +## Anfahren nach Stillstand + +1. Hauptschalter ein, Steuerung hochfahren lassen (ca. 90 Sekunden) +2. Ölstand am Aggregat prüfen: Schauglas muss zwischen Min und Max stehen +3. Pumpe im Leerlauf starten und zwei Minuten warmlaufen lassen +4. Probehub ohne Werkstück fahren, Druckanzeige beobachten +5. Solldruck 180 bar; Abweichungen über ±10 bar melden + +## Typische Störungen + +### Druck fällt unter Sollwert + +Meldet die Steuerung E-203 (siehe Fehlercode-Liste), zuerst Ölstand +prüfen. Häufigste Ursache ist eine undichte Verschraubung an der +Druckleitung — Leckagen sofort der Instandhaltung melden, nicht selbst +nachziehen, solange die Anlage unter Druck steht. + +### Presse fährt nicht in Grundstellung + +Meist steht der Wahlschalter noch auf „Einrichten". In Stellung +„Automatik" bringt die Steuerung den Stößel selbstständig in die +Grundstellung. + +## Sicherheit + +Der Lichtvorhang darf niemals überbrückt werden, auch nicht beim +Einrichten. Für Einrichtbetrieb gibt es den Zustimmtaster am Bedienpult. +Jede Manipulation an Sicherheitseinrichtungen ist ein Kündigungsgrund. diff --git a/backend/tests/fixtures/corpus/it-onboarding-arbeitsplatz.md b/backend/tests/fixtures/corpus/it-onboarding-arbeitsplatz.md new file mode 100644 index 0000000..493ce3c --- /dev/null +++ b/backend/tests/fixtures/corpus/it-onboarding-arbeitsplatz.md @@ -0,0 +1,34 @@ +--- +id: it-onboarding-arbeitsplatz +title: "IT-Ausstattung neuer Arbeitsplätze" +department: Administration +visibility: department +--- + +# IT-Ausstattung neuer Arbeitsplätze + +Checkliste für die Verwaltung, damit neue Kolleginnen und Kollegen am +ersten Tag arbeitsfähig sind. Vorlauf: mindestens zwei Wochen vor +Eintritt. + +## Standardausstattung + +- Notebook aus dem Standardwarenkorb (Büro) oder Terminal-Zugang + (Fertigung) +- Benutzerkonto im Verzeichnisdienst, E-Mail-Postfach +- Zugänge: ERP-Rolle laut Abteilungsprofil, CRM nur für den Vertrieb +- Telefonnebenstelle bzw. DECT-Gerät in der Fertigung + +## Ablauf + +1. Personalabteilung meldet den Eintritt über das IT-Ticketportal +2. IT legt Konto und Postfach an, richtet die ERP-Rolle ein +3. Verwaltung bestellt Hardware und bereitet den Arbeitsplatz vor +4. Am ersten Tag: Übergabeprotokoll unterschreiben lassen, Einweisung + in Passwortrichtlinie und Datenschutz-Grundlagen + +## Zugangskarten + +Zugangskarten erstellt der Empfang. Fertigungsmitarbeiter erhalten +zusätzlich die Berechtigung für Halle 1/2 erst nach der +Sicherheitsunterweisung durch den Schichtleiter. diff --git a/backend/tests/fixtures/corpus/messevorbereitung.md b/backend/tests/fixtures/corpus/messevorbereitung.md new file mode 100644 index 0000000..382e4b4 --- /dev/null +++ b/backend/tests/fixtures/corpus/messevorbereitung.md @@ -0,0 +1,32 @@ +--- +id: messevorbereitung +title: "Messevorbereitung: Checkliste Hannover" +department: Sales +visibility: department +--- + +# Messevorbereitung: Checkliste Hannover + +Erfahrungswerte aus den letzten drei Messeauftritten. Verantwortlich ist +der Vertriebsinnendienst, Start der Vorbereitung: 16 Wochen vor Messe. + +## 16 bis 8 Wochen vorher + +- Standfläche und Standbau bestätigen (Vertrag prüfen: Strom, Druckluft!) +- Exponat festlegen — Abstimmung mit Fertigung, ob die Maschine + rechtzeitig aus der Produktion genommen werden kann +- Hotelkontingent buchen (Innenstadt ist 12 Monate vorher ausgebucht, + Ausweichoption Laatzen) + +## 8 Wochen bis Messebeginn + +- Transport des Exponats mit Spedition Grothe terminieren +- Standdienstplan erstellen: immer mindestens ein Techniker am Stand +- Gesprächsleitfaden und Preislisten-Auszug drucken (keine vollständigen + Preislisten am Stand!) + +## Nach der Messe + +Alle Messekontakte innerhalb von fünf Arbeitstagen im CRM erfassen und +mit dem Kennzeichen der Messe versehen. Die Nachverfolgung läuft über +den normalen CRM-Wiedervorlagen-Prozess. diff --git a/backend/tests/fixtures/corpus/netzwerk-produktions-it.md b/backend/tests/fixtures/corpus/netzwerk-produktions-it.md new file mode 100644 index 0000000..1879917 --- /dev/null +++ b/backend/tests/fixtures/corpus/netzwerk-produktions-it.md @@ -0,0 +1,32 @@ +--- +id: netzwerk-produktions-it +title: "Produktions-IT: Netzwerk und Maschinenanbindung" +department: Engineering +visibility: restricted +--- + +# Produktions-IT: Netzwerk und Maschinenanbindung + +Vertraulich — Zugriff nur für die Produktions-IT. Enthält +Netzwerkstruktur und Zugangsdaten-Speicherorte. + +## Netzsegmente + +Die Produktion ist vom Büronetz vollständig getrennt. Es gibt drei +VLANs: Maschinen (nur Maschinensteuerungen), Panels (Bedienpanels und +Terminals) und Erfassung (BDE-Terminals der Betriebsdatenerfassung). +Übergänge laufen ausschließlich über die Firewall in Schrank R2. + +## Maschinenanbindung + +Neue Maschinen werden über OPC UA angebunden. Der OPC-UA-Server läuft +auf dem Edge-Rechner in Halle 2; Zertifikate liegen im Passwort-Tresor +der IT (Eintrag „OPC-UA Edge"). Die alte F-500 spricht kein OPC UA und +wird über eine serielle Brücke ausgelesen — Finger weg von dem grauen +Kasten neben ihrem Schaltschrank. + +## Fernwartung + +Servotec erhält Fernzugriff nur über die Wartungs-VPN mit +Einmal-Freischaltung durch die IT. Dauerhafte Fernzugänge sind nicht +zulässig und werden von der Firewall geblockt. diff --git a/backend/tests/fixtures/corpus/offboarding-krause-instandhaltung.md b/backend/tests/fixtures/corpus/offboarding-krause-instandhaltung.md new file mode 100644 index 0000000..14d50d2 --- /dev/null +++ b/backend/tests/fixtures/corpus/offboarding-krause-instandhaltung.md @@ -0,0 +1,40 @@ +--- +id: offboarding-krause-instandhaltung +title: "Wissenssicherung: Werner Krause (Instandhaltung)" +department: Engineering +visibility: restricted +grants: [Engineering] +--- + +# Wissenssicherung: Werner Krause (Instandhaltung) + +Werner Krause geht im September 2026 nach 31 Jahren in den Ruhestand. +Dieses Dokument fasst sein Erfahrungswissen aus dem Abschlussinterview +zusammen. + +## Undokumentierte Eigenheiten der Maschinen + +Die F-350 verliert nach einem Stromausfall gelegentlich die Position der +vierten Achse, obwohl die Referenzfahrt fehlerfrei durchläuft. Werner +fährt in dem Fall die Achse einmal manuell auf Endlage und wieder +zurück, danach stimmt die Position wieder. Servotec kennt das Problem, +konnte es aber nie reproduzieren. + +Beim Umbau der HP-20 im Jahr 2019 wurde ein Zwischenring am +Stößel eingebaut, der nicht in den Zeichnungen auftaucht. Bei +Ersatzteilbestellungen für den Stößel immer zuerst den Ring ausmessen. + +## Lieferanten und Ansprechpartner + +- Servotec: Herr Balke ist der einzige Techniker, der die F-350 wirklich + kennt. Bei Terminen explizit nach ihm fragen. +- Hydraulik-Ersatzteile: Firma Prüßmann liefert schneller als der + Hersteller, Qualität identisch. + +## Was der Nachfolger zuerst lernen sollte + +1. Diagnosepuffer der S7 lesen und exportieren +2. Zentralschmierung der Fräsen (Dosierventile reagieren empfindlich + auf falsches Öl) +3. Den Schichtbuch-Rhythmus: alles, was nicht dokumentiert ist, + ist nach zwei Wochen vergessen diff --git a/backend/tests/fixtures/corpus/qualitaetspruefung-wareneingang.md b/backend/tests/fixtures/corpus/qualitaetspruefung-wareneingang.md new file mode 100644 index 0000000..12697a0 --- /dev/null +++ b/backend/tests/fixtures/corpus/qualitaetspruefung-wareneingang.md @@ -0,0 +1,34 @@ +--- +id: qualitaetspruefung-wareneingang +title: "Qualitätsprüfung im Wareneingang" +department: Engineering +visibility: public +--- + +# Qualitätsprüfung im Wareneingang + +Jede Anlieferung durchläuft die Qualitätssicherung (QS), bevor sie ins +Lager gebucht wird. Ungeprüfte Ware steht in der gelben Zone und darf +nicht entnommen werden. + +## Prüfumfang + +Standardteile prüfen wir nach AQL-Stichprobenplan (Annehmbare +Qualitätsgrenzlage, Stufe II). Zeichnungsteile von Neulieferanten werden +in den ersten drei Lieferungen zu 100 % gemessen, danach nach +Stichprobenplan. + +## Ablauf + +1. Lieferschein mit Bestellung im ERP abgleichen +2. Sichtprüfung auf Transportschäden +3. Stichprobe ziehen laut AQL-Tabelle (hängt am QS-Arbeitsplatz) +4. Messwerte im ERP unter „WE-Prüfung" erfassen +5. Bei i.O.: grünes Etikett, Buchung ins Lager +6. Bei n.i.O.: Sperrbestand, QS-Meldung an den Einkauf + +## Sonderfreigaben + +Eine Sonderfreigabe gesperrter Ware darf nur die QS-Leitung erteilen, +schriftlich im ERP. Mündliche Freigaben gelten nicht — auch nicht, +wenn die Fertigung auf das Material wartet. diff --git a/backend/tests/fixtures/corpus/rabattrichtlinie.md b/backend/tests/fixtures/corpus/rabattrichtlinie.md new file mode 100644 index 0000000..a838b72 --- /dev/null +++ b/backend/tests/fixtures/corpus/rabattrichtlinie.md @@ -0,0 +1,34 @@ +--- +id: rabattrichtlinie +title: "Rabatt- und Konditionenrichtlinie" +department: Sales +visibility: department +--- + +# Rabatt- und Konditionenrichtlinie + +Gültig ab 01.01.2026, ersetzt alle älteren Regelungen. + +## Rabattstufen Ersatzteile + +- Bis 5 %: eigenverantwortlich durch den Vertriebsmitarbeiter +- 5–10 %: Freigabe durch den Vertriebsleiter +- Über 10 %: nur mit schriftlicher Freigabe der Geschäftsführung + +## Maschinen und Sondermaschinen + +Für Maschinen gibt es keine Standardrabatte. Preisnachlässe entstehen +ausschließlich über den Verhandlungsrahmen, der in der Kalkulation +hinterlegt ist. + +## Zahlungsbedingungen + +Standard: 30 % bei Auftrag, 60 % bei Liefermeldung, 10 % nach +Inbetriebnahme. Abweichende Zahlungspläne prüft die Buchhaltung auf +Bonität, bevor der Vertrag unterschrieben wird. + +## Skonto + +Skonto gewähren wir grundsätzlich nicht. Bestandskunden mit +Altverträgen (2 % / 14 Tage) behalten ihre Kondition bis zur nächsten +Vertragsverlängerung. diff --git a/backend/tests/fixtures/corpus/reisekosten.md b/backend/tests/fixtures/corpus/reisekosten.md new file mode 100644 index 0000000..800f8ea --- /dev/null +++ b/backend/tests/fixtures/corpus/reisekosten.md @@ -0,0 +1,34 @@ +--- +id: reisekosten +title: "Reisekostenabrechnung" +department: Administration +visibility: public +--- + +# Reisekostenabrechnung + +Reisekosten werden monatlich abgerechnet, Abgabefrist ist der 5. des +Folgemonats. Später eingereichte Abrechnungen rutschen in den nächsten +Lauf. + +## Was wird erstattet + +- Fahrten mit Privat-PKW: 0,30 € pro Kilometer laut Routenplaner +- Bahn: 2. Klasse, Tickets über das Firmenkonto im Bahnportal buchen +- Hotel: bis 120 € pro Nacht ohne Rückfrage, darüber vorher genehmigen + lassen +- Verpflegungsmehraufwand: gesetzliche Pauschalen, das Formular rechnet + automatisch + +## Ablauf + +1. Formular „Reisekosten" aus dem Intranet verwenden (aktuelle Version!) +2. Belege als PDF anhängen — Fotos sind okay, solange sie lesbar sind +3. An buchhaltung@nordwind-maschinenbau.example senden +4. Erstattung kommt mit der nächsten Gehaltsabrechnung + +## Firmenwagen und Poolfahrzeuge + +Für Poolfahrzeuge wird nur getankt (Tankkarte im Handschuhfach), keine +Kilometer abgerechnet. Das Fahrtenbuch im Fahrzeug ist Pflicht und wird +von der Verwaltung monatlich geprüft. diff --git a/backend/tests/fixtures/corpus/reklamationsprozess.md b/backend/tests/fixtures/corpus/reklamationsprozess.md new file mode 100644 index 0000000..3392da9 --- /dev/null +++ b/backend/tests/fixtures/corpus/reklamationsprozess.md @@ -0,0 +1,41 @@ +--- +id: reklamationsprozess +title: "Reklamationsprozess (Kundenreklamationen)" +department: Sales +visibility: public +--- + +# Reklamationsprozess (Kundenreklamationen) + +Gilt für alle Kundenreklamationen, unabhängig davon, wer sie +entgegennimmt. Ziel: Erstantwort an den Kunden innerhalb von 24 Stunden. + +## Ablauf in acht Schritten + +1. **Eingang erfassen**: Reklamation im ERP als Vorgang „REK" anlegen, + Kunde, Auftragsnummer und Fehlerbeschreibung erfassen. +2. **Eingangsbestätigung**: Der Vertrieb bestätigt dem Kunden den + Eingang innerhalb von 24 Stunden mit der REK-Nummer. +3. **Ersteinschätzung**: QS bewertet, ob es sich um einen Sachmangel, + einen Transportschaden oder einen Bedienfehler handelt. +4. **Sofortmaßnahme**: Falls der Kunde stillsteht, entscheidet der + Vertriebsleiter über Ersatzlieferung oder Technikereinsatz. +5. **Ursachenanalyse**: QS und Fertigung ermitteln die Ursache + (5-Why-Methode, Ergebnis im REK-Vorgang dokumentieren). +6. **Abstellmaßnahme**: Maßnahme festlegen, Verantwortlichen und + Termin eintragen. +7. **Kundenantwort**: Der Vertrieb formuliert die Antwort auf Basis der + Analyse — keine Rohdaten aus der QS unkommentiert weiterleiten. +8. **Wirksamkeitsprüfung**: QS prüft nach drei Monaten, ob die Maßnahme + gewirkt hat, und schließt den Vorgang. + +## Zuständigkeiten + +Der Vorgangsverantwortliche ist immer der Vertriebsmitarbeiter des +Kunden. QS unterstützt bei Analyse und Bewertung, übernimmt aber nicht +die Kundenkommunikation. + +## Eskalation + +Reklamationen mit Stillstand beim Kunden oder Streitwert über 10.000 € +gehen sofort an die Geschäftsführung. diff --git a/backend/tests/fixtures/corpus/schmierstoffe-wartung.md b/backend/tests/fixtures/corpus/schmierstoffe-wartung.md new file mode 100644 index 0000000..4b2bc46 --- /dev/null +++ b/backend/tests/fixtures/corpus/schmierstoffe-wartung.md @@ -0,0 +1,44 @@ +--- +id: schmierstoffe-wartung +title: "Schmierstoffe und Wartungsintervalle" +department: Engineering +visibility: department +--- + +# Schmierstoffe und Wartungsintervalle + +Übersicht der freigegebenen Schmierstoffe für alle Maschinen in Halle 1 +und Halle 2. Nicht gelistete Öle und Fette dürfen ohne Freigabe der +Instandhaltung nicht eingesetzt werden — falsche Schmierstoffe sind die +häufigste Ursache für Garantieverlust. + +## Freigegebene Schmierstoffe + +- **Getriebeöl GX-220**: Zentralschmierung der CNC-Fräsen (F-350, F-500), + Nachfüllintervall monatlich +- **Bettbahnöl BB-68**: Führungsbahnen, wöchentlich nach Reinigung +- **Hochdruckfett HF-2**: Lagerstellen Hydraulikpresse HP-20, + alle 500 Betriebsstunden +- **Spindelöl SP-10**: nur durch Servotec bei der Jahreswartung + +## Intervalle je Maschine + +### CNC-Fräse F-350 + +Die F-350 hat eine automatische Zentralschmierung; der Behälter wird +monatlich mit GX-220 aufgefüllt. Die Führungsbahnen zusätzlich wöchentlich +mit BB-68 benetzen. Der vollständige Plan steht im „Wartungsplan +CNC-Fräse F-350". + +### Hydraulikpresse HP-20 + +Lagerstellen alle 500 Betriebsstunden mit HF-2 abschmieren +(Betriebsstundenzähler am Panel). Hydrauliköl wird NICHT von uns +gewechselt — Ölwechsel nur durch den Hersteller-Service. + +## Lagerung und Entsorgung + +Schmierstoffe lagern im Gefahrstoffschrank in Halle 1. Altöl kommt in +die gekennzeichneten Behälter am Wertstoffplatz; die Entsorgung holt +die Firma Reko alle sechs Wochen ab. Sicherheitsdatenblätter hängen am +Gefahrstoffschrank aus. diff --git a/backend/tests/fixtures/corpus/urlaubsantrag-prozess.md b/backend/tests/fixtures/corpus/urlaubsantrag-prozess.md new file mode 100644 index 0000000..99a3728 --- /dev/null +++ b/backend/tests/fixtures/corpus/urlaubsantrag-prozess.md @@ -0,0 +1,35 @@ +--- +id: urlaubsantrag-prozess +title: "Urlaubsanträge und Abwesenheiten" +department: Administration +visibility: public +--- + +# Urlaubsanträge und Abwesenheiten + +Gilt für alle Beschäftigten. Urlaubsanträge laufen seit 2025 digital +über das Personalportal — Papieranträge werden nicht mehr angenommen. + +## Urlaub beantragen + +1. Personalportal → „Abwesenheit" → „Neuer Antrag" +2. Zeitraum wählen; das Portal zeigt den Resturlaub automatisch an +3. Antrag geht zur Genehmigung an die Führungskraft +4. Nach Genehmigung erscheint der Urlaub im Teamkalender + +Anträge für mehr als zwei Wochen am Stück bitte mindestens acht Wochen +vorher stellen. In der Fertigung gilt zusätzlich: pro Schicht dürfen +maximal zwei Personen gleichzeitig Urlaub haben. + +## Krankmeldung + +Am ersten Krankheitstag bis 9:00 Uhr telefonisch beim Schichtleiter +bzw. bei der Führungskraft melden. Die Arbeitsunfähigkeitsbescheinigung +kommt elektronisch von der Krankenkasse; ein Papierschein ist nur noch +für Privatversicherte nötig. + +## Sonderurlaub + +Sonderurlaub (Umzug, Hochzeit, Todesfall) regelt der Manteltarifvertrag. +Im Zweifel vor der Buchung bei der Personalabteilung nachfragen — +nachträgliche Umbuchungen sind aufwendig. diff --git a/backend/tests/fixtures/corpus/wartungsplan-cnc-f350.md b/backend/tests/fixtures/corpus/wartungsplan-cnc-f350.md new file mode 100644 index 0000000..f6f69fd --- /dev/null +++ b/backend/tests/fixtures/corpus/wartungsplan-cnc-f350.md @@ -0,0 +1,56 @@ +--- +id: wartungsplan-cnc-f350 +title: "Wartungsplan CNC-Fräse F-350" +department: Engineering +visibility: department +--- + +# Wartungsplan CNC-Fräse F-350 + +Die F-350 ist unsere meistgenutzte Fräse in Halle 2. Ausfälle blockieren +direkt die Fertigung der Serienteile für Bremer & Söhne. Deshalb gilt der +folgende Plan verbindlich; Abweichungen bitte immer im Schichtbuch +dokumentieren. + +## Tägliche Kontrolle (Schichtbeginn) + +- Kühlschmierstoff-Stand am Schauglas prüfen (Sollbereich grün) +- Späneförderer auf Blockaden kontrollieren +- Referenzfahrt durchführen und auf ungewöhnliche Geräusche achten +- Absaugung: Filteranzeige darf nicht im roten Bereich stehen + +## Wöchentliche Wartung + +Jeden Freitag in der Spätschicht, Dauer ca. 45 Minuten: + +1. Führungsbahnen mit Pinsel reinigen, danach mit Bettbahnöl benetzen +2. Werkzeugaufnahmen (WKZ-Kegel) mit Reinigungskegel abfahren +3. Kühlschmierstoff-Konzentration mit dem Refraktometer messen + (Soll: 8–10 %) +4. Druckluftwartungseinheit: Kondensat ablassen + +## Monatliche Wartung + +### Schmierung + +Die Zentralschmierung versorgt die Linearführungen automatisch, der +Vorratsbehälter muss aber monatlich mit Getriebeöl GX-220 aufgefüllt +werden. Nur GX-220 verwenden — andere Öle verharzen die Dosierventile. +Details zu Schmierstoffen und Freigaben stehen im Dokument +„Schmierstoffe und Wartungsintervalle". + +### Geometrieprüfung + +- Rundlaufprüfung an der Spindel mit Messuhr (Toleranz 0,01 mm) +- Referenzwerte im Maschinenordner ablegen + +## Jahreswartung (extern) + +Die Jahreswartung führt die Firma Servotec durch (Vertrag NW-2019-114). +Termin koordiniert die Arbeitsvorbereitung (AV). Vorher unbedingt den +Werkzeugwechsler leerräumen und die Paletten aus dem Speicher nehmen. + +## Ansprechpartner + +Instandhaltung: Werner Krause (bis 09/2026), danach Team Instandhaltung +über das Schichtbuch. Ersatzteile bestellt ausschließlich der Einkauf. diff --git a/backend/tests/fixtures/golden_queries.yaml b/backend/tests/fixtures/golden_queries.yaml new file mode 100644 index 0000000..54ab135 --- /dev/null +++ b/backend/tests/fixtures/golden_queries.yaml @@ -0,0 +1,72 @@ +# Golden retrieval queries against the fixture corpus. +# `expected`: document slugs — a query counts as a hit (recall@5) if ANY +# expected document appears in the top 5 results. An empty `expected` list +# marks a no-answer case: the corpus contains nothing relevant. + +- query: "Wie oft muss die Zentralschmierung der F-350 aufgefüllt werden?" + expected: [wartungsplan-cnc-f350, schmierstoffe-wartung] +# Declarative statements must retrieve the same documents as the equivalent +# question. The hybrid German full-text component closes the statement-vs- +# question gap the pure-cosine similarity path is more sensitive to (notes.md). +- query: "Die Zentralschmierung der F-350 muss regelmäßig aufgefüllt werden." + expected: [wartungsplan-cnc-f350, schmierstoffe-wartung] +- query: "Der Solldruck der Hydraulikpresse ist fest vorgegeben." + expected: [hydraulik-presse-hp20] +- query: "Welches Öl kommt in die Zentralschmierung der Fräse?" + expected: [wartungsplan-cnc-f350, schmierstoffe-wartung] +- query: "Was bedeutet Fehlercode E-203?" + expected: [fehlercodes-sps-s7, hydraulik-presse-hp20] +- query: "Wie exportiere ich den Diagnosepuffer der Steuerung?" + expected: [fehlercodes-sps-s7] +- query: "Wer muss eine Not-Aus-Störung freigeben?" + expected: [fehlercodes-sps-s7] +- query: "Welcher Solldruck gilt für die Hydraulikpresse?" + expected: [hydraulik-presse-hp20] +- query: "Die Presse fährt nicht in die Grundstellung zurück, was tun?" + expected: [hydraulik-presse-hp20] +- query: "Welche Schmierstoffe sind bei uns freigegeben?" + expected: [schmierstoffe-wartung] +- query: "Welcher Servotec-Techniker kennt die F-350 am besten?" + expected: [offboarding-krause-instandhaltung] +- query: "Die vierte Achse verliert nach einem Stromausfall die Position." + expected: [offboarding-krause-instandhaltung] +- query: "Wie werden neue Maschinen an das Produktionsnetzwerk angebunden?" + expected: [netzwerk-produktions-it] +- query: "Stichprobenprüfung bei Anlieferungen" # synonym for AQL/Wareneingang + expected: [qualitaetspruefung-wareneingang] +- query: "Wer darf gesperrte Ware freigeben?" + expected: [qualitaetspruefung-wareneingang] +- query: "Welche Pflichtfelder müssen im CRM gepflegt werden?" + expected: [crm-leitfaden] +- query: "Wie schnell muss auf eine Reklamation reagiert werden?" + expected: [reklamationsprozess] +- query: "Ablauf bei Kundenbeschwerden" # synonym: Beschwerde vs. Reklamation + expected: [reklamationsprozess] +- query: "Wie hoch ist der Zuschlag für Materialgemeinkosten?" + expected: [angebotskalkulation] +- query: "Wie lange sind unsere Angebote gültig?" + expected: [angebotskalkulation] +- query: "Wo übernachten wir während der Messe in Hannover?" + expected: [messevorbereitung] +- query: "Wie viel Rabatt darf ich auf Ersatzteile geben?" + expected: [rabattrichtlinie] +- query: "Wie beantrage ich Urlaub?" + expected: [urlaubsantrag-prozess] +- query: "Kilometerpauschale für Dienstfahrten mit dem Privatauto" # synonym: PKW + expected: [reisekosten] +- query: "elektronischer Datenaustausch von Rechnungen mit Großkunden" # spelled-out EDI + expected: [edi-rechnungen] +- query: "Was muss vor dem ersten Arbeitstag eines neuen Mitarbeiters vorbereitet werden?" + expected: [it-onboarding-arbeitsplatz] +- query: "Wie melde ich eine Datenpanne?" + expected: [datenschutz-grundlagen] + +# --- no-answer cases: nothing in the corpus covers these --- +- query: "Wie konfiguriere ich den Farblaserdrucker im dritten Obergeschoss?" + expected: [] +- query: "Welche Gerichte gibt es in der Kantine für Veganer?" + expected: [] +- query: "Wie stelle ich den Beamer im Konferenzraum scharf?" + expected: [] +- query: "Gibt es einen Zuschuss zum Deutschlandticket?" + expected: [] diff --git a/backend/tests/fixtures/loader.py b/backend/tests/fixtures/loader.py new file mode 100644 index 0000000..b2681ce --- /dev/null +++ b/backend/tests/fixtures/loader.py @@ -0,0 +1,58 @@ +"""Loader for the shared fixture corpus — seeds and tests draw from it. + +The corpus is product content (German knowledge documents of the fictional +SME "Nordwind Maschinenbau GmbH"). PyYAML is available through +uvicorn[standard]; it becomes a declared dependency with the template +import in M6. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +FIXTURES_DIR = Path(__file__).resolve().parent +CORPUS_DIR = FIXTURES_DIR / "corpus" + + +@dataclass +class CorpusDoc: + slug: str + title: str + department: str + visibility: str + content_md: str + grants: list[str] = field(default_factory=list) + + +def load_corpus() -> list[CorpusDoc]: + docs: list[CorpusDoc] = [] + for path in sorted(CORPUS_DIR.glob("*.md")): + text = path.read_text() + if not text.startswith("---\n"): + raise ValueError(f"corpus file without frontmatter: {path.name}") + _, frontmatter, body = text.split("---\n", 2) + meta = yaml.safe_load(frontmatter) + docs.append( + CorpusDoc( + slug=meta["id"], + title=meta["title"], + department=meta["department"], + visibility=meta["visibility"], + grants=list(meta.get("grants", [])), + content_md=body.strip() + "\n", + ) + ) + return docs + + +def load_golden_queries() -> list[dict[str, Any]]: + return yaml.safe_load((FIXTURES_DIR / "golden_queries.yaml").read_text()) + + +def load_conversation_snippets() -> list[dict[str, Any]]: + """Short chats whose LAST message is a topic-losing follow-up, with the + corpus slug the conversation is really about — for comparing topic-summary + retrieval against retrieval over the raw last message.""" + return yaml.safe_load((FIXTURES_DIR / "conversation_snippets.yaml").read_text()) diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py new file mode 100644 index 0000000..e62f715 --- /dev/null +++ b/backend/tests/test_account.py @@ -0,0 +1,203 @@ +"""Self-service password change: prove the old password, keep this session, +drop every other one.""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.account import PERSONAL_BLUEPRINT +from app.models import ( + AuthSession, + Document, + DocumentStatus, + DocumentVisibility, + Template, + User, +) + +pytestmark = pytest.mark.usefixtures("fake_llm") + + +async def _login(client: AsyncClient, password: str = "secret123") -> None: + response = await client.post( + "/api/auth/login", json={"email": "pablo@test.dev", "password": password} + ) + assert response.status_code == 200 + + +async def _session_count(db: AsyncSession, user_id) -> int: + """Takes the id, not the ORM object: callers expire the session first, + and a detached attribute access would need lazy IO.""" + return ( + await db.execute( + select(func.count(AuthSession.id)).where(AuthSession.user_id == user_id) + ) + ).scalar_one() + + +async def test_change_password_keeps_this_session_and_drops_the_others( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + user_id = seeded_user.id + # A second device: log in twice, then change the password on the second. + await _login(client) + await client.post("/api/auth/logout") # keeps the row count honest below + await _login(client) + other = await client.post( + "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} + ) + assert other.status_code == 200 + assert await _session_count(db, user_id) >= 2 + + changed = await client.post( + "/api/account/password", + json={"current_password": "secret123", "new_password": "neues-geheimnis"}, + ) + assert changed.status_code == 204 + + # The caller stays signed in... + assert (await client.get("/api/auth/me")).status_code == 200 + # ...and is now the only session left. + db.expire_all() + assert await _session_count(db, user_id) == 1 + + # The new password works, the old one does not. + await client.post("/api/auth/logout") + await _login(client, "neues-geheimnis") + await client.post("/api/auth/logout") + rejected = await client.post( + "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} + ) + assert rejected.status_code == 401 + + +async def test_wrong_current_password_changes_nothing( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + user_id = seeded_user.id + await _login(client) + before = await _session_count(db, user_id) + + response = await client.post( + "/api/account/password", + json={"current_password": "falsch", "new_password": "neues-geheimnis"}, + ) + assert response.status_code == 403 + assert response.json()["code"] == "invalid_current_password" + + db.expire_all() + assert await _session_count(db, user_id) == before + assert (await client.get("/api/auth/me")).status_code == 200 + + +async def test_short_passwords_are_rejected( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client) + response = await client.post( + "/api/account/password", + json={"current_password": "secret123", "new_password": "kurz"}, + ) + assert response.status_code == 422 + + +async def test_password_change_requires_a_session(client: AsyncClient) -> None: + response = await client.post( + "/api/account/password", + json={"current_password": "secret123", "new_password": "neues-geheimnis"}, + ) + assert response.status_code == 401 + + +async def test_locale_is_pinned_and_cleared( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """A pinned language follows the person to every device, so it rides on + the user row rather than in browser storage.""" + await _login(client) + assert (await client.get("/api/auth/me")).json()["locale"] is None + + assert ( + await client.put("/api/account/locale", json={"locale": "de"}) + ).status_code == 204 + assert (await client.get("/api/auth/me")).json()["locale"] == "de" + + # It survives a new session. + await client.post("/api/auth/logout") + await _login(client) + assert (await client.get("/api/auth/me")).json()["locale"] == "de" + + # null puts it back to following the browser. + assert ( + await client.put("/api/account/locale", json={"locale": None}) + ).status_code == 204 + assert (await client.get("/api/auth/me")).json()["locale"] is None + + +async def test_unsupported_locale_is_rejected( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client) + assert ( + await client.put("/api/account/locale", json={"locale": "fr"}) + ).status_code == 422 + + +async def test_setting_a_locale_requires_a_session(client: AsyncClient) -> None: + response = await client.put("/api/account/locale", json={"locale": "de"}) + assert response.status_code == 401 + + +async def test_the_personal_document_says_what_to_start_from( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Nothing written yet: the profile page gets the blueprint to start from, + and no document.""" + template = Template( + name="Onboarding", + version="1.0", + config={"id": PERSONAL_BLUEPRINT, "name": "Onboarding"}, + ) + db.add(template) + await db.commit() + + await _login(client) + body = (await client.get("/api/account/document")).json() + assert body["document_id"] is None + assert body["template_id"] == str(template.id) + + +async def test_the_personal_document_is_found_once_written( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Authorship is the whole rule: your own document from the person + blueprint, never one someone else wrote.""" + mine = Document( + title="Onboarding: Pablo", + status=DocumentStatus.draft, + visibility=DocumentVisibility.department, + content_md="## Rolle", + meta={"template": PERSONAL_BLUEPRINT}, + author_id=seeded_user.id, + ) + other = Document( + title="Onboarding: jemand anders", + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md="## Rolle", + meta={"template": PERSONAL_BLUEPRINT}, + author_id=None, + ) + db.add_all([mine, other]) + await db.commit() + + await _login(client) + body = (await client.get("/api/account/document")).json() + assert body["document_id"] == str(mine.id) + assert body["title"] == "Onboarding: Pablo" + assert body["status"] == "draft" + + +async def test_the_personal_document_requires_a_session(client: AsyncClient) -> None: + assert (await client.get("/api/account/document")).status_code == 401 diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py new file mode 100644 index 0000000..8703753 --- /dev/null +++ b/backend/tests/test_admin_api.py @@ -0,0 +1,83 @@ +from httpx import AsyncClient + +from app.models import User +from tests.fake_openai import FakeOpenAI + + +async def _login(client: AsyncClient, email: str) -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def test_llm_test_requires_authentication(client: AsyncClient) -> None: + response = await client.post("/api/admin/llm/test") + assert response.status_code == 401 + + +async def test_llm_test_requires_admin_role( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client, "pablo@test.dev") + response = await client.post("/api/admin/llm/test") + assert response.status_code == 403 + assert response.json()["code"] == "forbidden" + + +async def test_llm_test_reports_all_roles_healthy( + client: AsyncClient, seeded_admin: User, fake_llm: FakeOpenAI +) -> None: + await _login(client, "florian@test.dev") + response = await client.post("/api/admin/llm/test") + assert response.status_code == 200 + roles = {entry["role"]: entry for entry in response.json()["roles"]} + assert set(roles) == {"chat", "utility", "embedding"} + for entry in roles.values(): + assert entry["ok"] is True + assert entry["error"] is None + assert isinstance(entry["latency_ms"], int) + assert entry["model"] + assert entry["base_url"] + + +async def test_llm_test_reports_broken_roles( + client: AsyncClient, seeded_admin: User, fake_llm: FakeOpenAI +) -> None: + # Both chat-completion roles fail; embeddings stay healthy. Four + # errors: two roles x one SDK retry each. + fake_llm.chat_responses.extend([{"status": 500}] * 4) + await _login(client, "florian@test.dev") + response = await client.post("/api/admin/llm/test") + roles = {entry["role"]: entry for entry in response.json()["roles"]} + assert roles["chat"]["ok"] is False + assert "chat_stream failed" in roles["chat"]["error"] + assert "induced failure" not in roles["chat"]["error"] + assert roles["utility"]["ok"] is False + assert roles["embedding"]["ok"] is True + + +async def test_metrics_endpoint_reflects_llm_calls( + client: AsyncClient, seeded_admin: User, fake_llm: FakeOpenAI +) -> None: + await _login(client, "florian@test.dev") + await client.post("/api/admin/llm/test") + + response = await client.get("/api/admin/metrics") + assert response.status_code == 200 + snapshot = response.json() + counted_roles = { + entry["labels"]["role"] + for entry in snapshot["counters"]["llm_calls_total"] + if entry["labels"]["status"] == "ok" + } + assert counted_roles == {"chat", "utility", "embedding"} + assert "llm_call_seconds" in snapshot["histograms"] + + +async def test_metrics_endpoint_requires_admin( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client, "pablo@test.dev") + response = await client.get("/api/admin/metrics") + assert response.status_code == 403 diff --git a/backend/tests/test_admin_crud.py b/backend/tests/test_admin_crud.py new file mode 100644 index 0000000..45acf96 --- /dev/null +++ b/backend/tests/test_admin_crud.py @@ -0,0 +1,339 @@ +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ( + AuthSession, + Conversation, + ConversationMode, + Document, + DocumentStatus, + Message, + MessageRole, + User, +) + + +async def _login(client: AsyncClient, email: str) -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def test_users_crud_requires_admin( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client, "pablo@test.dev") + assert (await client.get("/api/admin/users")).status_code == 403 + + +async def test_full_user_lifecycle( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + await _login(client, "florian@test.dev") + + department = ( + await client.post("/api/admin/departments", json={"name": "QS"}) + ).json() + created = await client.post( + "/api/admin/users", + json={ + "email": "quinn@test.dev", + "name": "Quinn Test", + "role": "member", + "department_id": department["id"], + "password": "secret123", + }, + ) + assert created.status_code == 200 + user_id = created.json()["id"] + + # Duplicate email → 409. + duplicate = await client.post( + "/api/admin/users", + json={"email": "quinn@test.dev", "name": "X", "password": "secret123"}, + ) + assert duplicate.status_code == 409 + assert duplicate.json()["code"] == "email_taken" + + # The new user can log in. + await client.post("/api/auth/logout") + await _login(client, "quinn@test.dev") + me = (await client.get("/api/auth/me")).json() + assert me["department_id"] == department["id"] + + # Password reset by the admin invalidates the old password. + await client.post("/api/auth/logout") + await _login(client, "florian@test.dev") + reset = await client.patch( + f"/api/admin/users/{user_id}", json={"password": "new-secret-1"} + ) + assert reset.status_code == 200 + await client.post("/api/auth/logout") + old_login = await client.post( + "/api/auth/login", json={"email": "quinn@test.dev", "password": "secret123"} + ) + assert old_login.status_code == 401 + new_login = await client.post( + "/api/auth/login", json={"email": "quinn@test.dev", "password": "new-secret-1"} + ) + assert new_login.status_code == 200 + + # Deleting the user keeps their documents, authorless. + document = Document( + title="Bleibt", + status=DocumentStatus.published, + visibility="public", + content_md="# Bleibt", + author_id=user_id, + ) + db.add(document) + await db.commit() + + await client.post("/api/auth/logout") + await _login(client, "florian@test.dev") + assert (await client.delete(f"/api/admin/users/{user_id}")).status_code == 204 + db.expire_all() + survivor = ( + await db.execute(select(Document).where(Document.title == "Bleibt")) + ).scalar_one() + assert survivor.author_id is None + + +async def test_admin_cannot_delete_or_demote_self( + client: AsyncClient, seeded_admin: User +) -> None: + await _login(client, "florian@test.dev") + me = (await client.get("/api/auth/me")).json() + delete = await client.delete(f"/api/admin/users/{me['id']}") + assert delete.status_code == 409 + assert delete.json()["code"] == "self_modification" + demote = await client.patch(f"/api/admin/users/{me['id']}", json={"role": "member"}) + assert demote.status_code == 409 + + +async def test_department_crud_and_conflicts( + client: AsyncClient, seeded_admin: User +) -> None: + await _login(client, "florian@test.dev") + created = ( + await client.post("/api/admin/departments", json={"name": "Montage"}) + ).json() + + conflict = await client.post("/api/admin/departments", json={"name": "Montage"}) + assert conflict.status_code == 409 + assert conflict.json()["code"] == "name_taken" + + renamed = await client.patch( + f"/api/admin/departments/{created['id']}", json={"name": "Endmontage"} + ) + assert renamed.json()["name"] == "Endmontage" + + # Any authenticated user can list departments for pickers. + listing = (await client.get("/api/departments")).json() + assert "Endmontage" in [d["name"] for d in listing] + + assert ( + await client.delete(f"/api/admin/departments/{created['id']}") + ).status_code == 204 + + +async def test_deleting_a_department_in_use_needs_confirmation( + client: AsyncClient, seeded_admin: User +) -> None: + """A department with members/documents/grants cannot be silently deleted: + the CASCADE would drop its shared-access grants unseen, so it takes an + explicit confirm.""" + await _login(client, "florian@test.dev") + dept = ( + await client.post("/api/admin/departments", json={"name": "Vertrieb"}) + ).json() + await client.post( + "/api/admin/users", + json={ + "email": "neu@test.dev", + "name": "Neu", + "role": "member", + "department_id": dept["id"], + "password": "secret123", + }, + ) + + blocked = await client.delete(f"/api/admin/departments/{dept['id']}") + assert blocked.status_code == 409 + assert blocked.json()["code"] == "department_in_use" + + confirmed = await client.delete( + f"/api/admin/departments/{dept['id']}", params={"confirm": "true"} + ) + assert confirmed.status_code == 204 + + +async def test_password_reset_revokes_all_sessions( + client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User +) -> None: + # Pablo logs in — her session row exists and her cookie is captured. + await _login(client, "pablo@test.dev") + anna_cookie = client.cookies.get("pablan_session") + assert anna_cookie is not None + + # Admin (same client, new cookie) resets Pablo's password. + await _login(client, "florian@test.dev") + reset = await client.patch( + f"/api/admin/users/{seeded_user.id}", json={"password": "brand-new-pw1"} + ) + assert reset.status_code == 200 + + # Pablo's old session is dead ... + remaining = ( + await db.execute( + select(func.count(AuthSession.id)).where( + AuthSession.user_id == seeded_user.id + ) + ) + ).scalar_one() + assert remaining == 0 + client.cookies.clear() + client.cookies.set("pablan_session", anna_cookie) + assert (await client.get("/api/auth/me")).status_code == 401 + + # ... while the acting admin's session survived. + await _login(client, "florian@test.dev") + assert (await client.get("/api/auth/me")).status_code == 200 + + +async def test_admin_changing_own_password_logs_themselves_out( + client: AsyncClient, seeded_admin: User +) -> None: + await _login(client, "florian@test.dev") + me = (await client.get("/api/auth/me")).json() + reset = await client.patch( + f"/api/admin/users/{me['id']}", json={"password": "next-password-1"} + ) + assert reset.status_code == 200 + assert (await client.get("/api/auth/me")).status_code == 401 + + +async def test_user_delete_cascades_conversations_but_not_documents( + client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User +) -> None: + """GDPR offboarding: transcripts are personal data and go with the user; + the approved document is the legitimate artifact and stays.""" + conversation = Conversation(mode=ConversationMode.query, user_id=seeded_user.id) + db.add(conversation) + await db.flush() + db.add( + Message( + conversation_id=conversation.id, + role=MessageRole.user, + content="personal transcript", + ) + ) + document = Document( + title="Artefakt", + status=DocumentStatus.published, + visibility="public", + content_md="# Artefakt", + author_id=seeded_user.id, + ) + db.add(document) + await db.commit() + + await _login(client, "florian@test.dev") + assert ( + await client.delete(f"/api/admin/users/{seeded_user.id}") + ).status_code == 204 + + db.expire_all() + assert (await db.execute(select(func.count(Conversation.id)))).scalar_one() == 0 + assert (await db.execute(select(func.count(Message.id)))).scalar_one() == 0 + survivor = ( + await db.execute(select(Document).where(Document.title == "Artefakt")) + ).scalar_one() + assert survivor.author_id is None + + +async def test_admin_can_correct_a_users_email( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """A typo in an address used to mean deleting the person and losing + everything hanging off their id.""" + await _login(client, "florian@test.dev") + created = ( + await client.post( + "/api/admin/users", + json={ + "email": "tpyo@pablan.dev", + "name": "Typo", + "role": "member", + "password": "secret123", + }, + ) + ).json() + + patched = await client.patch( + f"/api/admin/users/{created['id']}", + json={"email": " Fixed@Pablan.DEV ", "name": "Fixed"}, + ) + assert patched.status_code == 200 + # Normalised exactly as creation does, or login would stop finding them. + assert patched.json()["email"] == "fixed@pablan.dev" + assert patched.json()["name"] == "Fixed" + + login = await client.post( + "/api/auth/login", json={"email": "fixed@pablan.dev", "password": "secret123"} + ) + assert login.status_code == 200 + + +async def test_an_email_already_in_use_is_refused( + client: AsyncClient, db: AsyncSession, seeded_admin: User, seeded_user: User +) -> None: + await _login(client, "florian@test.dev") + users = (await client.get("/api/admin/users")).json()["items"] + target = next(u for u in users if u["email"] == "pablo@test.dev") + + clash = await client.patch( + f"/api/admin/users/{target['id']}", json={"email": "florian@test.dev"} + ) + assert clash.status_code == 409 + assert clash.json()["code"] == "email_taken" + + +async def test_the_user_list_pages_and_searches( + client: AsyncClient, db: AsyncSession, seeded_admin: User, seeded_user: User +) -> None: + """The admin screen is the one place that scales with headcount.""" + await _login(client, "florian@test.dev") + for index in range(6): + await client.post( + "/api/admin/users", + json={ + "email": f"kolleg{index}@pablan.dev", + "name": f"Kollege {index}", + "role": "member", + "password": "secret123", + }, + ) + + first = (await client.get("/api/admin/users", params={"per_page": 3})).json() + assert len(first["items"]) == 3 + assert first["total"] >= 8 + second = ( + await client.get("/api/admin/users", params={"per_page": 3, "page": 2}) + ).json() + assert {u["id"] for u in first["items"]}.isdisjoint( + u["id"] for u in second["items"] + ) + + # Search covers both columns, because an admin looking for someone knows + # one of the two and rarely which. + by_name = ( + await client.get("/api/admin/users", params={"search": "Kollege 4"}) + ).json() + assert [u["email"] for u in by_name["items"]] == ["kolleg4@pablan.dev"] + by_email = ( + await client.get("/api/admin/users", params={"search": "kolleg4@"}) + ).json() + assert by_email["total"] == 1 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..56dd6c6 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,125 @@ +import uuid +from datetime import UTC, datetime, timedelta + +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password, verify_password +from app.auth.sessions import COOKIE_NAME +from app.models import AuthSession, User + + +def test_password_hash_roundtrip() -> None: + hashed = hash_password("secret123") + assert hashed != "secret123" + assert verify_password(hashed, "secret123") + assert not verify_password(hashed, "wrong") + assert not verify_password("not-a-hash", "secret123") + + +async def _session_count(db: AsyncSession) -> int: + return (await db.execute(select(func.count(AuthSession.id)))).scalar_one() + + +async def test_login_success_sets_cookie_and_session( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + response = await client.post( + "/api/auth/login", + json={"email": "pablo@test.dev", "password": "secret123"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["email"] == "pablo@test.dev" + assert body["role"] == "member" + assert COOKIE_NAME in response.cookies + assert await _session_count(db) == 1 + + +async def test_login_normalizes_email(client: AsyncClient, seeded_user: User) -> None: + response = await client.post( + "/api/auth/login", + json={"email": " PABLO@test.dev ", "password": "secret123"}, + ) + assert response.status_code == 200 + + +async def test_login_wrong_password( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + response = await client.post( + "/api/auth/login", + json={"email": "pablo@test.dev", "password": "wrong"}, + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Invalid email or password.", + "code": "invalid_credentials", + } + assert await _session_count(db) == 0 + + +async def test_login_unknown_email_same_error(client: AsyncClient) -> None: + response = await client.post( + "/api/auth/login", + json={"email": "ghost@test.dev", "password": "secret123"}, + ) + assert response.status_code == 401 + assert response.json()["code"] == "invalid_credentials" + + +async def test_me_without_cookie(client: AsyncClient) -> None: + response = await client.get("/api/auth/me") + assert response.status_code == 401 + assert response.json()["code"] == "not_authenticated" + + +async def test_me_with_garbage_cookie(client: AsyncClient) -> None: + client.cookies.set(COOKIE_NAME, "not-a-uuid") + response = await client.get("/api/auth/me") + assert response.status_code == 401 + + +async def test_me_after_login(client: AsyncClient, seeded_user: User) -> None: + await client.post( + "/api/auth/login", + json={"email": "pablo@test.dev", "password": "secret123"}, + ) + response = await client.get("/api/auth/me") + assert response.status_code == 200 + assert response.json()["id"] == str(seeded_user.id) + + +async def test_logout_deletes_session( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + await client.post( + "/api/auth/login", + json={"email": "pablo@test.dev", "password": "secret123"}, + ) + assert await _session_count(db) == 1 + + response = await client.post("/api/auth/logout") + assert response.status_code == 204 + assert await _session_count(db) == 0 + + response = await client.get("/api/auth/me") + assert response.status_code == 401 + + +async def test_expired_session_rejected( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + session = AuthSession( + id=uuid.uuid4(), + user_id=seeded_user.id, + expires_at=datetime.now(UTC) - timedelta(minutes=1), + ) + db.add(session) + await db.commit() + + client.cookies.set(COOKIE_NAME, str(session.id)) + response = await client.get("/api/auth/me") + assert response.status_code == 401 + assert response.json()["code"] == "not_authenticated" diff --git a/backend/tests/test_authoring_sections.py b/backend/tests/test_authoring_sections.py new file mode 100644 index 0000000..052624d --- /dev/null +++ b/backend/tests/test_authoring_sections.py @@ -0,0 +1,70 @@ +"""Unit tests for the active-section boundary — the authoritative computation +the refinement endpoint uses (client mirrors it only for a visual hint).""" + +from app.authoring.sections import active_section, slice_lines + +DOC = """## Zweck +Dieser Ablauf beschreibt die Rechnungsstellung. + +## Ablauf +Erst exportieren, dann versenden. + +## Fallstricke +""" + + +def _span(md: str, cursor_line: int) -> tuple[int, int]: + section = active_section(md, cursor_line) + return section.start_line, section.end_line + + +def test_cursor_in_a_section_selects_from_its_heading_to_the_next() -> None: + # Line 5 is "Erst exportieren, dann versenden." under "## Ablauf" (line 4). + assert _span(DOC, 5) == (4, 5) + _prefix, section, _suffix = slice_lines(DOC, 4, 5) + assert section == "## Ablauf\nErst exportieren, dann versenden." + + +def test_cursor_on_the_heading_selects_that_section() -> None: + assert _span(DOC, 1) == (1, 2) + + +def test_trailing_blank_lines_are_excluded() -> None: + # "## Ablauf" body is followed by a blank line before "## Fallstricke"; + # the blank must not be part of the section. + start, end = _span(DOC, 4) + assert (start, end) == (4, 5) + + +def test_content_before_the_first_heading_is_its_own_section() -> None: + md = "Eine Einleitung ohne Überschrift.\n\n## Danach\nText." + assert _span(md, 1) == (1, 1) + + +def test_a_document_without_headings_is_one_section() -> None: + md = "Nur Fließtext.\nZweite Zeile." + assert _span(md, 2) == (1, 2) + + +def test_nested_headings_stop_at_a_same_or_higher_level() -> None: + md = "## A\natext\n### A1\nsub\n## B\nbtext" + # Cursor in "## A" (line 1) spans through its subsection "### A1" up to + # the line before "## B". + assert _span(md, 1) == (1, 4) + # Cursor in the subsection spans only the subsection. + assert _span(md, 4) == (3, 4) + + +def test_a_heading_inside_a_code_fence_is_not_a_boundary() -> None: + md = "## Code\n```\n## nicht echt\n```\nfertig" + assert _span(md, 3) == (1, 5) + + +def test_a_large_section_narrows_to_the_paragraph_at_the_cursor() -> None: + big = "\n\n".join(f"Absatz {i} " + "x" * 400 for i in range(6)) + md = f"## Groß\n{big}" + start, end = _span(md, 6) # somewhere deep in the section + _prefix, section, _suffix = slice_lines(md, start, end) + # Narrowed: a single paragraph, not the whole oversized section. + assert "\n\n" not in section + assert section.startswith("Absatz") diff --git a/backend/tests/test_chunking.py b/backend/tests/test_chunking.py new file mode 100644 index 0000000..578094a --- /dev/null +++ b/backend/tests/test_chunking.py @@ -0,0 +1,57 @@ +from app.rag.chunking import TARGET_CHUNK_CHARS, chunk_markdown + + +def test_heading_paths_follow_hierarchy() -> None: + md = ( + "Intro before any heading.\n\n" + "## Wartung\n\nWöchentlich schmieren.\n\n" + "### Schmierstoffe\n\nNur GX-220 verwenden.\n\n" + "## Sicherheit\n\nLichtvorhang nie überbrücken.\n" + ) + chunks = chunk_markdown(md, "Maschinenhandbuch") + paths = [chunk.heading_path for chunk in chunks] + assert paths == [ + "Maschinenhandbuch", + "Maschinenhandbuch › Wartung", + "Maschinenhandbuch › Wartung › Schmierstoffe", + "Maschinenhandbuch › Sicherheit", + ] + assert "GX-220" in chunks[2].content + + +def test_leading_h1_equal_to_title_is_not_duplicated() -> None: + md = "# Handbuch\n\nText direkt unter dem Titel.\n\n## Details\n\nMehr.\n" + chunks = chunk_markdown(md, "Handbuch") + assert chunks[0].heading_path == "Handbuch" + assert chunks[1].heading_path == "Handbuch › Details" + + +def test_oversized_section_is_split_at_paragraphs() -> None: + paragraph = "Absatz mit ausreichend vielen Wörtern für den Test. " * 20 + md = "## Lang\n\n" + "\n\n".join([paragraph] * 5) + chunks = chunk_markdown(md, "Doc") + assert len(chunks) > 1 + assert all(len(chunk.content) <= TARGET_CHUNK_CHARS + 100 for chunk in chunks) + assert all(chunk.heading_path == "Doc › Lang" for chunk in chunks) + + +def test_code_fences_are_never_split() -> None: + fence = "```\n" + "\n\n".join(["zeile eins", "zeile zwei", "zeile drei"]) + "\n```" + filler = "Wort " * 500 + md = f"## Code\n\n{filler}\n\n{fence}\n\n{filler}" + chunks = chunk_markdown(md, "Doc") + fenced = [chunk for chunk in chunks if "```" in chunk.content] + for chunk in fenced: + assert chunk.content.count("```") % 2 == 0, "chunk split inside a fence" + + +def test_heading_inside_fence_is_not_a_section() -> None: + md = "## Skript\n\n```\n# kein heading, nur ein Kommentar\necho hi\n```\n" + chunks = chunk_markdown(md, "Doc") + assert len(chunks) == 1 + assert chunks[0].heading_path == "Doc › Skript" + + +def test_empty_document_yields_no_chunks() -> None: + assert chunk_markdown("", "Leer") == [] + assert chunk_markdown("\n\n \n", "Leer") == [] diff --git a/backend/tests/test_conversations_api.py b/backend/tests/test_conversations_api.py new file mode 100644 index 0000000..c8de1b9 --- /dev/null +++ b/backend/tests/test_conversations_api.py @@ -0,0 +1,368 @@ +"""Conversations API + query mode, end to end against fakes. + +fake_llm scripts the chat completions, fake_embed the vectors — the SSE +contract, persistence rules and permission scoping are what is under test. +""" + +import json +import logging + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.conversations import stream_turn +from app.log import JsonFormatter, apply_content_log_guard +from app.models import ( + Conversation, + ConversationMode, + Document, + DocumentStatus, + DocumentVisibility, + Message, + MessageRole, + User, +) +from app.modes import get_mode +from app.rag.indexing import reindex_document +from tests.fake_openai import FakeOpenAI + +pytestmark = pytest.mark.usefixtures("fake_llm", "fake_embed") + + +async def _login(client: AsyncClient, email: str = "pablo@test.dev") -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def _indexed_public_doc(db: AsyncSession, author: User) -> Document: + document = Document( + title="Kaffeemaschine", + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md="## Pflege\n\nDie Kaffeemaschine wird freitags entkalkt.", + author_id=author.id, + department_id=author.department_id, + ) + db.add(document) + await db.flush() + await reindex_document(db, document) + await db.commit() + return document + + +async def _create_conversation(client: AsyncClient) -> str: + response = await client.post("/api/conversations", json={"mode": "query"}) + assert response.status_code == 200 + return response.json()["id"] + + +def _parse_sse(text: str) -> list[tuple[str, str]]: + events = [] + for frame in text.split("\n\n"): + if not frame.strip(): + continue + lines = dict(line.split(": ", 1) for line in frame.splitlines() if ": " in line) + events.append((lines["event"], lines["data"])) + return events + + +async def test_create_and_list(client: AsyncClient, seeded_user: User) -> None: + await _login(client) + conversation_id = await _create_conversation(client) + + listing = (await client.get("/api/conversations")).json() + assert [c["id"] for c in listing] == [conversation_id] + assert listing[0]["title"] is None # no messages yet + + +async def test_unregistered_mode_rejected( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client) + # insight stays unregistered until the EE module provides it. + response = await client.post("/api/conversations", json={"mode": "insight"}) + assert response.status_code == 400 + assert response.json()["code"] == "unknown_mode" + + +async def test_conversations_are_owner_scoped( + client: AsyncClient, seeded_user: User, seeded_admin: User +) -> None: + await _login(client, "florian@test.dev") + foreign_id = await _create_conversation(client) + await client.post("/api/auth/logout") + + await _login(client) + assert (await client.get("/api/conversations")).json() == [] + assert (await client.get(f"/api/conversations/{foreign_id}")).status_code == 404 + assert (await client.delete(f"/api/conversations/{foreign_id}")).status_code == 404 + + +async def test_turn_streams_sources_tokens_done_and_persists( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + await _indexed_public_doc(db, seeded_user) + fake_llm.chat_responses.append({"chunks": ["Frei", "tags."]}) + await _login(client) + conversation_id = await _create_conversation(client) + + # All content words exist in the document — websearch_to_tsquery ANDs + # terms, and fake embeddings carry no semantics (only FTS can match). + response = await client.post( + f"/api/conversations/{conversation_id}/messages", + json={"content": "Kaffeemaschine entkalkt?"}, + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + + events = _parse_sse(response.text) + kinds = [kind for kind, _ in events] + # searching → results → sources → answering → tokens → done + assert kinds[:4] == ["state", "state", "sources", "state"] + assert kinds.count("token") == 2 + assert kinds[-1] == "done" + + states = [json.loads(data) for kind, data in events if kind == "state"] + assert [s["phase"] for s in states] == ["searching", "results", "answering"] + assert states[1]["count"] == 1 + # Progress events carry counts only — never the query or any content. + for state in states: + assert "Kaffeemaschine" not in json.dumps(state) + + sources = json.loads(dict(events)["sources"])["chunks"] + assert sources[0]["title"] == "Kaffeemaschine" + assert "freitags entkalkt" in sources[0]["excerpt"] + + messages = ( + ( + await db.execute( + select(Message) + .where(Message.conversation_id == conversation_id) + .order_by(Message.created_at) + ) + ) + .scalars() + .all() + ) + assert [m.role for m in messages] == [MessageRole.user, MessageRole.assistant] + assert messages[1].content == "Freitags." + assert str(messages[1].id) in events[-1][1] + + # Cache-friendly structure: this turn's excerpt rides the final user turn, + # while the static system prompt stays byte-identical for prompt caching. + sent = fake_llm.requests[-1]["messages"] + assert "freitags entkalkt" not in sent[0]["content"] + assert "freitags entkalkt" in sent[-1]["content"] + + listing = (await client.get("/api/conversations")).json() + assert listing[0]["title"] == "Kaffeemaschine entkalkt?" + + # Citations are snapshotted on the message, so they survive a reload. + detail = (await client.get(f"/api/conversations/{conversation_id}")).json() + persisted = detail["messages"][1]["sources"] + assert [source["title"] for source in persisted] == ["Kaffeemaschine"] + assert "freitags entkalkt" in persisted[0]["excerpt"] + assert detail["messages"][0]["sources"] == [] # user turn + + +async def test_low_confidence_marks_no_answer( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + """The knowledge gap is explicit on the wire — the UI turns it into the + 'capture this now?' invitation.""" + await _indexed_public_doc(db, seeded_user) + await _login(client) + conversation_id = await _create_conversation(client) + + response = await client.post( + f"/api/conversations/{conversation_id}/messages", + json={"content": "Xylophonstimmung Quartalsbericht?"}, + ) + events = _parse_sse(response.text) + phases = [json.loads(data)["phase"] for kind, data in events if kind == "state"] + assert phases == ["searching", "no_answer", "answering"] + # No passage is USED to ground the answer, but the retrieved-yet-too-weak + # passages are still reported (marked unused) so the "?" inspector can + # explain why there was no answer. + chunks = json.loads(dict(events)["sources"])["chunks"] + assert chunks and all(chunk["used"] is False for chunk in chunks) + context_turn = fake_llm.requests[-1]["messages"][-1]["content"] + # The model still answers (a refusal on every unmatched question makes + # the assistant feel broken) — it just may not invent company facts. + assert "nothing relevant" in context_turn + assert "Answer anyway" in context_turn + + +async def test_turn_logs_contain_no_content( + client: AsyncClient, + db: AsyncSession, + seeded_user: User, + fake_llm: FakeOpenAI, + caplog: pytest.LogCaptureFixture, +) -> None: + """Rule 12 for the whole turn: neither the question, the retrieved + document text, nor the reply may reach a log line.""" + await _indexed_public_doc(db, seeded_user) + fake_llm.chat_responses.append({"chunks": ["GEHEIM-ANTWORT-88"]}) + await _login(client) + conversation_id = await _create_conversation(client) + + apply_content_log_guard() + with caplog.at_level(logging.DEBUG): + await client.post( + f"/api/conversations/{conversation_id}/messages", + json={"content": "Kaffeemaschine entkalkt GEHEIM-FRAGE-77?"}, + ) + + formatter = JsonFormatter() + rendered = "\n".join(formatter.format(record) for record in caplog.records) + assert "turn finished" in rendered # the turn really was logged + assert "GEHEIM-FRAGE-77" not in rendered + assert "GEHEIM-ANTWORT-88" not in rendered + assert "freitags entkalkt" not in rendered # retrieved content / excerpt + + +async def test_llm_failure_falls_back_to_a_plain_search( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + """No model, but still a knowledge base: the turn ends as a full-text hit + list the user opens themselves. It is persisted like any other reply, so a + reload replays it instead of showing an empty assistant turn.""" + # Twice: the LLM client retries once on server errors. + fake_llm.chat_responses.extend([{"status": 500}, {"status": 500}]) + await _login(client) + conversation_id = await _create_conversation(client) + + response = await client.post( + f"/api/conversations/{conversation_id}/messages", json={"content": "Hallo?"} + ) + frames = _parse_sse(response.text) + kinds = [kind for kind, _ in frames] + assert "fallback" in kinds + assert "error" not in kinds + assert kinds[-1] == "done" + fallback = next(json.loads(data) for kind, data in frames if kind == "fallback") + assert fallback["code"] == "llm_failed" + + stored = ( + ( + await db.execute( + select(Message) + .where(Message.conversation_id == conversation_id) + .order_by(Message.created_at) + ) + ) + .scalars() + .all() + ) + assert [message.role for message in stored] == [ + MessageRole.user, + MessageRole.assistant, + ] + assert stored[-1].content == "" + assert stored[-1].meta["fallback"] == "llm_failed" + + # And it survives the round trip, so the frontend can phrase it on reload. + detail = (await client.get(f"/api/conversations/{conversation_id}")).json() + assert detail["messages"][-1]["fallback"] == "llm_failed" + + +async def test_a_dead_embedding_endpoint_still_answers( + client: AsyncClient, + db: AsyncSession, + seeded_user: User, + fake_llm: FakeOpenAI, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The three roles are configured separately: with no embedding endpoint, + retrieval drops to the full-text index and the chat model still answers.""" + from app.llm.errors import LLMError + + async def _no_endpoint(texts: list[str], *, role: str = "embedding"): + raise LLMError( + "down", + role="embedding", + kind="embed", + status="error", + cause_type="APIConnectionError", + ) + + monkeypatch.setattr("app.rag.retrieval.embed", _no_endpoint) + fake_llm.chat_responses.append({"chunks": ["Klar", "doch."]}) + await _login(client) + conversation_id = await _create_conversation(client) + + response = await client.post( + f"/api/conversations/{conversation_id}/messages", json={"content": "Hallo?"} + ) + kinds = [kind for kind, _ in _parse_sse(response.text)] + assert "fallback" not in kinds + assert "error" not in kinds + assert kinds[-1] == "done" + + +async def test_delete_conversation_cascades_messages( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + await _login(client) + conversation_id = await _create_conversation(client) + await client.post( + f"/api/conversations/{conversation_id}/messages", json={"content": "Hi"} + ) + + assert ( + await client.delete(f"/api/conversations/{conversation_id}") + ).status_code == 204 + remaining = (await db.execute(select(func.count(Message.id)))).scalar_one() + assert remaining == 0 + + +async def test_empty_message_rejected(client: AsyncClient, seeded_user: User) -> None: + await _login(client) + conversation_id = await _create_conversation(client) + response = await client.post( + f"/api/conversations/{conversation_id}/messages", json={"content": ""} + ) + assert response.status_code == 422 + + +async def test_client_abort_persists_partial( + db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + """Stop button: closing the SSE generator mid-stream keeps the tokens + that were already delivered.""" + conversation = Conversation(mode=ConversationMode.query, user_id=seeded_user.id) + db.add(conversation) + await db.commit() + conversation = ( + await db.execute( + select(Conversation) + .where(Conversation.id == conversation.id) + .options(selectinload(Conversation.messages)) + ) + ).scalar_one() + + fake_llm.chat_responses.append({"chunks": ["Teil ", "eins ", "und zwei"]}) + mode = get_mode("query") + assert mode is not None + generator = stream_turn(conversation, "Frage?", mode, db) + + token_frames = 0 + async for frame in generator: + if frame.startswith("event: token"): + token_frames += 1 + if token_frames == 2: + break + await generator.aclose() + + partial = ( + await db.execute( + select(Message.content).where(Message.role == MessageRole.assistant) + ) + ).scalar_one() + assert partial == "Teil eins " diff --git a/backend/tests/test_documents_api.py b/backend/tests/test_documents_api.py new file mode 100644 index 0000000..5ee2b8c --- /dev/null +++ b/backend/tests/test_documents_api.py @@ -0,0 +1,1098 @@ +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.models import ( + Chunk, + Conversation, + ConversationMode, + Department, + Document, + DocumentStatus, + DocumentVisibility, + Job, + Message, + MessageRole, + User, + UserRole, +) +from tests.embedding_stub import deterministic_embedding +from tests.fake_openai import FakeOpenAI + + +async def _sales_user(db: AsyncSession) -> User: + department = Department(name="Sales") + db.add(department) + await db.flush() + user = User( + email="max@test.dev", + name="Max Test", + role=UserRole.member, + password_hash=hash_password("secret123"), + department_id=department.id, + ) + db.add(user) + await db.commit() + return user + + +async def _doc( + db: AsyncSession, + *, + title: str, + author: User, + visibility: DocumentVisibility = DocumentVisibility.public, + status: DocumentStatus = DocumentStatus.published, +) -> Document: + document = Document( + title=title, + status=status, + visibility=visibility, + content_md=f"# {title}\n\nInhalt.", + author_id=author.id, + department_id=author.department_id, + meta={}, + ) + db.add(document) + await db.commit() + return document + + +async def _login(client: AsyncClient, email: str) -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def test_list_requires_auth(client: AsyncClient) -> None: + assert (await client.get("/api/documents")).status_code == 401 + + +async def test_list_is_permission_scoped( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + max = await _sales_user(db) + await _doc(db, title="Öffentlich", author=max) + await _doc( + db, + title="Vertrieb intern", + author=max, + visibility=DocumentVisibility.department, + ) + await _doc(db, title="Geheim", author=max, visibility=DocumentVisibility.restricted) + await _doc( + db, title="Mein Entwurf", author=seeded_user, status=DocumentStatus.draft + ) + + await _login(client, "pablo@test.dev") + titles = {d["title"] for d in (await client.get("/api/documents")).json()["items"]} + assert titles == {"Öffentlich", "Mein Entwurf"} + + +async def test_filters( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + await _doc(db, title="Wartungsplan", author=seeded_user) + await _doc(db, title="Urlaubsregeln", author=seeded_user) + await _login(client, "pablo@test.dev") + + by_search = ( + await client.get("/api/documents", params={"search": "urlaub"}) + ).json()["items"] + assert [d["title"] for d in by_search] == ["Urlaubsregeln"] + + by_status = (await client.get("/api/documents", params={"status": "draft"})).json()[ + "items" + ] + assert by_status == [] + + +async def test_detail_hides_unreadable_documents_as_404( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + max = await _sales_user(db) + secret = await _doc( + db, title="Geheim", author=max, visibility=DocumentVisibility.restricted + ) + await _login(client, "pablo@test.dev") + response = await client.get(f"/api/documents/{secret.id}") + assert response.status_code == 404 + assert response.json()["code"] == "not_found" + + +async def test_author_reads_own_draft( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + draft = await _doc( + db, title="Entwurf", author=seeded_user, status=DocumentStatus.draft + ) + await _login(client, "pablo@test.dev") + response = await client.get(f"/api/documents/{draft.id}") + assert response.status_code == 200 + assert response.json()["content_md"].startswith("# Entwurf") + + +async def test_patch_by_author_reindexes_published( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + document = await _doc(db, title="Plan", author=seeded_user) + await _login(client, "pablo@test.dev") + response = await client.patch( + f"/api/documents/{document.id}", + json={"content_md": "# Plan\n\nNeuer Inhalt."}, + ) + assert response.status_code == 200 + + jobs = ( + (await db.execute(select(Job).where(Job.type == "index_document"))) + .scalars() + .all() + ) + assert len(jobs) == 1 + assert jobs[0].payload == {"document_id": str(document.id)} + + +async def test_patch_that_changes_nothing_does_not_reindex( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Every field a PATCH can touch is denormalized into the chunks, so the + only edit that costs no reindex is the one that changes nothing.""" + document = await _doc(db, title="Plan", author=seeded_user) + await _login(client, "pablo@test.dev") + assert ( + await client.patch(f"/api/documents/{document.id}", json={"title": "Plan"}) + ).status_code == 200 + job_count = ( + await db.execute(select(func.count(Job.id)).where(Job.type == "index_document")) + ).scalar_one() + assert job_count == 0 + + +async def test_patch_by_non_author_forbidden( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + max = await _sales_user(db) + document = await _doc(db, title="Öffentlich", author=max) + await _login(client, "pablo@test.dev") + response = await client.patch( + f"/api/documents/{document.id}", json={"title": "Gekapert"} + ) + assert response.status_code == 403 + assert response.json()["code"] == "forbidden" + + +async def test_admin_may_edit_foreign_documents( + client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User +) -> None: + document = await _doc(db, title="Plan", author=seeded_user) + await _login(client, "florian@test.dev") + response = await client.patch( + f"/api/documents/{document.id}", json={"title": "Plan v2"} + ) + assert response.status_code == 200 + + +async def test_publishing_is_the_authors_own_action( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """A draft becomes readable when its author says so: one call, indexed, + with an audit entry.""" + document = await _doc( + db, + title="Wartungsbericht", + author=seeded_user, + status=DocumentStatus.draft, + ) + await _login(client, "pablo@test.dev") + + response = await client.post(f"/api/documents/{document.id}/publish") + assert response.status_code == 200 + assert response.json()["status"] == "published" + jobs = ( + (await db.execute(select(Job).where(Job.type == "index_document"))) + .scalars() + .all() + ) + assert len(jobs) == 1 + + again = await client.post(f"/api/documents/{document.id}/publish") + assert again.status_code == 409 + assert again.json()["code"] == "invalid_status" + + +_AUTHORING_TEMPLATE = ( + 'id: t-notiz\nname: "Notiz"\nversion: "1.0"\nkind: authoring\n' + 'persona: "Du bist ein Fachredakteur."\n' + 'title_template: "Notiz von {{user.name}}"\n' + "skeleton: |\n ## Thema\n" + 'sections:\n - heading: "Thema"\n hint: "Worum es geht."\n' + "metadata:\n visibility: department\n" +) + + +async def test_create_draft_from_a_template( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + from app.template_import import parse_template, upsert_template + + row, _ = await upsert_template(db, parse_template(_AUTHORING_TEMPLATE)) + await db.commit() + await _login(client, "pablo@test.dev") + + response = await client.post("/api/documents", json={"template_id": str(row.id)}) + assert response.status_code == 201 + body = response.json() + assert body["status"] == "draft" + assert "## Thema" in body["content_md"] + assert body["title"].startswith("Notiz von") + # The template link is kept on the document (for refinement), not returned. + created = await db.get(Document, uuid.UUID(body["id"])) + assert created.meta["template"] == "t-notiz" + + +async def test_created_draft_is_author_only( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """A draft is the author's private working copy — invisible to others and + never indexed (only published documents are searchable).""" + await _login(client, "pablo@test.dev") + created = await client.post("/api/documents", json={"title": "Mein Entwurf"}) + assert created.status_code == 201 + assert created.json()["status"] == "draft" + doc_id = created.json()["id"] + + await _sales_user(db) + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + assert (await client.get(f"/api/documents/{doc_id}")).status_code == 404 + + +async def test_create_blank_requires_a_title( + client: AsyncClient, seeded_user: User +) -> None: + await _login(client, "pablo@test.dev") + response = await client.post("/api/documents", json={}) + assert response.status_code == 422 + assert response.json()["code"] == "title_required" + + +async def test_a_new_draft_can_be_published_straight_away( + client: AsyncClient, seeded_user: User +) -> None: + """No gate in between: write, publish. There is nothing to submit to.""" + await _login(client, "pablo@test.dev") + created = await client.post("/api/documents", json={"title": "Entwurf"}) + doc_id = created.json()["id"] + assert created.json()["status"] == "draft" + + published = await client.post(f"/api/documents/{doc_id}/publish") + assert published.status_code == 200 + assert published.json()["status"] == "published" + + +async def test_a_review_request_grants_reading_and_editing( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Being asked to check something is what lets you see the draft and fix + what is wrong in it — a reviewer who spots a bad number should correct it, + not file a second question.""" + reviewer = await _sales_user(db) + document = await _doc( + db, + title="Zum Prüfen", + author=seeded_user, + visibility=DocumentVisibility.public, + status=DocumentStatus.draft, + ) + await _login(client, "pablo@test.dev") + + # Candidates are readers minus the author; a public document is readable by + # everyone, so the Sales user shows up. + candidates = (await client.get(f"/api/documents/{document.id}/reviewers")).json() + ids = [candidate["id"] for candidate in candidates] + assert str(reviewer.id) in ids + assert str(seeded_user.id) not in ids + + asked = await client.post( + f"/api/documents/{document.id}/reviews", + json={ + "reviewer_id": str(reviewer.id), + "question": "Stimmen die 14 Urlaubstage noch?", + }, + ) + assert asked.status_code == 200 + assert asked.json()["open_reviews"] == 1 + assert asked.json()["reviews"][0]["question"] == "Stimmen die 14 Urlaubstage noch?" + + # Asking the same person twice would just duplicate the question. + twice = await client.post( + f"/api/documents/{document.id}/reviews", + json={"reviewer_id": str(reviewer.id)}, + ) + assert twice.status_code == 409 + assert twice.json()["code"] == "review_already_open" + + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + got = (await client.get(f"/api/documents/{document.id}")).json() + assert got["can_edit"] is True + assert got["reviews"][0]["is_mine"] is True + + queue = ( + await client.get("/api/documents", params={"assigned_to_me": "true"}) + ).json() + assert str(document.id) in [item["id"] for item in queue["items"]] + + # The reviewer fixes it, then answers. + fixed = await client.patch( + f"/api/documents/{document.id}", json={"content_md": "14 Tage, geprüft."} + ) + assert fixed.status_code == 200 + + review_id = got["reviews"][0]["id"] + answered = await client.post( + f"/api/documents/{document.id}/reviews/{review_id}/resolve" + ) + assert answered.status_code == 200 + assert answered.json()["open_reviews"] == 0 + assert answered.json()["reviews"][0]["resolved_by_name"] == "Max Test" + + # While it is open, the request is the ONLY reason they see it — which is + # what the UI needs to know before the answer takes the access away. + assert got["access_reason"] == "review" + + # And the grant goes with the answer: the draft is the author's again. + after = await client.get(f"/api/documents/{document.id}") + assert after.status_code == 404 + + +async def test_a_reviewer_fixes_the_text_but_does_not_re_address_it( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Being asked is a licence to correct the content, not to decide who reads + it: publishing and visibility stay with the owner.""" + reviewer = await _sales_user(db) + document = await _doc( + db, + title="Zum Prüfen", + author=seeded_user, + visibility=DocumentVisibility.public, + status=DocumentStatus.draft, + ) + await _login(client, "pablo@test.dev") + await client.post( + f"/api/documents/{document.id}/reviews", + json={"reviewer_id": str(reviewer.id)}, + ) + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + + fixed = await client.patch( + f"/api/documents/{document.id}", json={"content_md": "Korrigiert."} + ) + assert fixed.status_code == 200 + + published = await client.post(f"/api/documents/{document.id}/publish") + assert published.status_code == 403 + narrowed = await client.patch( + f"/api/documents/{document.id}", json={"visibility": "restricted"} + ) + assert narrowed.status_code == 403 + assert narrowed.json()["code"] == "forbidden" + + +async def test_an_open_question_survives_publishing( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """The point of the whole mechanism: a document can be published AND still + carry an unanswered question, which is exactly when readers need to know.""" + reviewer = await _sales_user(db) + document = await _doc( + db, + title="Urlaubsanträge", + author=seeded_user, + visibility=DocumentVisibility.public, + status=DocumentStatus.published, + ) + await _login(client, "pablo@test.dev") + + await client.post( + f"/api/documents/{document.id}/reviews", + json={"reviewer_id": str(reviewer.id), "question": "Noch aktuell?"}, + ) + listed = (await client.get("/api/documents")).json()["items"] + entry = next(item for item in listed if item["id"] == str(document.id)) + assert entry["status"] == "published" + assert entry["open_reviews"] == 1 + + +async def test_a_reviewer_must_have_read_access( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """A restricted document cannot be handed to someone who could not read + it — they are neither offered nor accepted.""" + outsider = await _sales_user(db) + document = await _doc( + db, + title="Vertraulich", + author=seeded_user, + visibility=DocumentVisibility.restricted, + status=DocumentStatus.draft, + ) + await _login(client, "pablo@test.dev") + + candidates = (await client.get(f"/api/documents/{document.id}/reviewers")).json() + assert str(outsider.id) not in [candidate["id"] for candidate in candidates] + + bad = await client.post( + f"/api/documents/{document.id}/reviews", + json={"reviewer_id": str(outsider.id)}, + ) + assert bad.status_code == 422 + assert bad.json()["code"] == "invalid_reviewer" + + +async def test_sharing_grants_another_department_read_access( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None +) -> None: + """A department-visible document shared with another department becomes + readable, searchable and listable for that department — the grant plugs + straight into the existing permission filter.""" + from app.rag.indexing import reindex_document + + max = await _sales_user(db) + document = Document( + title="Engineering intern", + status=DocumentStatus.published, + visibility=DocumentVisibility.department, + content_md="## Verfahren\n\nInternes Verfahren der Entwicklung.", + author_id=seeded_user.id, + department_id=seeded_user.department_id, + meta={}, + ) + db.add(document) + await db.flush() + await reindex_document(db, document) + await db.commit() + + # Before sharing, the Sales user cannot see it. + await _login(client, "max@test.dev") + assert (await client.get(f"/api/documents/{document.id}")).status_code == 404 + + # The author shares it with Sales. + await client.post("/api/auth/logout") + await _login(client, "pablo@test.dev") + shared = await client.put( + f"/api/documents/{document.id}/departments", + json={"department_ids": [str(max.department_id)]}, + ) + assert shared.status_code == 200 + assert [d["name"] for d in shared.json()["shared_departments"]] == ["Sales"] + + # Now the Sales user reads, lists (under the shared department) and finds it. + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + assert (await client.get(f"/api/documents/{document.id}")).status_code == 200 + listed = ( + await client.get( + "/api/documents", params={"department": str(max.department_id)} + ) + ).json() + assert "Engineering intern" in [d["title"] for d in listed["items"]] + hits = (await client.get("/api/documents/search?q=Verfahren Entwicklung")).json() + assert "Engineering intern" in [h["title"] for h in hits] + + # Unsharing removes it again. + await client.post("/api/auth/logout") + await _login(client, "pablo@test.dev") + await client.put( + f"/api/documents/{document.id}/departments", json={"department_ids": []} + ) + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + assert (await client.get(f"/api/documents/{document.id}")).status_code == 404 + + +async def test_sharing_with_an_unknown_department_is_404( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + document = await _doc(db, title="Teilbar", author=seeded_user) + await _login(client, "pablo@test.dev") + response = await client.put( + f"/api/documents/{document.id}/departments", + json={"department_ids": ["00000000-0000-0000-0000-000000000000"]}, + ) + assert response.status_code == 404 + + +async def test_author_never_locks_themselves_out_of_their_document( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """The author keeps read access as author, so restricting their own document + needs no confirmation and never blocks.""" + document = await _doc(db, title="Meins", author=seeded_user) + await _login(client, "pablo@test.dev") + response = await client.patch( + f"/api/documents/{document.id}", json={"visibility": "restricted"} + ) + assert response.status_code == 200 + assert response.json()["visibility"] == "restricted" + + +async def test_admin_is_warned_before_editing_away_their_own_access( + client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User +) -> None: + """An admin editing a document they do not own can lose access; the change + is blocked with a warning until they explicitly confirm the override.""" + # Owned by Engineering (pablo); the admin is in Administration. + document = await _doc(db, title="Fremd", author=seeded_user) + await _login(client, "florian@test.dev") + + warned = await client.patch( + f"/api/documents/{document.id}", json={"visibility": "restricted"} + ) + assert warned.status_code == 409 + assert warned.json()["code"] == "self_lockout_warning" + + confirmed = await client.patch( + f"/api/documents/{document.id}", + json={"visibility": "restricted", "confirm_lockout": True}, + ) + assert confirmed.status_code == 200 + # Having confirmed, the admin has indeed lost access on the next read. + assert (await client.get(f"/api/documents/{document.id}")).status_code == 404 + + +async def test_suggest_similar_for_an_unknown_conversation_is_empty( + client: AsyncClient, seeded_user: User +) -> None: + """No conversation (or one that is not the caller's) yields no matches and + never touches the model.""" + await _login(client, "pablo@test.dev") + response = await client.post( + "/api/documents/suggest-similar", + json={"conversation_id": "00000000-0000-0000-0000-000000000000"}, + ) + assert response.status_code == 200 + assert response.json() == [] + + +async def test_export_returns_a_zip_of_readable_documents( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + import io + import zipfile + + await _doc( + db, + title="Exportierbar", + author=seeded_user, + visibility=DocumentVisibility.public, + ) + await _login(client, "pablo@test.dev") + + response = await client.get("/api/documents/export") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/zip" + + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + names = archive.namelist() + assert names and all(name.endswith(".md") for name in names) + contents = "".join(archive.read(name).decode() for name in names) + assert "Exportierbar" in contents + assert "---" in contents # YAML frontmatter + + +async def test_delete_cascades_chunks( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + document = await _doc(db, title="Weg damit", author=seeded_user) + db.add( + Chunk( + document_id=document.id, + chunk_index=0, + content="Inhalt.", + embedding=deterministic_embedding("Inhalt."), + ) + ) + await db.commit() + + await _login(client, "pablo@test.dev") + assert (await client.delete(f"/api/documents/{document.id}")).status_code == 204 + remaining = (await db.execute(select(func.count(Chunk.id)))).scalar_one() + assert remaining == 0 + + +@pytest.mark.parametrize("route", ["publish", "reviews"]) +async def test_workflow_actions_on_an_unreadable_document_are_404( + client: AsyncClient, db: AsyncSession, seeded_user: User, route: str +) -> None: + """Every workflow action loads the document through the read filter first, + so an unreadable one is missing rather than forbidden.""" + max = await _sales_user(db) + secret = await _doc( + db, + title="Fremd", + author=max, + visibility=DocumentVisibility.restricted, + status=DocumentStatus.draft, + ) + await _login(client, "pablo@test.dev") + response = await client.post( + f"/api/documents/{secret.id}/{route}", json={"reviewer_id": str(max.id)} + ) + assert response.status_code == 404 + + +async def test_archive_and_republish( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + document = await _doc(db, title="Archivierbar", author=seeded_user) + await _login(client, "pablo@test.dev") + + archived = await client.patch( + f"/api/documents/{document.id}", json={"status": "archived"} + ) + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + + republished = await client.patch( + f"/api/documents/{document.id}", json={"status": "published"} + ) + assert republished.json()["status"] == "published" + + # Both transitions enqueue an index job (remove chunks / rebuild). + job_count = ( + await db.execute(select(func.count(Job.id)).where(Job.type == "index_document")) + ).scalar_one() + assert job_count == 2 + + # A draft has nothing to archive — it was never readable in the first + # place; publishing is its own endpoint, not a status field. + draft = await _doc( + db, title="Wartet", author=seeded_user, status=DocumentStatus.draft + ) + blocked = await client.patch( + f"/api/documents/{draft.id}", json={"status": "archived"} + ) + assert blocked.status_code == 409 + assert blocked.json()["code"] == "invalid_status" + + +async def test_stats_counts_published_documents_and_departments( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Landing-page growth signal: aggregates only, no titles.""" + await _doc(db, title="Sichtbar", author=seeded_user) + await _doc(db, title="Entwurf", author=seeded_user, status=DocumentStatus.draft) + await _login(client, "pablo@test.dev") + + stats = (await client.get("/api/documents/stats")).json() + assert stats["documents_total"] == 1 # drafts do not count as knowledge + assert stats["departments_total"] >= 1 + assert "Sichtbar" not in str(stats) + + +async def test_stats_requires_auth(client: AsyncClient) -> None: + assert (await client.get("/api/documents/stats")).status_code == 401 + + +async def test_search_finds_documents_by_content_and_reports_the_section( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None +) -> None: + """Search goes through the same hybrid retrieval as the chat, so a hit + can name the section it matched.""" + from app.rag.indexing import reindex_document + + document = Document( + title="Wartungsplan", + status=DocumentStatus.published, + visibility=DocumentVisibility.public, + content_md="## Intervalle\n\nDie Presse wird freitags gewartet.", + author_id=seeded_user.id, + department_id=seeded_user.department_id, + meta={}, + ) + db.add(document) + await db.flush() + await reindex_document(db, document) + await db.commit() + + await _login(client, "pablo@test.dev") + hits = (await client.get("/api/documents/search?q=Presse freitags gewartet")).json() + assert [hit["title"] for hit in hits] == ["Wartungsplan"] + # A section match carries the heading path; a title-only match would not. + assert "Intervalle" in hits[0]["heading_path"] + + +async def test_search_falls_back_to_titles_for_unindexed_drafts( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None +) -> None: + """Drafts are readable but never chunked — only the title can match.""" + await _doc( + db, title="Entwurf Hydraulik", author=seeded_user, status=DocumentStatus.draft + ) + await _login(client, "pablo@test.dev") + + hits = (await client.get("/api/documents/search?q=Hydraulik")).json() + assert len(hits) == 1 + # A title-only match has no section heading path. + assert hits[0]["heading_path"] == "" + assert hits[0]["status"] == "draft" + + +async def test_search_never_returns_another_departments_document( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None +) -> None: + """Same permission filter as retrieval — by construction (rule 2).""" + from app.rag.indexing import reindex_document + + max = await _sales_user(db) + secret = Document( + title="Vertriebsgeheimnis", + status=DocumentStatus.published, + visibility=DocumentVisibility.department, + content_md="## Rabatte\n\nSonderrabatte für Großkunden.", + author_id=max.id, + department_id=max.department_id, + meta={}, + ) + db.add(secret) + await db.flush() + await reindex_document(db, secret) + await db.commit() + + await _login(client, "pablo@test.dev") + hits = (await client.get("/api/documents/search?q=Sonderrabatte Großkunden")).json() + assert hits == [] + + # Max, who owns it, does find it. + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + hits = (await client.get("/api/documents/search?q=Sonderrabatte Großkunden")).json() + assert [hit["title"] for hit in hits] == ["Vertriebsgeheimnis"] + + +async def test_search_requires_a_query(client: AsyncClient, seeded_user: User) -> None: + await _login(client, "pablo@test.dev") + assert (await client.get("/api/documents/search?q=")).status_code == 422 + + +async def test_list_is_paginated_and_sortable( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """The browse list is the one screen that grows without bound, so it + pages server-side rather than shipping the whole knowledge base.""" + for index in range(5): + await _doc(db, title=f"Doc {index}", author=seeded_user) + await _login(client, seeded_user.email) + + first = ( + await client.get("/api/documents", params={"per_page": 2, "page": 1}) + ).json() + assert len(first["items"]) == 2 + assert first["total"] >= 5 + assert first["per_page"] == 2 + + second = ( + await client.get("/api/documents", params={"per_page": 2, "page": 2}) + ).json() + assert len(second["items"]) == 2 + # Pages do not overlap. + assert {d["id"] for d in first["items"]}.isdisjoint( + d["id"] for d in second["items"] + ) + + # Both sort orders are accepted and cover the same set on one page. + by_created = ( + await client.get("/api/documents", params={"sort": "created", "per_page": 100}) + ).json() + by_updated = ( + await client.get("/api/documents", params={"sort": "updated", "per_page": 100}) + ).json() + assert {d["id"] for d in by_created["items"]} == { + d["id"] for d in by_updated["items"] + } + + # An out-of-range page is empty, not an error. + assert (await client.get("/api/documents", params={"page": 999})).json()[ + "items" + ] == [] + + +async def test_the_total_counts_only_what_the_user_may_see( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """`total` runs over the same filters as the page — a count that ignored + permissions would leak how much exists (rule 2).""" + max_user = await _sales_user(db) + await _doc( + db, + title="Nur Engineering", + author=seeded_user, + visibility=DocumentVisibility.department, + ) + await _login(client, max_user.email) + + body = (await client.get("/api/documents", params={"per_page": 100})).json() + assert "Nur Engineering" not in {d["title"] for d in body["items"]} + assert body["total"] == len(body["items"]) + + +async def test_history_records_the_lifecycle_with_actors_and_snapshots( + client: AsyncClient, seeded_user: User +) -> None: + """Every transition is recorded newest-first; a content edit snapshots the + Markdown so a past version can be fetched and diffed.""" + await _login(client, "pablo@test.dev") + created = await client.post("/api/documents", json={"title": "Verlauf"}) + doc_id = created.json()["id"] + + await client.patch( + f"/api/documents/{doc_id}", json={"content_md": "# Verlauf\n\nErste Fassung."} + ) + await client.post(f"/api/documents/{doc_id}/publish") + + history = (await client.get(f"/api/documents/{doc_id}/history")).json() + assert [event["action"] for event in history] == [ + "published", + "edited", + "created", + ] + assert all(event["actor_id"] == str(seeded_user.id) for event in history) + assert all(event["actor_name"] == "Pablo Test" for event in history) + + # The edit carries a content snapshot; the pure transitions do not. + edited = next(event for event in history if event["action"] == "edited") + published = next(event for event in history if event["action"] == "published") + assert edited["has_snapshot"] is True + assert published["has_snapshot"] is False + + version = ( + await client.get(f"/api/documents/{doc_id}/versions/{edited['id']}") + ).json() + assert version["content_md"] == "# Verlauf\n\nErste Fassung." + assert version["title"] == "Verlauf" + + +async def test_a_version_carries_the_content_it_replaced( + client: AsyncClient, seeded_user: User +) -> None: + """A snapshot is written after its event, so a version's diff runs against + the snapshot before it — otherwise every entry would show the next entry's + change.""" + await _login(client, "pablo@test.dev") + doc_id = (await client.post("/api/documents", json={"title": "Verlauf"})).json()[ + "id" + ] + await client.patch( + f"/api/documents/{doc_id}", json={"content_md": "Erste Fassung."} + ) + await client.patch( + f"/api/documents/{doc_id}", json={"content_md": "Zweite Fassung."} + ) + + history = (await client.get(f"/api/documents/{doc_id}/history")).json() + assert [event["action"] for event in history] == ["edited", "edited", "created"] + newest, middle, oldest = history + versions = { + event["id"]: ( + await client.get(f"/api/documents/{doc_id}/versions/{event['id']}") + ).json() + for event in history + } + + assert versions[newest["id"]]["content_md"] == "Zweite Fassung." + assert versions[newest["id"]]["previous_content_md"] == "Erste Fassung." + assert versions[middle["id"]]["content_md"] == "Erste Fassung." + assert ( + versions[middle["id"]]["previous_content_md"] + == versions[oldest["id"]]["content_md"] + ) + # Nothing preceded the first snapshot: everything in it was added. + assert versions[oldest["id"]]["previous_content_md"] is None + + +async def test_history_captures_who_answered_a_review_request( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Asking and answering are both in the timeline, with the colleague — not + the author — recorded as the one who checked it.""" + reviewer = await _sales_user(db) + document = await _doc( + db, + title="Delegiert", + author=seeded_user, + visibility=DocumentVisibility.public, + status=DocumentStatus.published, + ) + await _login(client, "pablo@test.dev") + asked = await client.post( + f"/api/documents/{document.id}/reviews", + json={"reviewer_id": str(reviewer.id)}, + ) + review_id = asked.json()["reviews"][0]["id"] + await client.post("/api/auth/logout") + await _login(client, "max@test.dev") + await client.post(f"/api/documents/{document.id}/reviews/{review_id}/resolve") + + history = (await client.get(f"/api/documents/{document.id}/history")).json() + assert [event["action"] for event in history[:2]] == [ + "review_resolved", + "review_requested", + ] + assert history[0]["actor_id"] == str(reviewer.id) + assert history[0]["actor_name"] == "Max Test" + assert history[1]["actor_name"] == "Pablo Test" + + +async def test_history_records_a_visibility_change( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + document = await _doc( + db, + title="Sichtbarkeit", + author=seeded_user, + visibility=DocumentVisibility.public, + ) + await _login(client, "pablo@test.dev") + await client.patch( + f"/api/documents/{document.id}", json={"visibility": "department"} + ) + history = (await client.get(f"/api/documents/{document.id}/history")).json() + assert history[0]["action"] == "visibility_changed" + assert history[0]["visibility"] == "department" + assert history[0]["has_snapshot"] is False + + +async def test_history_is_hidden_for_unreadable_documents( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """History follows the document's read gate — existence must not leak.""" + max = await _sales_user(db) + secret = await _doc( + db, title="Geheim", author=max, visibility=DocumentVisibility.restricted + ) + await _login(client, "pablo@test.dev") + assert (await client.get(f"/api/documents/{secret.id}/history")).status_code == 404 + assert ( + await client.get(f"/api/documents/{secret.id}/versions/{uuid.uuid4()}") + ).status_code == 404 + + +async def test_paging_is_stable_when_timestamps_collide( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + """Documents seeded in one transaction share a timestamp to the + microsecond. Ordering only by that timestamp is a PARTIAL order, and + Postgres may return tied rows differently per query: two pages overlap, + one document shows up twice and another is unreachable. The id is the + tiebreaker that makes the order total. + """ + for index in range(8): + await _doc(db, title=f"Gleichzeitig {index}", author=seeded_user) + await _login(client, seeded_user.email) + + seen: list[str] = [] + for page in (1, 2, 3): + body = ( + await client.get("/api/documents", params={"per_page": 3, "page": page}) + ).json() + seen.extend(item["id"] for item in body["items"]) + + assert len(seen) == len(set(seen)), "a document appeared on more than one page" + + +async def _conversation_about( + db: AsyncSession, user: User, question: str +) -> Conversation: + """A chat with one exchange in it — the thing a capture can start from.""" + conversation = Conversation(mode=ConversationMode.query, user_id=user.id) + db.add(conversation) + await db.flush() + db.add_all( + [ + Message( + conversation_id=conversation.id, + role=MessageRole.user, + content=question, + ), + Message( + conversation_id=conversation.id, + role=MessageRole.assistant, + content="Dazu ist nichts dokumentiert.", + ), + ] + ) + await db.commit() + return conversation + + +async def test_a_capture_out_of_a_chat_keeps_the_chat_as_background( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + """The subject of the chat becomes the draft's background context, which is + what makes the first refinement on-topic instead of generic.""" + conversation = await _conversation_about(db, seeded_user, "Wie wechsle ich das Öl?") + fake_llm.chat_responses.append({"content": '{"topic": "Ölwechsel HP-20"}'}) + await _login(client, "pablo@test.dev") + + response = await client.post( + "/api/documents", + json={"title": "Ölwechsel", "conversation_id": str(conversation.id)}, + ) + assert response.status_code == 201 + document = await db.get(Document, uuid.UUID(response.json()["id"])) + assert document is not None + assert document.meta["context"] == "Ölwechsel HP-20" + assert document.meta["conversation_id"] == str(conversation.id) + + +async def test_extending_a_document_out_of_a_chat_keeps_the_same_background( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + """Answering "this is already documented" by extending that document must + carry the chat along too — the conversation led here either way.""" + document = await _doc(db, title="Wartungsplan", author=seeded_user) + conversation = await _conversation_about(db, seeded_user, "Wie wechsle ich das Öl?") + fake_llm.chat_responses.append({"content": '{"topic": "Ölwechsel HP-20"}'}) + await _login(client, "pablo@test.dev") + + response = await client.patch( + f"/api/documents/{document.id}", + json={"conversation_id": str(conversation.id)}, + ) + assert response.status_code == 200 + await db.refresh(document) + assert document.meta["context"] == "Ölwechsel HP-20" + + # Metadata only: nothing a chunk carries changed, so nothing is reindexed. + jobs = ( + await db.execute(select(func.count(Job.id)).where(Job.type == "index_document")) + ).scalar_one() + assert jobs == 0 + + +async def test_a_foreign_conversation_contributes_no_background( + client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI +) -> None: + """Owner-scoped: another user's chat is not a source of context, and the + capture still succeeds.""" + max = await _sales_user(db) + conversation = await _conversation_about(db, max, "Interne Rabattgrenzen?") + await _login(client, "pablo@test.dev") + + response = await client.post( + "/api/documents", + json={"title": "Eigenes", "conversation_id": str(conversation.id)}, + ) + assert response.status_code == 201 + document = await db.get(Document, uuid.UUID(response.json()["id"])) + assert document is not None + assert "context" not in document.meta + assert fake_llm.requests == [] diff --git a/backend/tests/test_excerpt.py b/backend/tests/test_excerpt.py new file mode 100644 index 0000000..3eb0c55 --- /dev/null +++ b/backend/tests/test_excerpt.py @@ -0,0 +1,82 @@ +"""The citation popover shows prose, not Markdown source. + +The popover is a small hover surface showing a fragment, so the excerpt is +reduced to prose rather than rendered: a cited table would otherwise become +a real table squeezed into ~320px, and a cited heading would render at h2 +size. Clicking the badge opens the document in the side panel, which does +render Markdown properly. + +These cases are all real shapes from the fixture corpus. +""" + +from app.modes.query import EXCERPT_CHARS +from app.modes.query import excerpt as make_excerpt + + +def test_a_cited_table_reads_as_prose() -> None: + """The worst case, and the one that sent this back for a second pass: + the divider row is pure punctuation and survives naive stripping.""" + excerpt = make_excerpt( + "| Code | Bedeutung | Sofortmaßnahme |\n" + "|-------|------------|----------------|\n" + "| E-101 | Not-Aus-Kreis unterbrochen | Alle Not-Aus-Taster prüfen |" + ) + assert "|" not in excerpt + assert "---" not in excerpt + assert excerpt.startswith("Code · Bedeutung · Sofortmaßnahme · E-101") + + +def test_inline_emphasis_loses_its_markers_not_its_words() -> None: + for source, expected in [ + ("**Eingang erfassen**: im ERP anlegen.", "Eingang erfassen: im ERP anlegen."), + ("die Presse muss *drucklos* sein", "die Presse muss drucklos sein"), + ("__Achtung__ beim Anfahren", "Achtung beim Anfahren"), + ("setze `max_turns` hoch", "setze max_turns hoch"), + ("**fett _und_ kursiv**", "fett und kursiv"), + ]: + assert make_excerpt(source) == expected + + +def test_links_keep_their_text_and_drop_their_target() -> None: + excerpt = make_excerpt( + "Siehe [das Handbuch](https://intranet.example/doc.pdf) dazu." + ) + assert excerpt == "Siehe das Handbuch dazu." + # A URL in a hover preview is noise, and it is also the part a reader + # cannot click here anyway. + assert "http" not in excerpt + + +def test_block_markers_and_fences_are_dropped() -> None: + excerpt = make_excerpt( + "## Wartung\n\n" + "- **Getriebeöl GX-220**: monatlich\n" + "1. Bettbahnöl wöchentlich\n" + "> Wichtig: erst entlüften\n" + "```yaml\n" + "interval: 30\n" + "```" + ) + assert excerpt.startswith("Wartung Getriebeöl GX-220: monatlich") + for marker in ("##", "```", "> ", "**"): + assert marker not in excerpt + + +def test_plain_prose_is_left_alone() -> None: + source = "Die Kaffeemaschine wird freitags entkalkt." + assert make_excerpt(source) == source + + +def test_underscores_inside_identifiers_survive() -> None: + """`result_document_id` is not italics. Emphasis needs a non-space + character on both sides, which is what keeps snake_case intact.""" + assert make_excerpt("das Feld result_document_id bleibt leer") == ( + "das Feld result_document_id bleibt leer" + ) + + +def test_long_content_is_cut_on_a_word_boundary() -> None: + excerpt = make_excerpt("Wartung " * 200) + assert len(excerpt) <= EXCERPT_CHARS + 1 # the ellipsis + assert excerpt.endswith("…") + assert not excerpt.rstrip("…").endswith(" ") diff --git a/backend/tests/test_help_import.py b/backend/tests/test_help_import.py new file mode 100644 index 0000000..61df7c6 --- /dev/null +++ b/backend/tests/test_help_import.py @@ -0,0 +1,114 @@ +"""Built-in help documents: imported from files, never editable in the UI.""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.help_import import ( + HelpImportError, + import_help_documents, + parse_help_document, +) +from app.models import Document, Job, User + +pytestmark = pytest.mark.usefixtures("fake_embed") + +HELP_FILE = """--- +key: test-hilfe +title: "Testhilfe" +--- + +# Testhilfe + +So funktioniert es. +""" + + +def test_parse_splits_frontmatter_from_body() -> None: + key, title, body = parse_help_document(HELP_FILE) + assert (key, title) == ("test-hilfe", "Testhilfe") + assert body.startswith("# Testhilfe") + + +def test_parse_rejects_a_file_without_frontmatter() -> None: + with pytest.raises(HelpImportError): + parse_help_document("# Just markdown") + + +async def _import(db: AsyncSession, tmp_path, monkeypatch, text: str) -> int: + (tmp_path / "hilfe.md").write_text(text) + monkeypatch.setenv("PABLAN_HELP_DIR", str(tmp_path)) + from app.config import get_settings + + get_settings.cache_clear() + try: + return await import_help_documents(db) + finally: + get_settings.cache_clear() + + +async def test_import_is_idempotent_and_reindexes_only_on_change( + db: AsyncSession, tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert await _import(db, tmp_path, monkeypatch, HELP_FILE) == 1 + document = ( + await db.execute(select(Document).where(Document.is_builtin.is_(True))) + ).scalar_one() + assert document.title == "Testhilfe" + assert document.status.value == "published" + assert document.visibility.value == "public" + assert document.author_id is None + + # Unchanged: no second document, no new index job. + assert await _import(db, tmp_path, monkeypatch, HELP_FILE) == 0 + + # Changed: content is refreshed in place and re-indexed. + assert ( + await _import( + db, tmp_path, monkeypatch, HELP_FILE.replace("So funktioniert es.", "Neu.") + ) + == 1 + ) + documents = ( + (await db.execute(select(Document).where(Document.is_builtin.is_(True)))) + .scalars() + .all() + ) + assert len(documents) == 1 + assert "Neu." in documents[0].content_md + jobs = (await db.execute(select(Job))).scalars().all() + assert len(jobs) == 2 # one per actual change + + +async def test_help_documents_cannot_be_edited_or_deleted( + client: AsyncClient, + db: AsyncSession, + seeded_admin: User, + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Not even an admin may edit them — the next deploy would overwrite it.""" + await _import(db, tmp_path, monkeypatch, HELP_FILE) + document = ( + await db.execute(select(Document).where(Document.is_builtin.is_(True))) + ).scalar_one() + + login = await client.post( + "/api/auth/login", json={"email": "florian@test.dev", "password": "secret123"} + ) + assert login.status_code == 200 + + listed = (await client.get("/api/documents")).json()["items"] + entry = next(row for row in listed if row["id"] == str(document.id)) + assert entry["is_builtin"] is True + assert entry["can_edit"] is False + + patched = await client.patch( + f"/api/documents/{document.id}", json={"title": "Gekapert"} + ) + assert patched.status_code == 409 + assert patched.json()["code"] == "builtin_readonly" + + deleted = await client.delete(f"/api/documents/{document.id}") + assert deleted.status_code == 409 diff --git a/backend/tests/test_indexing.py b/backend/tests/test_indexing.py new file mode 100644 index 0000000..4752325 --- /dev/null +++ b/backend/tests/test_indexing.py @@ -0,0 +1,152 @@ +import pytest +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from app.ingestion.handlers import INDEX_DOCUMENT, REINDEX_ALL +from app.ingestion.queue import enqueue, process_one +from app.models import ( + Chunk, + Document, + DocumentStatus, + DocumentVisibility, + Job, + JobStatus, + User, +) +from app.rag.indexing import reindex_document + +pytestmark = pytest.mark.usefixtures("fake_embed") + +CONTENT = ( + "## Wartung\n\nWöchentlich schmieren mit GX-220.\n\n" + "## Sicherheit\n\nLichtvorhang niemals überbrücken.\n" +) + + +@pytest.fixture +def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(db_engine, expire_on_commit=False) + + +async def _doc( + db: AsyncSession, + seeded_user: User, + *, + title: str = "Handbuch", + status: DocumentStatus = DocumentStatus.published, +) -> Document: + document = Document( + title=title, + status=status, + visibility=DocumentVisibility.department, + content_md=CONTENT, + author_id=seeded_user.id, + department_id=seeded_user.department_id, + meta={}, + ) + db.add(document) + await db.flush() + return document + + +async def _chunk_count(db: AsyncSession, document_id) -> int: + return ( + await db.execute( + select(func.count(Chunk.id)).where(Chunk.document_id == document_id) + ) + ).scalar_one() + + +async def test_reindex_creates_chunks_with_meta( + db: AsyncSession, seeded_user: User +) -> None: + document = await _doc(db, seeded_user) + count = await reindex_document(db, document) + await db.commit() + assert count == 2 + + chunks = ( + (await db.execute(select(Chunk).where(Chunk.document_id == document.id))) + .scalars() + .all() + ) + assert {chunk.meta["heading_path"] for chunk in chunks} == { + "Handbuch › Wartung", + "Handbuch › Sicherheit", + } + for chunk in chunks: + assert chunk.meta["visibility"] == "department" + assert chunk.meta["department_id"] == str(seeded_user.department_id) + + +async def test_reindex_is_idempotent(db: AsyncSession, seeded_user: User) -> None: + document = await _doc(db, seeded_user) + await reindex_document(db, document) + await db.commit() + first_ids = { + chunk.id + for chunk in ( + await db.execute(select(Chunk).where(Chunk.document_id == document.id)) + ).scalars() + } + + await reindex_document(db, document) + await db.commit() + chunks = ( + (await db.execute(select(Chunk).where(Chunk.document_id == document.id))) + .scalars() + .all() + ) + assert len(chunks) == 2 + assert first_ids.isdisjoint({chunk.id for chunk in chunks}) + + +async def test_index_job_indexes_published_and_cleans_unpublished( + db: AsyncSession, + seeded_user: User, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + document = await _doc(db, seeded_user) + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) + await db.commit() + assert await process_one(session_factory) is True + assert await _chunk_count(db, document.id) == 2 + + document.status = DocumentStatus.archived + await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) + await db.commit() + assert await process_one(session_factory) is True + assert await _chunk_count(db, document.id) == 0 + + +async def test_reindex_all_fans_out_and_rebuilds_from_markdown( + db: AsyncSession, + seeded_user: User, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + published = [await _doc(db, seeded_user, title=f"Doc {i}") for i in range(3)] + await _doc(db, seeded_user, title="Entwurf", status=DocumentStatus.draft) + # Simulate an embedding-model swap: all derivatives are gone. + await db.execute(delete(Chunk)) + await enqueue(db, REINDEX_ALL) + await db.commit() + + # Fan-out: the reindex_all job only enqueues per-document jobs. + assert await process_one(session_factory) is True + index_jobs = ( + ( + await db.execute( + select(Job).where( + Job.type == INDEX_DOCUMENT, Job.status == JobStatus.pending + ) + ) + ) + .scalars() + .all() + ) + assert len(index_jobs) == 3 # the draft is not indexed + + while await process_one(session_factory): + pass + for document in published: + assert await _chunk_count(db, document.id) == 2 diff --git a/backend/tests/test_llm_client.py b/backend/tests/test_llm_client.py new file mode 100644 index 0000000..bc9e524 --- /dev/null +++ b/backend/tests/test_llm_client.py @@ -0,0 +1,182 @@ +import pytest +from pydantic import BaseModel + +from app.llm.client import chat_json, chat_stream, embed +from app.llm.errors import LLMError +from app.metrics import metrics +from tests.fake_openai import FakeOpenAI + +PING = [{"role": "user", "content": "hi"}] + + +class Verdict(BaseModel): + covered: list[str] + done: bool + + +def _counter(name: str, **labels: str) -> float: + for entry in metrics.snapshot()["counters"].get(name, []): + if entry["labels"] == labels: + return entry["value"] + return 0.0 + + +async def test_chat_stream_yields_deltas_and_records_metrics( + fake_llm: FakeOpenAI, +) -> None: + fake_llm.chat_responses.append({"chunks": ["Hel", "lo"]}) + out = [token async for token in chat_stream(PING)] + assert out == ["Hel", "lo"] + + body = fake_llm.requests[-1] + assert body["stream"] is True + assert body["stream_options"] == {"include_usage": True} + assert ( + _counter("llm_calls_total", role="chat", kind="chat_stream", status="ok") == 1 + ) + assert _counter("llm_tokens_total", role="chat", direction="completion") == 3 + + +async def test_chat_stream_role_override(fake_llm: FakeOpenAI) -> None: + [token async for token in chat_stream(PING, role="utility", max_tokens=1)] + assert fake_llm.requests[-1]["max_tokens"] == 1 + assert ( + _counter("llm_calls_total", role="utility", kind="chat_stream", status="ok") + == 1 + ) + + +async def test_chat_stream_error_is_sanitized(fake_llm: FakeOpenAI) -> None: + # Twice: the client retries once on connection/server errors. + fake_llm.chat_responses.extend([{"status": 500}, {"status": 500}]) + with pytest.raises(LLMError) as excinfo: + [token async for token in chat_stream(PING)] + error = excinfo.value + assert "chat_stream failed" in str(error) + # The server error body must not leak into the exception message. + assert "induced failure" not in str(error) + assert error.role == "chat" + assert error.kind == "chat_stream" + assert error.status == "error" + assert error.status_code == 500 + assert error.cause_type # original exception class name only + assert isinstance(error.duration_ms, int) + assert ( + _counter("llm_calls_total", role="chat", kind="chat_stream", status="error") + == 1 + ) + + +async def test_chat_json_happy_path(fake_llm: FakeOpenAI) -> None: + fake_llm.chat_responses.append({"content": '{"covered": ["a"], "done": true}'}) + result = await chat_json(PING, Verdict) + assert result == Verdict(covered=["a"], done=True) + + body = fake_llm.requests[-1] + assert body["response_format"]["type"] == "json_schema" + assert body["response_format"]["json_schema"]["name"] == "Verdict" + assert ( + _counter("llm_calls_total", role="utility", kind="chat_json", status="ok") == 1 + ) + + +async def test_chat_json_retries_once_on_invalid_json(fake_llm: FakeOpenAI) -> None: + fake_llm.chat_responses.extend( + [ + {"content": "definitely not json"}, + {"content": '{"covered": [], "done": false}'}, + ] + ) + result = await chat_json(PING, Verdict) + assert result.done is False + assert len(fake_llm.requests) == 2 + retry_messages = fake_llm.requests[-1]["messages"] + assert retry_messages[-1]["role"] == "user" + assert "JSON" in retry_messages[-1]["content"] + assert retry_messages[-2] == {"role": "assistant", "content": "definitely not json"} + assert ( + _counter("llm_calls_total", role="utility", kind="chat_json", status="invalid") + == 1 + ) + + +async def test_chat_json_gives_up_after_retry_without_leaking( + fake_llm: FakeOpenAI, +) -> None: + fake_llm.chat_responses.extend( + [{"content": "SECRET-A 123"}, {"content": "SECRET-B 456"}] + ) + with pytest.raises(LLMError) as excinfo: + await chat_json(PING, Verdict) + error = excinfo.value + assert "Verdict" in str(error) + # Structured debugging metadata is present ... + assert error.role == "utility" + assert error.kind == "chat_json" + assert error.status == "invalid" + assert error.cause_type == "ValidationError" + assert error.attempt == 2 + assert error.status_code is None + assert isinstance(error.duration_ms, int) + # ... and neither message nor ANY metadata field carries content. + everything = str(error) + repr(vars(error)) + assert "SECRET-A" not in everything + assert "SECRET-B" not in everything + + +async def test_chat_json_http_error(fake_llm: FakeOpenAI) -> None: + # Twice: the client retries once on connection/server errors. + fake_llm.chat_responses.extend([{"status": 503}, {"status": 503}]) + with pytest.raises(LLMError) as excinfo: + await chat_json(PING, Verdict) + error = excinfo.value + assert error.status == "error" + assert error.status_code == 503 + assert error.attempt == 1 + assert error.cause_type + assert "induced failure" not in str(error) + repr(vars(error)) + assert ( + _counter("llm_calls_total", role="utility", kind="chat_json", status="error") + == 1 + ) + + +async def test_embed_preserves_order(fake_llm: FakeOpenAI) -> None: + vectors = await embed(["a", "b", "c"]) + assert len(vectors) == 3 + assert vectors[0][0] == 0.0 + assert vectors[2][0] == 2.0 + assert len(vectors[0]) == fake_llm.embedding_dim + assert _counter("llm_calls_total", role="embedding", kind="embed", status="ok") == 1 + + +@pytest.mark.parametrize( + ("cause_type", "status_code", "expected"), + [ + ("APIConnectionError", None, "llm_unreachable"), + ("ConnectError", None, "llm_unreachable"), + # A timeout is the busy case, not the down case: the endpoint took the + # request and never came back. + ("APITimeoutError", None, "llm_busy"), + ("RateLimitError", 429, "llm_busy"), + ("APIStatusError", 503, "llm_busy"), + ("AuthenticationError", 401, "llm_misconfigured"), + ("NotFoundError", 404, "llm_misconfigured"), + ("BadRequestError", 400, "llm_failed"), + ("ValueError", None, "llm_failed"), + ], +) +def test_error_code_separates_down_from_busy_from_misconfigured( + cause_type: str, status_code: int | None, expected: str +) -> None: + """One classification for every surface: retry-in-a-moment, start the + endpoint, and fix the config must not read the same to the user.""" + error = LLMError( + "boom", + role="chat", + kind="chat_stream", + status="error", + cause_type=cause_type, + status_code=status_code, + ) + assert error.code == expected diff --git a/backend/tests/test_llm_gate.py b/backend/tests/test_llm_gate.py new file mode 100644 index 0000000..e2488b1 --- /dev/null +++ b/backend/tests/test_llm_gate.py @@ -0,0 +1,196 @@ +"""Several people asking at once, against an endpoint with a few slots. + +The gate is the only place that bounds how much work reaches an endpoint, so +these tests are about the three answers it can give: go, wait, or busy. +""" + +import asyncio +from collections.abc import Iterator + +import pytest + +from app.config import get_settings +from app.llm import gate +from app.llm.errors import LLMError + +ENDPOINT = "http://endpoint.test/v1" + + +@pytest.fixture(autouse=True) +def _fresh_gate() -> Iterator[None]: + gate.reset() + yield + gate.reset() + + +def _limits( + monkeypatch: pytest.MonkeyPatch, + *, + parallel: int, + wait: float = 20.0, + queued: int = 24, +) -> None: + settings = get_settings() + monkeypatch.setattr(settings, "llm_max_parallel", parallel) + monkeypatch.setattr(settings, "llm_queue_wait_seconds", wait) + monkeypatch.setattr(settings, "llm_max_queued", queued) + gate.reset() + + +async def test_only_as_many_calls_reach_the_endpoint_as_it_has_slots( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _limits(monkeypatch, parallel=2) + concurrent = 0 + peak = 0 + release = asyncio.Event() + + async def call() -> None: + nonlocal concurrent, peak + async with gate.slot(ENDPOINT, "chat"): + concurrent += 1 + peak = max(peak, concurrent) + await release.wait() + concurrent -= 1 + + tasks = [asyncio.create_task(call()) for _ in range(6)] + await asyncio.sleep(0) # let everyone reach the gate + assert peak == 2, "more calls were in flight than the endpoint has slots" + release.set() + await asyncio.gather(*tasks) + assert peak == 2 + + +async def test_a_waiting_call_gets_the_slot_the_previous_one_frees( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _limits(monkeypatch, parallel=1) + order: list[str] = [] + first_in = asyncio.Event() + let_go = asyncio.Event() + + async def first() -> None: + async with gate.slot(ENDPOINT, "chat"): + order.append("first in") + first_in.set() + await let_go.wait() + order.append("first out") + + async def second() -> None: + await first_in.wait() + async with gate.slot(ENDPOINT, "chat"): + order.append("second in") + + task_one = asyncio.create_task(first()) + task_two = asyncio.create_task(second()) + await first_in.wait() + await asyncio.sleep(0) + assert order == ["first in"], "the second call did not wait" + let_go.set() + await asyncio.gather(task_one, task_two) + assert order == ["first in", "first out", "second in"] + + +async def test_waiting_longer_than_allowed_is_reported_as_busy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A caller that cannot be served soon is told so, rather than being held + until the HTTP timeout makes it look like a broken endpoint.""" + _limits(monkeypatch, parallel=1, wait=0.05) + let_go = asyncio.Event() + + async def holder() -> None: + async with gate.slot(ENDPOINT, "chat"): + await let_go.wait() + + held = asyncio.create_task(holder()) + await asyncio.sleep(0) + + with pytest.raises(LLMError) as raised: + async with gate.slot(ENDPOINT, "chat"): + pass + assert raised.value.code == "llm_busy" + + let_go.set() + await held + + +async def test_beyond_the_queue_limit_the_answer_is_immediate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Once far more work has arrived than the endpoint can absorb, the useful + reply is "busy" now — not "busy" in twenty seconds.""" + _limits(monkeypatch, parallel=1, wait=5.0, queued=2) + let_go = asyncio.Event() + + async def occupy() -> None: + async with gate.slot(ENDPOINT, "chat"): + await let_go.wait() + + async def queue_up() -> None: + async with gate.slot(ENDPOINT, "chat"): + pass + + holder = asyncio.create_task(occupy()) + await asyncio.sleep(0) + waiters = [asyncio.create_task(queue_up()) for _ in range(2)] + await asyncio.sleep(0) + + started = asyncio.get_running_loop().time() + with pytest.raises(LLMError) as raised: + async with gate.slot(ENDPOINT, "chat"): + pass + assert raised.value.code == "llm_busy" + assert asyncio.get_running_loop().time() - started < 1.0 + + let_go.set() + await asyncio.gather(holder, *waiters) + + +async def test_endpoints_do_not_share_a_limit(monkeypatch: pytest.MonkeyPatch) -> None: + """The chat and embedding roles usually run on different servers; a busy + chat endpoint must not stop retrieval from embedding a query.""" + _limits(monkeypatch, parallel=1) + let_go = asyncio.Event() + + async def occupy() -> None: + async with gate.slot(ENDPOINT, "chat"): + await let_go.wait() + + holder = asyncio.create_task(occupy()) + await asyncio.sleep(0) + async with gate.slot("http://other.test/v1", "embedding"): + pass # reached its own slot while the first endpoint is full + let_go.set() + await holder + + +async def test_a_failed_call_gives_its_slot_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _limits(monkeypatch, parallel=1, wait=0.05) + with pytest.raises(RuntimeError): + async with gate.slot(ENDPOINT, "chat"): + raise RuntimeError("endpoint blew up") + # The slot is free again, so the next caller is served rather than queued. + async with gate.slot(ENDPOINT, "chat"): + pass + + +async def test_the_mode_can_tell_whether_a_turn_will_have_to_wait( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _limits(monkeypatch, parallel=1) + assert gate.endpoint_busy(ENDPOINT) is False + let_go = asyncio.Event() + + async def occupy() -> None: + async with gate.slot(ENDPOINT, "chat"): + await let_go.wait() + + holder = asyncio.create_task(occupy()) + await asyncio.sleep(0) + assert gate.endpoint_busy(ENDPOINT) is True + let_go.set() + await holder + assert gate.endpoint_busy(ENDPOINT) is False diff --git a/backend/tests/test_llm_settings.py b/backend/tests/test_llm_settings.py new file mode 100644 index 0000000..1496ab7 --- /dev/null +++ b/backend/tests/test_llm_settings.py @@ -0,0 +1,286 @@ +"""LLM endpoint configuration: bootstrapped from `.env` once, then owned by +the database, applied without a restart, and the api_key never leaves the +server (rule 12).""" + +import logging + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.llm import client as llm_client +from app.llm import overrides +from app.llm.overrides import bootstrap_llm_settings, env_defaults, load_config +from app.log import JsonFormatter +from app.models import LLMSetting, User + +pytestmark = pytest.mark.usefixtures("fake_llm") + +SECRET = "sk-super-secret-key-9876" + + +@pytest.fixture(autouse=True) +def _clean_config(): + overrides.clear() + llm_client.rebuild_clients() + yield + overrides.clear() + llm_client.rebuild_clients() + + +async def _login_admin(client: AsyncClient) -> None: + response = await client.post( + "/api/auth/login", json={"email": "florian@test.dev", "password": "secret123"} + ) + assert response.status_code == 200 + + +async def test_bootstrap_copies_the_environment_once(db: AsyncSession) -> None: + written = await bootstrap_llm_settings(db) + assert written > 0 + + rows = {row.role: row for row in (await db.execute(select(LLMSetting))).scalars()} + for role in ("chat", "utility", "embedding"): + defaults = env_defaults(role) + assert rows[role].base_url == defaults.base_url + assert rows[role].base_url_from_env is True + assert rows[role].model_from_env is True + + # A second start changes nothing — the rows are the admin's now. + assert await bootstrap_llm_settings(db) == 0 + + +async def test_bootstrap_fills_a_field_an_upgrade_left_deferring_to_env( + db: AsyncSession, +) -> None: + """The upgrade path: before this milestone a NULL column meant "inherit + from .env", so the migration flags those fields `*_from_env` and leaves + them empty. Startup has to fill them in — otherwise a row whose + overrides had been cleared comes out as an empty configuration and + takes the endpoint down.""" + db.add( + LLMSetting( + role="chat", + base_url=None, + model="hand-picked", + api_key=None, + base_url_from_env=True, + model_from_env=False, + api_key_from_env=True, + ) + ) + await db.commit() + + await bootstrap_llm_settings(db) + + row = ( + await db.execute(select(LLMSetting).where(LLMSetting.role == "chat")) + ).scalar_one() + assert row.base_url == env_defaults("chat").base_url + # The admin's own value is never overwritten. + assert row.model == "hand-picked" + assert row.model_from_env is False + + +async def test_startup_logging_survives_the_reserved_name_trap( + db: AsyncSession, caplog: pytest.LogCaptureFixture +) -> None: + """`extra={"created": ...}` raises KeyError inside logging, because + LogRecord already owns that attribute — and the process dies on startup. + + This slipped through once: the log line only builds when the logger is + enabled for INFO, and the suite otherwise runs above that level, so + every existing test passed while the app refused to boot. + """ + with caplog.at_level(logging.INFO, logger="pablan.llm"): + await bootstrap_llm_settings(db) + await load_config(db) + + rendered = "\n".join(JsonFormatter().format(record) for record in caplog.records) + assert "llm_bootstrap" in rendered + + +async def test_the_database_wins_over_a_later_env_change( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """The point of bootstrap-then-DB: once a value is stored, the process + uses it even though `.env` still says something else.""" + await _login_admin(client) + env_url = env_defaults("chat").base_url + + saved = await client.put( + "/api/admin/llm/settings/chat", json={"base_url": "http://stored.invalid/v1"} + ) + assert saved.status_code == 200 + assert saved.json()["base_url"] == "http://stored.invalid/v1" + assert saved.json()["base_url_from_env"] is False + assert llm_client.role_config("chat")[0] == "http://stored.invalid/v1" + + # Re-running bootstrap (i.e. a restart) must not undo it. + await bootstrap_llm_settings(db) + await load_config(db) + assert llm_client.role_config("chat")[0] == "http://stored.invalid/v1" + assert env_url != "http://stored.invalid/v1" + + +async def test_resetting_a_field_restores_the_env_value( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + await _login_admin(client) + env_model = env_defaults("chat").model + + changed = await client.put( + "/api/admin/llm/settings/chat", json={"model": "gemma-9000"} + ) + assert changed.json()["model"] == "gemma-9000" + assert changed.json()["model_from_env"] is False + + reset = await client.put("/api/admin/llm/settings/chat", json={"reset_model": True}) + assert reset.json()["model_from_env"] is True + assert reset.json()["model"] == (env_model or "") + assert llm_client.role_config("chat")[2] == env_model + + +async def test_provenance_is_tracked_per_field( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """Changing the model must not relabel the URL as hand-edited.""" + await _login_admin(client) + body = ( + await client.put("/api/admin/llm/settings/chat", json={"model": "gemma-9000"}) + ).json() + assert body["model_from_env"] is False + assert body["base_url_from_env"] is True + assert body["api_key_from_env"] is True + + +async def test_saving_rebuilds_the_client_so_no_restart_is_needed( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + await _login_admin(client) + before = llm_client._client_for("chat") + + await client.put( + "/api/admin/llm/settings/chat", + json={"base_url": "http://elsewhere.invalid/v1"}, + ) + + after = llm_client._client_for("chat") + assert after is not before, "cached client kept the old base_url" + assert str(after.base_url).startswith("http://elsewhere.invalid") + + +async def test_the_api_key_is_write_only( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + await _login_admin(client) + saved = await client.put("/api/admin/llm/settings/chat", json={"api_key": SECRET}) + assert saved.status_code == 200 + + # It is stored... + row = ( + await db.execute(select(LLMSetting).where(LLMSetting.role == "chat")) + ).scalar_one() + assert row.api_key == SECRET + # ...and used... + assert llm_client.role_config("chat")[1] == SECRET + + # ...but no response body ever contains it. + assert SECRET not in saved.text + assert saved.json()["api_key_set"] is True + assert saved.json()["api_key_from_env"] is False + + listing = await client.get("/api/admin/llm/settings") + assert SECRET not in listing.text + assert "api_key" not in listing.json()[0] + + +async def test_the_api_key_never_reaches_a_log_line( + client: AsyncClient, + db: AsyncSession, + seeded_admin: User, + caplog: pytest.LogCaptureFixture, +) -> None: + await _login_admin(client) + with caplog.at_level(logging.DEBUG): + await client.put("/api/admin/llm/settings/chat", json={"api_key": SECRET}) + await client.post( + "/api/admin/llm/test", + json={"role": "chat", "api_key": SECRET, "base_url": "http://x.invalid/v1"}, + ) + await client.post( + "/api/admin/llm/models/chat", + json={"api_key": SECRET, "base_url": "http://x.invalid/v1"}, + ) + + formatter = JsonFormatter() + rendered = "\n".join(formatter.format(record) for record in caplog.records) + assert SECRET not in rendered + + +async def test_testing_a_candidate_does_not_persist_it( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """The test button must not change the running configuration.""" + await _login_admin(client) + before = llm_client.role_config("chat") + + response = await client.post( + "/api/admin/llm/test", + json={"role": "chat", "base_url": "http://candidate.invalid/v1"}, + ) + assert response.status_code == 200 + assert [role["role"] for role in response.json()["roles"]] == ["chat"] + + assert llm_client.role_config("chat") == before + assert (await db.execute(select(LLMSetting))).scalars().all() == [] + + +async def test_available_models_come_from_the_endpoint( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + await _login_admin(client) + response = await client.post("/api/admin/llm/models/chat", json={}) + assert response.status_code == 200 + body = response.json() + assert body["supported"] is True + assert body["models"] == ["bge-m3", "gemma-3-27b"] + + +async def test_an_endpoint_without_the_route_degrades_quietly( + client: AsyncClient, db: AsyncSession, seeded_admin: User, fake_llm +) -> None: + """Plenty of OpenAI-compatible servers do not implement /v1/models. That + is a missing convenience, not an error worth showing.""" + fake_llm.served_models = None + await _login_admin(client) + + body = (await client.post("/api/admin/llm/models/chat", json={})).json() + assert body["supported"] is False + assert body["models"] == [] + assert body["error"] is None + + +async def test_listing_models_does_not_persist_the_candidate( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + await _login_admin(client) + await client.post( + "/api/admin/llm/models/chat", + json={"base_url": "http://candidate.invalid/v1", "api_key": SECRET}, + ) + assert (await db.execute(select(LLMSetting))).scalars().all() == [] + + +async def test_settings_require_an_admin( + client: AsyncClient, seeded_user: User +) -> None: + await client.post( + "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} + ) + assert (await client.get("/api/admin/llm/settings")).status_code == 403 + assert ( + await client.put("/api/admin/llm/settings/chat", json={"model": "x"}) + ).status_code == 403 + assert (await client.post("/api/admin/llm/models/chat", json={})).status_code == 403 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..e0b9fac --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,74 @@ +import pytest +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ( + EMBEDDING_DIM, + Chunk, + Document, + DocumentStatus, +) + + +async def _make_document(db: AsyncSession, content: str) -> Document: + document = Document( + title="Server maintenance", + status=DocumentStatus.published, + content_md=content, + ) + db.add(document) + await db.flush() + return document + + +async def test_chunk_tsv_is_generated_with_german_config(db: AsyncSession) -> None: + document = await _make_document(db, "# Maintenance") + chunk = Chunk( + document_id=document.id, + chunk_index=0, + content="The servers are maintained and checked regularly.", + embedding=[0.1] * EMBEDDING_DIM, + ) + db.add(chunk) + await db.commit() + + # Same word in content and query stems identically under any config; + # real German retrieval assertions come with the M4 fixture corpus. + matches = ( + await db.execute( + select(Chunk.id).where( + text("tsv @@ websearch_to_tsquery('german', 'maintained')") + ) + ) + ).all() + assert len(matches) == 1 + + +async def test_chunk_index_unique_per_document(db: AsyncSession) -> None: + document = await _make_document(db, "# Duplicate") + for _ in range(2): + db.add( + Chunk( + document_id=document.id, + chunk_index=0, + content="same index", + embedding=[0.0] * EMBEDDING_DIM, + ) + ) + with pytest.raises(IntegrityError): + await db.commit() + + +async def test_embedding_dimension_enforced(db: AsyncSession) -> None: + document = await _make_document(db, "# Dimension") + db.add( + Chunk( + document_id=document.id, + chunk_index=0, + content="wrong dimension", + embedding=[0.0] * (EMBEDDING_DIM - 1), + ) + ) + with pytest.raises(Exception, match="expected 1024 dimensions"): + await db.commit() diff --git a/backend/tests/test_observability.py b/backend/tests/test_observability.py new file mode 100644 index 0000000..a410462 --- /dev/null +++ b/backend/tests/test_observability.py @@ -0,0 +1,130 @@ +import json +import logging + +import pytest +from pydantic import BaseModel + +from app.config import get_settings +from app.llm.client import chat_json +from app.log import ( + JsonFormatter, + apply_content_log_guard, + correlation_id, + safe_error, +) +from app.metrics import MetricsRegistry +from tests.fake_openai import FakeOpenAI + + +def test_metrics_registry_roundtrip() -> None: + registry = MetricsRegistry() + registry.inc("calls", {"role": "chat"}) + registry.inc("calls", {"role": "chat"}, value=2) + registry.set_gauge("depth", 4.0) + registry.observe("seconds", 1.0, {"kind": "x"}) + registry.observe("seconds", 3.0, {"kind": "x"}) + + snapshot = registry.snapshot() + assert snapshot["counters"]["calls"] == [{"labels": {"role": "chat"}, "value": 3.0}] + assert snapshot["gauges"]["depth"] == [{"labels": {}, "value": 4.0}] + hist = snapshot["histograms"]["seconds"][0] + assert hist == { + "labels": {"kind": "x"}, + "count": 2, + "sum": 4.0, + "min": 1.0, + "max": 3.0, + "avg": 2.0, + } + + registry.reset() + assert registry.snapshot() == {"counters": {}, "gauges": {}, "histograms": {}} + + +def _format(record: logging.LogRecord) -> dict: + return json.loads(JsonFormatter().format(record)) + + +def test_json_formatter_includes_extras_and_correlation_id() -> None: + token = correlation_id.set("req-123") + try: + record = logging.LogRecord( + name="pablan.test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="llm call", + args=(), + exc_info=None, + ) + record.role = "chat" + record.duration_ms = 42 + payload = _format(record) + finally: + correlation_id.reset(token) + + assert payload["message"] == "llm call" + assert payload["level"] == "INFO" + assert payload["correlation_id"] == "req-123" + assert payload["role"] == "chat" + assert payload["duration_ms"] == 42 + assert "ts" in payload + + +def test_safe_error_strips_sql_parameters() -> None: + error = ValueError( + "insert failed [SQL: INSERT INTO messages ...] [parameters: ('secret content',)]" + ) + sanitized = safe_error(error) + assert sanitized == "ValueError: insert failed" + assert "secret content" not in sanitized + + +def test_safe_error_truncates() -> None: + sanitized = safe_error(RuntimeError("x" * 1000), limit=50) + assert len(sanitized) <= len("RuntimeError: ") + 50 + + +class Verdict(BaseModel): + done: bool + + +async def test_llm_logs_contain_no_content( + fake_llm: FakeOpenAI, caplog: pytest.LogCaptureFixture +) -> None: + """CLAUDE.md rule 12: with debug logging off (the default), neither the + prompt nor the model response may appear in any rendered log line.""" + secret_prompt = "GEHEIM-PROMPT-77" + secret_response = '{"done": true, "leak": "GEHEIM-ANTWORT-88"}' + fake_llm.chat_responses.append({"content": secret_response}) + + # As in production: third-party SDK loggers are capped so they cannot + # dump request bodies even at global DEBUG level. + apply_content_log_guard() + with caplog.at_level(logging.DEBUG): + await chat_json([{"role": "user", "content": secret_prompt}], Verdict) + + formatter = JsonFormatter() + rendered = "\n".join(formatter.format(record) for record in caplog.records) + assert "llm call" in rendered + assert "GEHEIM-PROMPT-77" not in rendered + assert "GEHEIM-ANTWORT-88" not in rendered + + +async def test_debug_flag_enables_content_logging( + fake_llm: FakeOpenAI, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The documented never-in-production escape hatch actually works.""" + monkeypatch.setenv("PABLAN_DEBUG_LOG_PROMPTS", "true") + get_settings.cache_clear() + try: + fake_llm.chat_responses.append({"content": '{"done": true}'}) + with caplog.at_level(logging.DEBUG): + await chat_json([{"role": "user", "content": "SICHTBAR-99"}], Verdict) + formatter = JsonFormatter() + rendered = "\n".join(formatter.format(record) for record in caplog.records) + assert "SICHTBAR-99" in rendered + finally: + get_settings.cache_clear() diff --git a/backend/tests/test_people.py b/backend/tests/test_people.py new file mode 100644 index 0000000..2975620 --- /dev/null +++ b/backend/tests/test_people.py @@ -0,0 +1,44 @@ +"""The colleague directory: member-visible, permission-safe, no credentials.""" + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import User + + +async def _login(client: AsyncClient, email: str = "pablo@test.dev") -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def test_directory_lists_colleagues_without_leaking_credentials( + client: AsyncClient, seeded_user: User, seeded_admin: User +) -> None: + await _login(client) + people = (await client.get("/api/people")).json() + names = {person["name"] for person in people} + assert {"Pablo Test", "Florian Test"} <= names + # No email or password ever leaves the directory. + assert all("email" not in p and "password_hash" not in p for p in people) + + pablo = next(p for p in people if p["name"] == "Pablo Test") + assert pablo["department"] == "Engineering" + assert pablo["role"] == "member" + + +async def test_directory_requires_a_session(client: AsyncClient) -> None: + assert (await client.get("/api/people")).status_code == 401 + + +async def test_person_detail_and_unknown_is_404( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + await _login(client) + ok = await client.get(f"/api/people/{seeded_user.id}") + assert ok.status_code == 200 + assert ok.json()["name"] == "Pablo Test" + + missing = await client.get("/api/people/00000000-0000-0000-0000-000000000000") + assert missing.status_code == 404 diff --git a/backend/tests/test_prompt_settings.py b/backend/tests/test_prompt_settings.py new file mode 100644 index 0000000..a2b0b9f --- /dev/null +++ b/backend/tests/test_prompt_settings.py @@ -0,0 +1,52 @@ +"""Admin-editable system prompts: override without a restart, reset to default.""" + +from httpx import AsyncClient + +from app.prompts.defaults import DEFAULTS +from app.prompts.overrides import get_prompt + + +async def _login(client: AsyncClient, email: str) -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def test_prompts_require_admin(client: AsyncClient, seeded_user) -> None: + await _login(client, "pablo@test.dev") + assert (await client.get("/api/admin/prompts")).status_code == 403 + + +async def test_prompt_override_applies_and_resets( + client: AsyncClient, seeded_admin +) -> None: + await _login(client, "florian@test.dev") + + listed = (await client.get("/api/admin/prompts")).json() + keys = {prompt["key"] for prompt in listed} + assert {"query_system", "refine_rules", "title"} <= keys + assert all(prompt["is_default"] for prompt in listed) + + # Overriding applies immediately (get_prompt reads the refreshed cache). + put = await client.put( + "/api/admin/prompts/query_system", + json={"content": "You are a test assistant."}, + ) + assert put.status_code == 200 + assert put.json()["is_default"] is False + assert get_prompt("query_system") == "You are a test assistant." + + # An empty prompt is rejected; an unknown key is a 404. + assert ( + await client.put("/api/admin/prompts/query_system", json={"content": " "}) + ).status_code == 422 + assert ( + await client.put("/api/admin/prompts/nope", json={"content": "x"}) + ).status_code == 404 + + # Resetting restores the shipped default. + reset = await client.put("/api/admin/prompts/query_system", json={"reset": True}) + assert reset.status_code == 200 + assert reset.json()["is_default"] is True + assert get_prompt("query_system") == DEFAULTS["query_system"] diff --git a/backend/tests/test_query_mode.py b/backend/tests/test_query_mode.py new file mode 100644 index 0000000..65211a0 --- /dev/null +++ b/backend/tests/test_query_mode.py @@ -0,0 +1,44 @@ +"""Query mode helpers (the SSE contract itself lives in +test_conversations_api.py).""" + +from types import SimpleNamespace + +from app.models import MessageRole +from app.modes.query import _topic_transcript + + +def _msg(role: MessageRole, content: str) -> SimpleNamespace: + return SimpleNamespace(role=role, content=content) + + +def test_topic_transcript_needs_prior_context() -> None: + # A first message alone has no earlier context: the topic fallback is a + # no-op, so a first-message miss stays a genuine no-answer. + conversation = SimpleNamespace(messages=[]) + assert _topic_transcript(conversation, "Wie beantrage ich Urlaub?") == "" + + +def test_topic_transcript_includes_history_and_current() -> None: + conversation = SimpleNamespace( + messages=[ + _msg(MessageRole.user, "Wie beantrage ich Urlaub?"), + _msg(MessageRole.assistant, "Über das Personalportal."), + ] + ) + transcript = _topic_transcript(conversation, "Und was war meine erste Frage?") + assert "Urlaub" in transcript + assert "erste Frage" in transcript + assert transcript.count("User:") == 2 + + +def test_topic_transcript_does_not_duplicate_the_current_message() -> None: + # The current message may already be persisted as the last stored turn. + conversation = SimpleNamespace( + messages=[ + _msg(MessageRole.user, "Erste Frage."), + _msg(MessageRole.assistant, "Antwort."), + _msg(MessageRole.user, "Zweite Frage."), + ] + ) + transcript = _topic_transcript(conversation, "Zweite Frage.") + assert transcript.count("Zweite Frage.") == 1 diff --git a/backend/tests/test_queue.py b/backend/tests/test_queue.py new file mode 100644 index 0000000..60d5b5a --- /dev/null +++ b/backend/tests/test_queue.py @@ -0,0 +1,266 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from app.ingestion.handlers import RETENTION_CLEANUP, ensure_retention_scheduled +from app.ingestion.queue import ( + MAX_ATTEMPTS, + enqueue, + job_handler, + process_one, +) +from app.models import ( + AuthSession, + Conversation, + ConversationMode, + Department, + Job, + JobStatus, + User, +) + + +@pytest.fixture +def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(db_engine, expire_on_commit=False) + + +async def _get_job(db: AsyncSession, job_id: uuid.UUID) -> Job: + db.expire_all() + job = await db.get(Job, job_id) + assert job is not None + return job + + +async def test_successful_job_is_marked_done( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + seen: list[dict] = [] + + @job_handler("t_ok") + async def handle(handler_db: AsyncSession, job: Job) -> None: + seen.append(job.payload) + + job = await enqueue(db, "t_ok", {"n": 1}) + await db.commit() + + assert await process_one(session_factory) is True + assert seen == [{"n": 1}] + refreshed = await _get_job(db, job.id) + assert refreshed.status == JobStatus.done + assert refreshed.attempts == 1 + + # Nothing left to do. + assert await process_one(session_factory) is False + + +async def test_failing_job_retries_with_backoff( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + @job_handler("t_fail") + async def handle(handler_db: AsyncSession, job: Job) -> None: + raise ValueError("boom") + + job = await enqueue(db, "t_fail") + await db.commit() + + assert await process_one(session_factory) is True + refreshed = await _get_job(db, job.id) + assert refreshed.status == JobStatus.pending + assert refreshed.attempts == 1 + assert refreshed.last_error is not None + assert "ValueError: boom" in refreshed.last_error + assert refreshed.run_after > datetime.now(UTC) + timedelta(seconds=10) + + # Backed off into the future: not claimable right now. + assert await process_one(session_factory) is False + + +async def test_job_fails_permanently_after_max_attempts( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + @job_handler("t_exhaust") + async def handle(handler_db: AsyncSession, job: Job) -> None: + raise RuntimeError("always broken") + + job = await enqueue(db, "t_exhaust") + await db.commit() + + for _ in range(MAX_ATTEMPTS): + refreshed = await _get_job(db, job.id) + refreshed.run_after = datetime.now(UTC) - timedelta(seconds=1) + await db.commit() + assert await process_one(session_factory) is True + + refreshed = await _get_job(db, job.id) + assert refreshed.status == JobStatus.failed + assert refreshed.attempts == MAX_ATTEMPTS + + +async def test_unknown_job_type_records_error( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + job = await enqueue(db, "t_nobody_home") + await db.commit() + + assert await process_one(session_factory) is True + refreshed = await _get_job(db, job.id) + assert refreshed.status == JobStatus.pending + assert refreshed.last_error is not None + assert "LookupError" in refreshed.last_error + + +async def test_locked_job_is_skipped_and_claimable_after_rollback( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + """SKIP LOCKED + crash-safety: a claim held by a dying worker (open tx) + is invisible to others and becomes claimable again on rollback.""" + + @job_handler("t_locked") + async def handle(handler_db: AsyncSession, job: Job) -> None: + pass + + job = await enqueue(db, "t_locked") + await db.commit() + + async with session_factory() as other: + claimed = ( + await other.execute( + select(Job).where(Job.id == job.id).with_for_update(skip_locked=True) + ) + ).scalar_one() + assert claimed.id == job.id + # Row is locked by "another worker": nothing to process. + assert await process_one(session_factory) is False + await other.rollback() # the worker "crashes" + + # After the rollback the job is claimable again. + assert await process_one(session_factory) is True + refreshed = await _get_job(db, job.id) + assert refreshed.status == JobStatus.done + + +async def test_handler_writes_roll_back_atomically_on_failure( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + @job_handler("t_atomic") + async def handle(handler_db: AsyncSession, job: Job) -> None: + handler_db.add(Department(name="Ghost Department")) + await handler_db.flush() + raise RuntimeError("after write") + + await enqueue(db, "t_atomic") + await db.commit() + assert await process_one(session_factory) is True + + ghost = ( + await db.execute( + select(Department).where(Department.name == "Ghost Department") + ) + ).scalar_one_or_none() + assert ghost is None + + +async def test_retention_cleanup( + db: AsyncSession, + session_factory: async_sessionmaker[AsyncSession], + seeded_user: User, +) -> None: + now = datetime.now(UTC) + old = now - timedelta(days=120) + + old_query = Conversation( + mode=ConversationMode.query, user_id=seeded_user.id, updated_at=old + ) + fresh_query = Conversation(mode=ConversationMode.query, user_id=seeded_user.id) + # A non-query mode (EE insight) must survive retention: only ephemeral + # query threads are cleaned up. + old_insight = Conversation( + mode=ConversationMode.insight, user_id=seeded_user.id, updated_at=old + ) + expired_session = AuthSession( + user_id=seeded_user.id, expires_at=now - timedelta(days=1) + ) + valid_session = AuthSession( + user_id=seeded_user.id, expires_at=now + timedelta(days=1) + ) + db.add_all([old_query, fresh_query, old_insight, expired_session, valid_session]) + await enqueue(db, RETENTION_CLEANUP) + await db.commit() + + assert await process_one(session_factory) is True + + db.expire_all() + remaining_conversations = { + c.id for c in (await db.execute(select(Conversation))).scalars() + } + assert remaining_conversations == {fresh_query.id, old_insight.id} + remaining_sessions = { + s.id for s in (await db.execute(select(AuthSession))).scalars() + } + assert remaining_sessions == {valid_session.id} + + # Rescheduled itself for tomorrow. + next_job = ( + await db.execute( + select(Job).where( + Job.type == RETENTION_CLEANUP, Job.status == JobStatus.pending + ) + ) + ).scalar_one() + assert next_job.run_after > now + timedelta(hours=23) + + +async def test_ensure_retention_scheduled_is_idempotent( + db: AsyncSession, session_factory: async_sessionmaker[AsyncSession] +) -> None: + async with session_factory() as first: + await ensure_retention_scheduled(first) + async with session_factory() as second: + await ensure_retention_scheduled(second) + + jobs = ( + (await db.execute(select(Job).where(Job.type == RETENTION_CLEANUP))) + .scalars() + .all() + ) + assert len(jobs) == 1 + + +async def test_retention_respects_configured_days( + db: AsyncSession, + session_factory: async_sessionmaker[AsyncSession], + seeded_user: User, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """End-to-end with a short retention window: 1 day keeps yesterday's + conversation out of scope for deletion at 20h but purges a 30h one.""" + from app.config import get_settings + + monkeypatch.setenv("PABLAN_QUERY_RETENTION_DAYS", "1") + get_settings.cache_clear() + try: + now = datetime.now(UTC) + too_old = Conversation( + mode=ConversationMode.query, + user_id=seeded_user.id, + updated_at=now - timedelta(hours=30), + ) + still_fresh = Conversation( + mode=ConversationMode.query, + user_id=seeded_user.id, + updated_at=now - timedelta(hours=20), + ) + db.add_all([too_old, still_fresh]) + await enqueue(db, RETENTION_CLEANUP) + await db.commit() + + assert await process_one(session_factory) is True + db.expire_all() + remaining = {c.id for c in (await db.execute(select(Conversation))).scalars()} + assert remaining == {still_fresh.id} + finally: + get_settings.cache_clear() diff --git a/backend/tests/test_refine_grounding.py b/backend/tests/test_refine_grounding.py new file mode 100644 index 0000000..76c365e --- /dev/null +++ b/backend/tests/test_refine_grounding.py @@ -0,0 +1,227 @@ +"""Retrieval-aware section refinement: a refined section may draw on what the +company has already documented, and only on documents the author is allowed to +read. + +Uses deterministic fake embeddings (fake_embed): identical text lands at +distance 0, unrelated text near-orthogonal. That is enough to prove the wiring, +the permission boundary and the gates; whether grounding helps the writing is +measured against the real model in tests/evals. +""" + +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.authoring.grounding import for_section as grounding_for +from app.auth.passwords import hash_password +from app.authoring.prompts import render_refine_prompt +from app.models import ( + Chunk, + Department, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.indexing import embedding_text, reindex_document +from tests.fake_openai import FakeOpenAI + +pytestmark = pytest.mark.usefixtures("fake_embed") + +# A sentence long enough to clear the grounding minimum, reused as both the +# stored document and the query so the fake embedding matches exactly. +COFFEE = "Die Kaffeemaschine wird jeden Freitag gründlich entkalkt und gereinigt." + + +async def _user(db: AsyncSession, email: str, department_id: uuid.UUID) -> User: + user = User( + email=email, + name=email.split("@")[0], + role=UserRole.member, + password_hash=hash_password("secret123"), + department_id=department_id, + ) + db.add(user) + await db.flush() + return user + + +async def _published( + db: AsyncSession, + *, + title: str, + content: str, + author: User, + visibility: DocumentVisibility = DocumentVisibility.public, +) -> Document: + document = Document( + title=title, + status=DocumentStatus.published, + visibility=visibility, + content_md=content, + author_id=author.id, + department_id=author.department_id, + ) + db.add(document) + await db.flush() + await reindex_document(db, document) + return document + + +async def _chunk_text(db: AsyncSession, document: Document) -> str: + """A chunk exactly as it was embedded — heading path and all, so a search + for it lands at distance 0 (see indexing.embedding_text).""" + row = ( + await db.execute( + select(Chunk.content, Chunk.meta).where(Chunk.document_id == document.id) + ) + ).first() + return embedding_text(row.meta["heading_path"], row.content) + + +# --- The prompt only offers grounding as a reference, never as a fact source. + + +def test_prompt_omits_grounding_when_there_is_none() -> None: + messages = render_refine_prompt( + "## Pflege\n\nNotizen", prefix="", suffix="", persona=None, hint=None + ) + assert "Related knowledge" not in messages[-1]["content"] + + +def test_prompt_appends_grounding_after_the_section() -> None: + messages = render_refine_prompt( + "## Pflege\n\nNotizen", + prefix="", + suffix="", + persona=None, + hint=None, + knowledge=['From "Kaffeemaschine": entkalken.'], + ) + turn = messages[-1]["content"] + assert "Related knowledge" in turn + assert 'From "Kaffeemaschine"' in turn + # The section to refine still leads; grounding trails it. + assert turn.index("Refine only this section") < turn.index("Related knowledge") + + +# --- grounding.for_section: finds related knowledge, excludes self, respects the gates. + + +async def test_grounding_surfaces_a_related_document(db: AsyncSession) -> None: + engineering = Department(name="Engineering") + db.add(engineering) + await db.flush() + author = await _user(db, "pablo@test.dev", engineering.id) + document = await _published( + db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author + ) + await db.commit() + + query = await _chunk_text(db, document) + references = await grounding_for(db, query, author, document_id=uuid.uuid4()) + assert references, "an exact-text match should be grounded" + assert references[0].title == "Kaffeemaschine" + + +async def test_grounding_never_includes_the_document_being_edited( + db: AsyncSession, +) -> None: + engineering = Department(name="Engineering") + db.add(engineering) + await db.flush() + author = await _user(db, "pablo@test.dev", engineering.id) + document = await _published( + db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author + ) + await db.commit() + + query = await _chunk_text(db, document) + references = await grounding_for(db, query, author, document_id=document.id) + assert references == [] + + +async def test_grounding_skips_a_section_that_is_still_just_a_heading( + db: AsyncSession, +) -> None: + engineering = Department(name="Engineering") + db.add(engineering) + await db.flush() + author = await _user(db, "pablo@test.dev", engineering.id) + await _published( + db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author + ) + await db.commit() + + # Heading plus a few words is below the minimum, so nothing is searched. + references = await grounding_for( + db, "## Pflege\n\nnoch nichts", author, uuid.uuid4() + ) + assert references == [] + + +async def test_grounding_cannot_reach_a_document_the_author_may_not_read( + db: AsyncSession, +) -> None: + engineering = Department(name="Engineering") + sales = Department(name="Sales") + db.add_all([engineering, sales]) + await db.flush() + pablo = await _user(db, "pablo@test.dev", engineering.id) + max_user = await _user(db, "max@test.dev", sales.id) + secret = await _published( + db, + title="Preisliste", + content=f"## Preise\n\n{COFFEE}", + author=max_user, + visibility=DocumentVisibility.restricted, + ) + await db.commit() + + query = await _chunk_text(db, secret) + # Max authored it, so he is grounded on it; Pablo has no access, so he is not. + assert await grounding_for(db, query, max_user, uuid.uuid4()) + assert await grounding_for(db, query, pablo, uuid.uuid4()) == [] + + +# --- The endpoint wires grounding into the streamed prompt. + + +async def test_refine_endpoint_passes_grounding_to_the_model( + client: AsyncClient, db: AsyncSession, fake_llm: FakeOpenAI +) -> None: + engineering = Department(name="Engineering") + db.add(engineering) + await db.flush() + author = await _user(db, "pablo@test.dev", engineering.id) + reference = await _published( + db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author + ) + draft = Document( + title="Entwurf", + status=DocumentStatus.draft, + visibility=DocumentVisibility.public, + content_md="## Pflege\n\nStichpunkte", + author_id=author.id, + department_id=engineering.id, + ) + db.add(draft) + await db.commit() + + query = await _chunk_text(db, reference) + response = await client.post( + "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} + ) + assert response.status_code == 200 + + refined = await client.post( + f"/api/documents/{draft.id}/refine", + json={"content_md": query, "cursor_line": 1}, + ) + assert refined.status_code == 200 + prompt = fake_llm.requests[-1]["messages"][-1]["content"] + assert "Kaffeemaschine" in prompt diff --git a/backend/tests/test_retrieval.py b/backend/tests/test_retrieval.py new file mode 100644 index 0000000..9723fee --- /dev/null +++ b/backend/tests/test_retrieval.py @@ -0,0 +1,324 @@ +"""Permission boundaries and hybrid plumbing of rag.retrieval.search. + +Uses deterministic fake embeddings (fake_embed fixture): identical text → +distance 0; retrieval semantics with real embeddings live in tests/evals. +""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.metrics import metrics +from app.models import ( + Chunk, + Department, + DocPermission, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.indexing import embedding_text, reindex_document +from app.rag.retrieval import search, text_search + +pytestmark = pytest.mark.usefixtures("fake_embed") + + +class Setup: + pablo: User # Engineering + max: User # Sales + norbert: User # no department + pub: Document + dept_eng: Document + restricted_ben: Document + granted_eng: Document + draft: Document + + +async def _user(db: AsyncSession, email: str, department_id) -> User: + user = User( + email=email, + name=email.split("@")[0], + role=UserRole.member, + password_hash=hash_password("secret123"), + department_id=department_id, + ) + db.add(user) + await db.flush() + return user + + +async def _doc( + db: AsyncSession, + *, + title: str, + content: str, + author: User, + department_id=None, + visibility: DocumentVisibility, + status: DocumentStatus = DocumentStatus.published, +) -> Document: + document = Document( + title=title, + status=status, + visibility=visibility, + content_md=content, + author_id=author.id, + department_id=department_id, + ) + db.add(document) + await db.flush() + return document + + +@pytest.fixture +async def setup(db: AsyncSession) -> Setup: + s = Setup() + engineering = Department(name="Engineering") + sales = Department(name="Sales") + db.add_all([engineering, sales]) + await db.flush() + + s.pablo = await _user(db, "pablo@test.dev", engineering.id) + s.max = await _user(db, "max@test.dev", sales.id) + s.norbert = await _user(db, "norbert@test.dev", None) + + s.pub = await _doc( + db, + title="Kaffeemaschine", + content="## Pflege\n\nDie Kaffeemaschine wird jeden Freitag entkalkt.", + author=s.pablo, + department_id=engineering.id, + visibility=DocumentVisibility.public, + ) + s.dept_eng = await _doc( + db, + title="Bandschleifer BS-100", + content="## Wartung\n\nDer Bandschleifer braucht wöchentlich ein neues Schleifband.", + author=s.pablo, + department_id=engineering.id, + visibility=DocumentVisibility.department, + ) + s.restricted_ben = await _doc( + db, + title="Geheime Preisliste", + content="## Preise\n\nDer Rabattdeckel liegt bei zwölf Prozent.", + author=s.max, + department_id=sales.id, + visibility=DocumentVisibility.restricted, + ) + s.granted_eng = await _doc( + db, + title="Ersatzteillager", + content="## Zugang\n\nDie Zugangskarte für das Ersatzteillager liegt im Tresorfach drei.", + author=s.max, + department_id=sales.id, + visibility=DocumentVisibility.restricted, + ) + db.add(DocPermission(document_id=s.granted_eng.id, department_id=engineering.id)) + s.draft = await _doc( + db, + title="Pausenregelung", + content="## Entwurf\n\nNeue Pausenregelung ab Oktober.", + author=s.pablo, + department_id=engineering.id, + visibility=DocumentVisibility.public, + status=DocumentStatus.draft, + ) + + for document in (s.pub, s.dept_eng, s.restricted_ben, s.granted_eng, s.draft): + await reindex_document(db, document) + await db.commit() + return s + + +def _doc_ids(results) -> set[uuid.UUID]: + return {result.document_id for result in results} + + +async def test_results_carry_citation_metadata(db: AsyncSession, setup: Setup) -> None: + # Every query term must exist in the target chunk: websearch_to_tsquery + # ANDs terms, and the fake embeddings carry no semantics. + results = await search(db, "Kaffeemaschine entkalkt", user=setup.pablo) + assert results, "public document not found" + top = results[0] + assert top.document_id == setup.pub.id + assert top.title == "Kaffeemaschine" + assert top.heading_path == "Kaffeemaschine › Pflege" + assert top.content + assert top.score > 0 + + +async def test_department_visibility(db: AsyncSession, setup: Setup) -> None: + query = "Schleifband für den Bandschleifer" + assert setup.dept_eng.id in _doc_ids(await search(db, query, user=setup.pablo)) + assert setup.dept_eng.id not in _doc_ids(await search(db, query, user=setup.max)) + assert setup.dept_eng.id not in _doc_ids( + await search(db, query, user=setup.norbert) + ) + + +async def test_restricted_needs_grant_or_authorship( + db: AsyncSession, setup: Setup +) -> None: + query = "Zugangskarte Ersatzteillager Tresorfach" + # Engineering has an explicit grant. + assert setup.granted_eng.id in _doc_ids(await search(db, query, user=setup.pablo)) + # The author always sees their own document. + assert setup.granted_eng.id in _doc_ids(await search(db, query, user=setup.max)) + # No department, no grant, no authorship: nothing. + assert setup.granted_eng.id not in _doc_ids( + await search(db, query, user=setup.norbert) + ) + + +async def test_restricted_document_never_leaks(db: AsyncSession, setup: Setup) -> None: + """Acceptance: user A can NEVER retrieve chunks of user B's restricted + document — tested through both retrieval branches.""" + # Full-text branch: the exact distinctive term. + fts_results = await search(db, "Rabattdeckel", user=setup.pablo) + assert setup.restricted_ben.id not in _doc_ids(fts_results) + + # Vector branch: query IS the exact chunk content (distance 0 — it would + # be the top hit if the filter leaked). + chunk = ( + await db.execute( + select(Chunk.content, Chunk.meta).where( + Chunk.document_id == setup.restricted_ben.id + ) + ) + ).one() + chunk_content = embedding_text(chunk.meta["heading_path"], chunk.content) + vec_results = await search(db, chunk_content, user=setup.pablo) + assert setup.restricted_ben.id not in _doc_ids(vec_results) + + # The author, of course, finds it. + assert setup.restricted_ben.id in _doc_ids( + await search(db, "Rabattdeckel", user=setup.max) + ) + + +async def test_unpublished_documents_are_never_searchable( + db: AsyncSession, setup: Setup +) -> None: + """Draft chunks exist in the table but must never surface — not even for + the author, not even for a query that is the exact chunk content.""" + chunk = ( + await db.execute( + select(Chunk.content, Chunk.meta).where(Chunk.document_id == setup.draft.id) + ) + ).one() + chunk_content = embedding_text(chunk.meta["heading_path"], chunk.content) + for query in ("Pausenregelung Entwurf", chunk_content): + assert setup.draft.id not in _doc_ids(await search(db, query, user=setup.pablo)) + + +async def test_status_change_applies_without_reindex( + db: AsyncSession, setup: Setup +) -> None: + query = "Zugangskarte Ersatzteillager Tresorfach" + assert setup.granted_eng.id in _doc_ids(await search(db, query, user=setup.pablo)) + setup.granted_eng.status = DocumentStatus.archived + await db.commit() + # Chunks still exist, but the live status filter hides them instantly. + assert setup.granted_eng.id not in _doc_ids( + await search(db, query, user=setup.pablo) + ) + + +async def test_visibility_change_applies_without_reindex( + db: AsyncSession, setup: Setup +) -> None: + """The permission filter reads the documents table, never the stale + denormalized copy in chunk meta.""" + query = "Schleifband für den Bandschleifer" + assert setup.dept_eng.id in _doc_ids(await search(db, query, user=setup.pablo)) + setup.dept_eng.visibility = DocumentVisibility.restricted + setup.dept_eng.author_id = setup.max.id # take authorship out of the way + await db.commit() + assert setup.dept_eng.id not in _doc_ids(await search(db, query, user=setup.pablo)) + + +async def test_vector_branch_finds_exact_content( + db: AsyncSession, setup: Setup +) -> None: + chunk = ( + await db.execute( + select(Chunk.content, Chunk.meta).where(Chunk.document_id == setup.pub.id) + ) + ).one() + chunk_content = embedding_text(chunk.meta["heading_path"], chunk.content) + results = await search(db, chunk_content, user=setup.norbert) + assert results + top = results[0] + assert top.document_id == setup.pub.id + assert top.vector_distance is not None + assert top.vector_distance < 0.001 + + +async def test_top_k_limits_results(db: AsyncSession, setup: Setup) -> None: + results = await search(db, "Kaffeemaschine entkalkt", user=setup.pablo, top_k=1) + assert len(results) <= 1 + + +async def test_search_records_metrics(db: AsyncSession, setup: Setup) -> None: + await search(db, "Kaffeemaschine", user=setup.pablo) + snapshot = metrics.snapshot() + assert snapshot["counters"]["retrieval_searches_total"][0]["value"] >= 1 + assert "retrieval_seconds" in snapshot["histograms"] + + +async def test_text_search_needs_no_embedding_and_keeps_the_permission_filter( + db: AsyncSession, setup: Setup, monkeypatch: pytest.MonkeyPatch +) -> None: + """The fallback for a dead embedding endpoint: keyword matching over the + tsvector index alone, with the same permission CTE as `search`.""" + + async def _no_endpoint(texts: list[str], *, role: str = "embedding"): + raise AssertionError("text_search must not embed") + + monkeypatch.setattr("app.rag.retrieval.embed", _no_endpoint) + + results = await text_search(db, "Kaffeemaschine entkalkt", user=setup.pablo) + assert setup.pub.id in _doc_ids(results) + assert all(result.fts_match for result in results) + assert all(result.vector_distance is None for result in results) + + # Same boundaries as the hybrid path: someone else's restricted document + # stays invisible, an unpublished draft stays out. + assert setup.restricted_ben.id not in _doc_ids( + await text_search(db, "Rabattdeckel", user=setup.pablo) + ) + assert setup.restricted_ben.id in _doc_ids( + await text_search(db, "Rabattdeckel", user=setup.max) + ) + + +async def test_text_search_returns_nothing_for_an_unmatched_query( + db: AsyncSession, setup: Setup +) -> None: + """No fuzzy rescue without vectors: a word nobody wrote finds nothing, + which is what the UI has to be able to say.""" + assert await text_search(db, "Quantenverschraenkung", user=setup.pablo) == [] + + +async def test_text_search_answers_a_whole_question( + db: AsyncSession, setup: Setup +) -> None: + """A question is typed as a sentence, and without a vector half to carry + the recall, requiring every word in one chunk would find nothing.""" + results = await text_search( + db, "Wie wird die Kaffeemaschine eigentlich entkalkt?", user=setup.pablo + ) + assert setup.pub.id in _doc_ids(results) + + +async def test_text_search_ignores_a_query_of_only_stop_words( + db: AsyncSession, setup: Setup +) -> None: + """Nothing to search for is an empty result, not a database error.""" + assert await text_search(db, "und der die", user=setup.pablo) == [] diff --git a/backend/tests/test_similarity.py b/backend/tests/test_similarity.py new file mode 100644 index 0000000..72e7f70 --- /dev/null +++ b/backend/tests/test_similarity.py @@ -0,0 +1,320 @@ +"""The shared similarity mechanic: one permission-filtered vector search, +two calibrated thresholds. + +Uses deterministic fake embeddings (fake_embed): identical text → distance +0, unrelated text → near-orthogonal. That is enough to prove the SQL +plumbing, the permission boundary and the threshold behaviour; whether the +thresholds are set at useful VALUES is measured against the real model in +tests/evals/test_duplicate_eval.py. +""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.metrics import metrics +from app.models import ( + Chunk, + Department, + DocPermission, + Document, + DocumentStatus, + DocumentVisibility, + User, + UserRole, +) +from app.rag.indexing import embedding_text, reindex_document +from app.rag.similarity import ( + CAPTURE_CONTEXT_MAX_DISTANCE, + DUPLICATE_MAX_DISTANCE, + similar_chunks, + similar_documents, +) + +pytestmark = pytest.mark.usefixtures("fake_embed") + + +class Setup: + pablo: User # Engineering + max: User # Sales + pub: Document + dept_sales: Document + restricted_sales: Document + granted_eng: Document + draft: Document + + +async def _user(db: AsyncSession, email: str, department_id) -> User: + user = User( + email=email, + name=email.split("@")[0], + role=UserRole.member, + password_hash=hash_password("secret123"), + department_id=department_id, + ) + db.add(user) + await db.flush() + return user + + +async def _doc( + db: AsyncSession, + *, + title: str, + content: str, + author: User, + department_id=None, + visibility: DocumentVisibility, + status: DocumentStatus = DocumentStatus.published, +) -> Document: + document = Document( + title=title, + status=status, + visibility=visibility, + content_md=content, + author_id=author.id, + department_id=department_id, + ) + db.add(document) + await db.flush() + return document + + +@pytest.fixture +async def setup(db: AsyncSession) -> Setup: + s = Setup() + engineering = Department(name="Engineering") + sales = Department(name="Sales") + db.add_all([engineering, sales]) + await db.flush() + + s.pablo = await _user(db, "pablo@test.dev", engineering.id) + s.max = await _user(db, "max@test.dev", sales.id) + + s.pub = await _doc( + db, + title="Wartung der Kaffeemaschine", + content="## Pflege\n\nDie Kaffeemaschine wird jeden Freitag entkalkt.", + author=s.pablo, + department_id=engineering.id, + visibility=DocumentVisibility.public, + ) + s.dept_sales = await _doc( + db, + title="Angebotsfristen", + content="## Fristen\n\nAngebote gelten dreißig Tage.", + author=s.max, + department_id=sales.id, + visibility=DocumentVisibility.department, + ) + s.restricted_sales = await _doc( + db, + title="Geheime Preisliste", + content="## Preise\n\nDer Rabattdeckel liegt bei zwölf Prozent.", + author=s.max, + department_id=sales.id, + visibility=DocumentVisibility.restricted, + ) + s.granted_eng = await _doc( + db, + title="Ersatzteillager", + content="## Zugang\n\nDie Zugangskarte liegt im Tresorfach drei.", + author=s.max, + department_id=sales.id, + visibility=DocumentVisibility.restricted, + ) + db.add(DocPermission(document_id=s.granted_eng.id, department_id=engineering.id)) + s.draft = await _doc( + db, + title="Pausenregelung", + content="## Entwurf\n\nNeue Pausenregelung ab Oktober.", + author=s.pablo, + department_id=engineering.id, + visibility=DocumentVisibility.public, + status=DocumentStatus.draft, + ) + + for document in ( + s.pub, + s.dept_sales, + s.restricted_sales, + s.granted_eng, + s.draft, + ): + await reindex_document(db, document) + await db.commit() + return s + + +async def _chunk_text(db: AsyncSession, document: Document) -> str: + """A chunk exactly as it was embedded — heading path and all, so a search + for it lands at distance 0 (see indexing.embedding_text).""" + row = ( + await db.execute( + select(Chunk.content, Chunk.meta).where(Chunk.document_id == document.id) + ) + ).first() + return embedding_text(row.meta["heading_path"], row.content) + + +def _doc_ids(results) -> set[uuid.UUID]: + return {result.document_id for result in results} + + +async def test_finds_the_matching_chunk_with_a_usable_distance( + db: AsyncSession, setup: Setup +) -> None: + text = await _chunk_text(db, setup.pub) + results = await similar_chunks( + db, text, user=setup.pablo, max_distance=DUPLICATE_MAX_DISTANCE + ) + assert results + top = results[0] + assert top.document_id == setup.pub.id + assert top.title == "Wartung der Kaffeemaschine" + # Unlike the hybrid path, a distance is always present — that is the + # whole reason this search exists. + assert top.distance < 0.001 + + +async def test_unrelated_text_is_filtered_by_the_threshold( + db: AsyncSession, setup: Setup +) -> None: + loose = await similar_chunks( + db, + "Völlig anderes Thema ohne Bezug zu irgendetwas", + user=setup.pablo, + max_distance=CAPTURE_CONTEXT_MAX_DISTANCE, + ) + assert loose == [] + + +async def test_the_tight_threshold_rejects_what_the_loose_one_accepts( + db: AsyncSession, setup: Setup +) -> None: + """One mechanic, two thresholds: the same call with a smaller limit is + strictly more selective.""" + text = await _chunk_text(db, setup.pub) + near_miss = text + " Zusätzlich wird der Wasserfilter getauscht." + + loose = await similar_chunks(db, near_miss, user=setup.pablo, max_distance=1.0) + assert loose, "the near miss should be retrievable at all" + distance = loose[0].distance + + accepted = await similar_chunks( + db, near_miss, user=setup.pablo, max_distance=distance + ) + rejected = await similar_chunks( + db, near_miss, user=setup.pablo, max_distance=distance / 2 + ) + assert accepted and not rejected + + +async def test_restricted_document_never_surfaces( + db: AsyncSession, setup: Setup +) -> None: + """The permission filter is the same CTE search() uses: Pablo has no + grant for the Sales price list, so no threshold can reveal it.""" + text = await _chunk_text(db, setup.restricted_sales) + results = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) + assert setup.restricted_sales.id not in _doc_ids(results) + # Max authored it, so he still finds it — the filter is about the user, + # not about the document being hidden from everyone. + mine = await similar_chunks(db, text, user=setup.max, max_distance=1.0) + assert setup.restricted_sales.id in _doc_ids(mine) + + +async def test_excluding_a_document_drops_its_own_chunks( + db: AsyncSession, setup: Setup +) -> None: + """A document must never ground a suggestion on itself: excluding its id + removes its own chunks even when the query is its exact text.""" + text = await _chunk_text(db, setup.pub) + included = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) + assert setup.pub.id in _doc_ids(included) + + excluded = await similar_chunks( + db, + text, + user=setup.pablo, + max_distance=1.0, + exclude_document_id=setup.pub.id, + ) + assert setup.pub.id not in _doc_ids(excluded) + + +async def test_department_grant_is_honoured(db: AsyncSession, setup: Setup) -> None: + text = await _chunk_text(db, setup.granted_eng) + results = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) + assert setup.granted_eng.id in _doc_ids(results) + + +async def test_unpublished_documents_are_never_similar( + db: AsyncSession, setup: Setup +) -> None: + """Why duplicate detection cannot match the draft it just created: an + unpublished document is outside the searchable set by construction.""" + text = await _chunk_text(db, setup.draft) + results = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) + assert setup.draft.id not in _doc_ids(results) + + +async def test_documents_are_grouped_by_their_closest_chunk( + db: AsyncSession, setup: Setup +) -> None: + document = await _doc( + db, + title="Mehrteilige Anleitung", + content=( + "## Erster Abschnitt\n\nHier steht der erste Teil der Anleitung.\n\n" + "## Zweiter Abschnitt\n\nHier steht der zweite Teil der Anleitung." + ), + author=setup.pablo, + department_id=setup.pablo.department_id, + visibility=DocumentVisibility.public, + ) + await reindex_document(db, document) + await db.commit() + + chunks = ( + await db.execute( + select(Chunk.content, Chunk.meta).where(Chunk.document_id == document.id) + ) + ).all() + assert len(chunks) > 1, "fixture needs a multi-chunk document" + + query = embedding_text(chunks[1].meta["heading_path"], chunks[1].content) + results = await similar_documents(db, query, user=setup.pablo, max_distance=1.0) + mine = [r for r in results if r.document_id == document.id] + assert len(mine) == 1, "a document must appear once, not once per chunk" + assert mine[0].distance < 0.001, "grouping must keep the CLOSEST chunk's distance" + + +async def test_similar_documents_respects_top_k(db: AsyncSession, setup: Setup) -> None: + results = await similar_documents( + db, "Kaffeemaschine", user=setup.pablo, top_k=1, max_distance=1.0 + ) + assert len(results) <= 1 + + +async def test_similarity_records_metrics(db: AsyncSession, setup: Setup) -> None: + await similar_chunks(db, "Kaffeemaschine", user=setup.pablo, max_distance=1.0) + snapshot = metrics.snapshot() + assert snapshot["counters"]["similarity_searches_total"][0]["value"] >= 1 + assert "similarity_seconds" in snapshot["histograms"] + + +async def test_similarity_logs_no_content( + db: AsyncSession, setup: Setup, caplog: pytest.LogCaptureFixture +) -> None: + """Rule 12: the searched text is user content and never reaches a log.""" + secret = "GEHEIM-SUCHTEXT-42 Kaffeemaschine entkalken" + with caplog.at_level("INFO", logger="pablan.rag"): + await similar_chunks(db, secret, user=setup.pablo, max_distance=1.0) + rendered = "\n".join( + record.getMessage() + str(record.__dict__) for record in caplog.records + ) + assert "GEHEIM-SUCHTEXT-42" not in rendered diff --git a/backend/tests/test_template_catalog.py b/backend/tests/test_template_catalog.py new file mode 100644 index 0000000..d534975 --- /dev/null +++ b/backend/tests/test_template_catalog.py @@ -0,0 +1,128 @@ +"""The catalog must never write over a customer's templates. + +These tests exist because the opposite behaviour shipped first: the catalog +used to be re-imported on every start, which would silently discard an +admin's edits the next time we improved a shipped blueprint. +""" + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Template +from app.template_catalog import ( + STARTER_TEMPLATE_IDS, + catalog_for_locale, + load_catalog, + seed_starter_templates, +) +from app.template_import import parse_template, upsert_template + + +async def test_starter_set_seeds_only_an_empty_table(db: AsyncSession) -> None: + seeded = await seed_starter_templates(db) + assert seeded == len(STARTER_TEMPLATE_IDS) + + ids = set((await db.execute(select(Template.config["id"].astext))).scalars().all()) + assert ids == set(STARTER_TEMPLATE_IDS) + + # A second start adds nothing — the admin's curation is the truth. + assert await seed_starter_templates(db) == 0 + count = (await db.execute(select(func.count(Template.id)))).scalar_one() + assert count == len(STARTER_TEMPLATE_IDS) + + +async def test_seeding_never_overwrites_an_edited_template(db: AsyncSession) -> None: + """The regression this module is named for: an admin edits a starter + template, the server restarts, and the edit survives.""" + await seed_starter_templates(db) + row = ( + await db.execute( + select(Template).where(Template.config["id"].astext == "prozess") + ) + ).scalar_one() + row.name = "Unser Ablauf" + row.config = {**row.config, "persona": "Komplett umgeschrieben."} + await db.commit() + + await seed_starter_templates(db) + + await db.refresh(row) + assert row.name == "Unser Ablauf" + assert row.config["persona"] == "Komplett umgeschrieben." + + +async def test_deleting_a_starter_template_keeps_it_deleted(db: AsyncSession) -> None: + """Deleting all but one must not resurrect the rest on restart — the + table is non-empty, so seeding stays out.""" + await seed_starter_templates(db) + rows = (await db.execute(select(Template))).scalars().all() + for row in rows[1:]: + await db.delete(row) + await db.commit() + + await seed_starter_templates(db) + count = (await db.execute(select(func.count(Template.id)))).scalar_one() + assert count == 1 + + +async def test_an_empty_table_after_deleting_everything_reseeds( + db: AsyncSession, +) -> None: + """The flip side, and the honest consequence of the empty-table rule: an + admin who removes every template gets the starter set back on restart + rather than an instance nobody can capture with.""" + await seed_starter_templates(db) + for row in (await db.execute(select(Template))).scalars().all(): + await db.delete(row) + await db.commit() + + assert await seed_starter_templates(db) == len(STARTER_TEMPLATE_IDS) + + +async def test_importing_a_customer_template_is_untouched_by_seeding( + db: AsyncSession, +) -> None: + own = parse_template( + "\n".join( + [ + "id: unser-eigenes", + 'name: "Unser eigenes"', + 'version: "1.0"', + "kind: authoring", + "persona: |", + " Du bist ein Fachredakteur.", + 'title_template: "X: {{user.name}} ({{date}})"', + "skeleton: |", + " ## Thema", + "sections:", + ' - heading: "Thema"', + ' hint: "Worum es geht."', + ] + ) + ) + await upsert_template(db, own) + await db.commit() + + # The table is not empty, so nothing is seeded over it. + assert await seed_starter_templates(db) == 0 + count = (await db.execute(select(func.count(Template.id)))).scalar_one() + assert count == 1 + + +def test_every_shipped_blueprint_parses_and_declares_its_language() -> None: + """A broken blueprint is skipped at load time, so a silent typo would + quietly shrink the catalog instead of failing loudly.""" + entries = load_catalog() + assert len(entries) >= len(STARTER_TEMPLATE_IDS) + assert all(entry.locale in ("de", "en") for entry in entries) + assert all(entry.description for entry in entries) + # A blueprint may have no sections at all (`notiz` opens an empty + # document on purpose), but one that HAS a skeleton must hint at it. + assert all(entry.sections >= 1 for entry in entries if entry.id != "notiz") + + +def test_catalog_collapses_language_variants_to_one_entry_per_id() -> None: + per_locale = load_catalog() + collapsed = catalog_for_locale("de") + assert len(collapsed) == len({entry.id for entry in per_locale}) + assert all(entry.locale == "de" for entry in collapsed) diff --git a/backend/tests/test_templates_api.py b/backend/tests/test_templates_api.py new file mode 100644 index 0000000..a6725ad --- /dev/null +++ b/backend/tests/test_templates_api.py @@ -0,0 +1,274 @@ +import re + +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Template, User +from app.template_import import parse_template, upsert_template + +VALID_YAML = """\ +id: import-test +name: "Import-Test" +version: "1.0" +kind: authoring +persona: | + Du bist ein Fachredakteur. +title_template: "Import: {{user.name}} ({{date}})" +skeleton: | + ## Thema +sections: + - heading: "Thema" + hint: "Worum es geht." +metadata: + visibility: department +""" + +# The structured config the form builder posts — the same shape parse_template +# produces, so /build and /import share one validation guarantee. +VALID_CONFIG = { + "id": "gebaut", + "name": "Gebaute Vorlage", + "version": "1.0", + "kind": "authoring", + "locale": "de", + "description": "Aus dem Formular gebaut.", + "model": {"temperature": 0.4, "min_class_hint": None}, + "persona": "Du bist ein Fachredakteur.", + "skeleton": "## Thema\n", + "sections": [{"heading": "Thema", "hint": "Worum es geht."}], + "title_template": "Gebaut: {{user.name}}", + "metadata": {"visibility": "department"}, +} + + +async def _login(client: AsyncClient, email: str) -> None: + response = await client.post( + "/api/auth/login", json={"email": email, "password": "secret123"} + ) + assert response.status_code == 200 + + +async def _seed(db: AsyncSession, yaml: str = VALID_YAML) -> Template: + """Put a template in the table directly, for tests that need one to exist + rather than to exercise a create endpoint.""" + row, _ = await upsert_template(db, parse_template(yaml)) + await db.commit() + return row + + +async def test_endpoints_require_auth(client: AsyncClient) -> None: + assert (await client.get("/api/templates")).status_code == 401 + assert ( + await client.post("/api/templates/build", json={"config": VALID_CONFIG}) + ).status_code == 401 + + +async def test_list_get_roundtrip( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + row = await _seed(db) + + # Members can browse templates (they pick one to write a document from). + await _login(client, "pablo@test.dev") + listing = (await client.get("/api/templates")).json() + assert [t["name"] for t in listing] == ["Import-Test"] + detail = (await client.get(f"/api/templates/{row.id}")).json() + assert detail["config"]["sections"][0]["heading"] == "Thema" + assert detail["config"]["persona"].startswith("Du bist") + + +async def test_every_template_is_editable_and_deletable( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """There is no read-only template: a template describes how a company + documents its own knowledge, so the company owns it.""" + row, _ = await upsert_template(db, parse_template(VALID_YAML)) + await db.commit() + await _login(client, "florian@test.dev") + + edited = VALID_YAML.replace('version: "1.0"', 'version: "1.1"') + saved = await client.put(f"/api/templates/{row.id}", json={"yaml": edited}) + assert saved.status_code == 200 + assert saved.json()["version"] == "1.1" + + # A fork gets its own config id, so the two never collide. + forked = await client.post(f"/api/templates/{row.id}/duplicate") + assert forked.status_code == 200 + fork = forked.json() + assert fork["config"]["id"].endswith("-copy") + # Numbered, not worded: the name is shown in the UI, and the backend + # renders no UI-language strings. + assert fork["name"].endswith(" (2)") + + assert (await client.delete(f"/api/templates/{fork['id']}")).status_code == 204 + assert (await client.delete(f"/api/templates/{row.id}")).status_code == 204 + + +async def test_editing_returns_the_yaml_and_rejects_invalid_input( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + row = await _seed(db) + await _login(client, "florian@test.dev") + + # The detail carries editable YAML, not just the parsed config. + detail = (await client.get(f"/api/templates/{row.id}")).json() + assert "skeleton:" in detail["yaml"] + + # Saving invalid YAML is rejected: a missing required field, or malformed + # syntax, both surface as 422 invalid_template rather than corrupting the row. + incomplete = await client.put( + f"/api/templates/{row.id}", json={"yaml": "id: x\nname: y"} + ) + assert incomplete.status_code == 422 + assert incomplete.json()["code"] == "invalid_template" + + malformed = await client.put(f"/api/templates/{row.id}", json={"yaml": ":\n - ]["}) + assert malformed.status_code == 422 + + +async def test_template_management_requires_an_admin( + client: AsyncClient, db: AsyncSession, seeded_user: User +) -> None: + row, _ = await upsert_template(db, parse_template(VALID_YAML)) + await db.commit() + await client.post( + "/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"} + ) + assert ( + await client.put(f"/api/templates/{row.id}", json={"yaml": VALID_YAML}) + ).status_code == 403 + assert (await client.post(f"/api/templates/{row.id}/duplicate")).status_code == 403 + assert (await client.delete(f"/api/templates/{row.id}")).status_code == 403 + + +async def test_catalog_lists_blueprints_and_marks_what_is_added( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """The catalog is what ships in templates/ — inert until someone adds + an entry.""" + await _login(client, "florian@test.dev") + catalog = (await client.get("/api/templates/catalog")).json() + by_id = {entry["id"]: entry for entry in catalog} + + assert "notiz" in by_id + assert "anlage" in by_id + assert by_id["anlage"]["sections"] >= 1 + assert by_id["anlage"]["description"] + # Nothing is in the instance yet. + assert all(entry["added"] is False for entry in catalog) + + added = await client.post("/api/templates/catalog/anlage") + assert added.status_code == 200 + assert added.json()["config"]["id"] == "anlage" + + catalog = (await client.get("/api/templates/catalog")).json() + assert {e["id"]: e["added"] for e in catalog}["anlage"] is True + + # Adding twice would silently overwrite an edited template. + again = await client.post("/api/templates/catalog/anlage") + assert again.status_code == 409 + assert again.json()["code"] == "already_added" + + +async def test_a_template_added_from_the_catalog_is_fully_editable( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """The whole point of the catalog: what you add becomes yours.""" + await _login(client, "florian@test.dev") + added = (await client.post("/api/templates/catalog/person")).json() + + # The detail serializes the config back to YAML, so quoting is safe_dump's. + changed = re.sub(r"^version:.*$", 'version: "0.9"', added["yaml"], flags=re.M) + saved = await client.put(f"/api/templates/{added['id']}", json={"yaml": changed}) + assert saved.status_code == 200, saved.json() + assert saved.json()["version"] == "0.9" + + assert (await client.delete(f"/api/templates/{added['id']}")).status_code == 204 + + +async def test_unknown_blueprint_is_404( + client: AsyncClient, seeded_admin: User +) -> None: + await _login(client, "florian@test.dev") + assert (await client.get("/api/templates/catalog/nope")).status_code == 404 + assert (await client.post("/api/templates/catalog/nope")).status_code == 404 + + +async def test_catalog_requires_an_admin( + client: AsyncClient, seeded_user: User +) -> None: + """Blueprints are an administration concern — a member picks from what + the admin enabled, not from the whole catalogue.""" + await _login(client, "pablo@test.dev") + assert (await client.get("/api/templates/catalog")).status_code == 403 + assert (await client.post("/api/templates/catalog/freies-thema")).status_code == 403 + + +async def test_build_creates_from_config_then_updates_in_place( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """The form builder posts structured config instead of YAML. Creating with + template_id null makes a row; posting the row id updates it in place.""" + await _login(client, "florian@test.dev") + created = await client.post( + "/api/templates/build", json={"template_id": None, "config": VALID_CONFIG} + ) + assert created.status_code == 200, created.json() + body = created.json() + assert body["config"]["id"] == "gebaut" + assert body["config"]["skeleton"].startswith("## Thema") + row_id = body["id"] + + reordered = { + **VALID_CONFIG, + "sections": [{"heading": "Ablauf", "hint": "Die Schritte."}], + "skeleton": "## Ablauf\n", + } + saved = await client.post( + "/api/templates/build", json={"template_id": row_id, "config": reordered} + ) + assert saved.status_code == 200, saved.json() + assert saved.json()["id"] == row_id + assert saved.json()["config"]["sections"][0]["heading"] == "Ablauf" + count = (await db.execute(select(func.count(Template.id)))).scalar_one() + assert count == 1 + + +async def test_build_uniquifies_config_id_for_new_templates( + client: AsyncClient, db: AsyncSession, seeded_admin: User +) -> None: + """A second new template with the same name must not silently overwrite + the first: the server suffixes the derived config id instead.""" + await _login(client, "florian@test.dev") + first = await client.post( + "/api/templates/build", json={"template_id": None, "config": VALID_CONFIG} + ) + second = await client.post( + "/api/templates/build", json={"template_id": None, "config": VALID_CONFIG} + ) + assert first.json()["config"]["id"] == "gebaut" + assert second.json()["config"]["id"] == "gebaut-2" + count = (await db.execute(select(func.count(Template.id)))).scalar_one() + assert count == 2 + + +async def test_build_requires_admin(client: AsyncClient, seeded_user: User) -> None: + await _login(client, "pablo@test.dev") + response = await client.post( + "/api/templates/build", json={"template_id": None, "config": VALID_CONFIG} + ) + assert response.status_code == 403 + + +async def test_build_rejects_invalid_config( + client: AsyncClient, seeded_admin: User +) -> None: + """The nested config is a Pydantic model, so a missing required field is + rejected before it can reach the table.""" + await _login(client, "florian@test.dev") + without_persona = {k: v for k, v in VALID_CONFIG.items() if k != "persona"} + response = await client.post( + "/api/templates/build", json={"template_id": None, "config": without_persona} + ) + assert response.status_code == 422 diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..f099323 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1219 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "openai" +version = "2.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pgvector" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tqdm" +version = "4.69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "pablan-backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "openai" }, + { name = "pgvector" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.18.5" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "openai", specifier = ">=2.46.0" }, + { name = "pgvector", specifier = ">=0.5.0" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.51" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "ruff", specifier = ">=0.11" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..24f8721 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,8 @@ +# Reverse proxy + +Pablan is proxy-agnostic: the proxy only has to serve the frontend and route +`/api/*` to the backend on one origin (no CORS, cookies and SSE work as-is). + +Caddy is the supported setup: `caddy/Caddyfile` is used by +`docker-compose.yml` and provides automatic TLS for `PABLAN_DOMAIN` in five +lines of config. diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..4530aae --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,14 @@ +# Pablan reverse proxy — Caddy (customer default). +# PABLAN_DOMAIN comes from .env via docker-compose.yml; Caddy provisions TLS +# automatically (Let's Encrypt for public domains, internal CA for localhost). +{$PABLAN_DOMAIN} { + encode zstd gzip + + handle /api/* { + reverse_proxy backend:8000 + } + + handle { + reverse_proxy frontend:3000 + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..9475eb2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +# Dev stack: only Postgres runs in Docker. Backend (uvicorn --reload) and +# frontend (vite dev) run natively on the host — see `make dev`. +# Separate project name so a customer-stack smoke test (docker-compose.yml) +# can run on the same machine without colliding. +name: pablan-dev + +services: + postgres: + image: pgvector/pgvector:pg18 + environment: + POSTGRES_USER: ${POSTGRES_USER:-pablan} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + POSTGRES_DB: ${POSTGRES_DB:-pablan} + ports: + - "5432:5432" + volumes: + # PG18 images expect the mount at /var/lib/postgresql (not .../data) + - postgres_dev_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 15 + +volumes: + postgres_dev_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..056cb21 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +# Pablan customer deployment: postgres + backend + frontend + reverse proxy. +# Copy .env.example to .env, set POSTGRES_PASSWORD, PABLAN_DOMAIN and the LLM +# endpoints, then: docker compose up -d --build +name: pablan + +services: + postgres: + image: pgvector/pgvector:pg18 + environment: + POSTGRES_USER: ${POSTGRES_USER:-pablan} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-pablan} + volumes: + # PG18 images expect the mount at /var/lib/postgresql (not .../data) + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + backend: + build: ./backend + # Exactly ONE worker — the in-process job queue and metrics registry + # assume a single process (docs/architecture.md). Do not raise this; + # scaling is the Variant B worker split. + command: + [ + "uv", + "run", + "--no-sync", + "uvicorn", + "app.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + "--workers", + "1", + ] + env_file: .env + environment: + PABLAN_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pablan}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-pablan} + # Production posture by default, even if .env carries dev values. + PABLAN_ENV: ${PABLAN_ENV:-production} + PABLAN_COOKIE_SECURE: ${PABLAN_COOKIE_SECURE:-true} + PABLAN_TEMPLATES_DIR: /app/templates + volumes: + # Built-in interview templates (product content, outside the image). + - ./templates:/app/templates:ro + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + frontend: + build: ./frontend + environment: + ORIGIN: https://${PABLAN_DOMAIN:?set PABLAN_DOMAIN in .env} + # SvelteKit server-side auth check goes straight to the backend + # container instead of looping through the public proxy. + PABLAN_INTERNAL_API_BASE: http://backend:8000 + restart: unless-stopped + + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + PABLAN_DOMAIN: ${PABLAN_DOMAIN} + volumes: + - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - backend + - frontend + restart: unless-stopped + +volumes: + postgres_data: + caddy_data: + caddy_config: diff --git a/docs/api-protocol.md b/docs/api-protocol.md new file mode 100644 index 0000000..046711f --- /dev/null +++ b/docs/api-protocol.md @@ -0,0 +1,415 @@ +# API protocol + +Status: rough contract — Claude refines this doc as endpoints are +implemented; it must always match the actual FastAPI routes. + +## General + +- All API routes live under `/api/*`, served by FastAPI. The reverse proxy + (prod) / Vite dev proxy (dev) makes frontend and API same-origin. +- Types flow one way: FastAPI OpenAPI → `make types` → `openapi-typescript` + → typed `openapi-fetch` client. Never hand-write response types. +- Errors: consistent JSON problem shape `{ "detail": string, "code": string }`. + `detail` is a developer-facing English sentence; the frontend renders the + message from `code` (`lib/api/errors.ts`), never from `detail`. +- **LLM endpoint failures share one vocabulary.** `LLMError.code` + (`llm/client.py`) classifies every endpoint failure once, and every surface + that talks to a model reports it unchanged, over REST (503) or as an SSE + `error` frame: + + | code | when | what the user is told | + |---|---|---| + | `llm_unreachable` | connection refused / DNS / reset | the endpoint is not running | + | `llm_busy` | 408, 429, 503, 504, a timeout, or Pablan's own endpoint gate refusing to queue further (`llm/gate.py`) | it is loaded, retry in a moment | + | `llm_misconfigured` | 401, 403, 404 | wrong key, wrong model, wrong URL | + | `llm_failed` | anything else | it did not answer | + + A busy endpoint queues rather than refuses, so a turn can simply stay + silent: the chat says so after 12s (`slow` in `chat/state.svelte.ts`) + instead of leaving the user guessing. Waiting for a free slot is bounded + by Pablan itself (`llm/gate.py`), so a turn that cannot be served soon + ends in `llm_busy` in seconds rather than at the HTTP timeout. +- `GET /api/health` — unauthenticated liveness probe, returns + `{"status": "ok"}`. Used by reverse proxies / monitoring. + +## Auth flow + +Sequence incl. the 401 → redirect branch: +[`diagrams/auth-sequence.svg`](diagrams/auth-sequence.svg) + +- `POST /api/auth/login` `{email, password}` → verifies argon2 hash, creates + `auth_sessions` row, sets httpOnly + Secure + SameSite=Lax cookie + `pablan_session`. Returns the user object. +- `POST /api/auth/logout` → deletes the session row, clears the cookie. +- `GET /api/auth/me` → current user or 401. +- SvelteKit `hooks.server.ts` calls `/api/auth/me` server-side (forwarding + the cookie header) and fills `locals.user`; the `(app)` layout group + redirects to `/login` without it. +- `POST /api/account/password` `{current_password, new_password}` → 204. + Self-service: the current password must verify (403 + `invalid_current_password`), the new one is at least 8 characters. Every + OTHER session of that user is revoked; the session making the change + survives, so the user is not thrown out of the app they are standing in. +- `PUT /api/account/locale` `{locale: "de" | "en" | null}` → 204. Pins the + interface language on `users.locale`, so it follows the person across + devices; `null` goes back to following the browser. The backend only + stores the choice — it never renders UI-language strings (see + `architecture.md`). Anything else → 422. +- `GET /api/account/document` → `{document_id, title, status, template_id}`, + the caller's own document about themselves: the one they authored from the + person blueprint (`person`), or nulls plus the `template_id` to + start it from. Self-scoped, and AUTHORSHIP is the whole rule — a document + someone else wrote about you is not this one. The blueprint id lives here, so + the frontend needs to know no ids; `template_id` is null too when an admin + removed the blueprint, and the ordinary template picker takes over. +- Later: OIDC (Entra ID) via authlib ends in the same `auth_sessions` + mechanism — no parallel auth system. + +## People + +- `GET /api/people` → `[{id, name, role, department}]`, the member-visible + colleague directory, ordered by name. Any authenticated user; **no email or + password hash** ever leaves it — the same permission-safe, non-admin shape as + `ReviewerCandidate`, deliberately separate from the admin-only + `/api/admin/users`. +- `GET /api/people/{id}` → one colleague's `{id, name, role, department}`; + unknown id → 404. + +## Conversations & streaming + +Conversations are chat threads. The only core mode is `query` (RAG Q&A); EE +registers `insight`. **Capture is no longer a conversation** — it writes a +Document directly (see the Documents section and `authoring-templates.md`), so +there are no interview/draft/checklist endpoints. All conversation endpoints +are owner-scoped; another user's conversation returns 404. The `mode` must be +registered (`modes/registry.py`) or creation returns 400 `unknown_mode`. + +- `POST /api/conversations` `{mode}` → creates a conversation. +- `GET /api/conversations` / `GET /api/conversations/{id}` → list / detail + incl. messages; list carries a `title` derived from the first user message. +- `DELETE /api/conversations/{id}` → user deletes own conversation (GDPR); + messages cascade. +- `POST /api/conversations/{id}/messages` `{content}` → **SSE stream** + response (`text/event-stream`). The user message is persisted before + streaming; the assistant message is persisted when the stream ends — + complete on normal end, as a partial if the client aborts (stop button). + +### SSE events (mirror of `ModeEvent`) + +| event | data | meaning | +|---|---|---| +| `token` | `{"text": "..."}` | next fragment of the assistant reply | +| `sources` | `{"chunks": [{document_id, title, heading_path, excerpt, used, review_pending}]}` | every retrieved passage; `used` marks the ones that grounded the answer (the "?" inspector shows all, the badges only `used`), `review_pending` that the cited document has an unanswered question about it | +| `state` | `{"phase": "...", "count": 3\|null}` | progress updates | +| `error` | `{"code"}` | why the turn failed, e.g. `llm_unreachable`; the frontend phrases it | +| `fallback` | `{"code"}` | no model was reachable: the preceding `sources` are a plain full-text result list to open, and no answer follows | +| `done` | `{"message_id": "..."}` | reply persisted, stream ends | + +`state` is **metadata only, never content** (rule 12): the query text and +retrieved passages never appear in a `state` payload. Query mode's phases are +`searching` → `results` \| `no_answer` → `queued`? → `answering`; `queued` +appears only when every slot on the chat endpoint is taken as the turn is about +to stream, and is followed by `answering` when the first token arrives. `count` +is the number of +passages (0 for `no_answer`). The two-field frame is structurally incapable of +carrying document text, and one shape serves every mode — an EE mode adding a +phase does not change it. + +`fallback` is the no-model path, and deliberately not an `error`: the turn +still has a reply, just not a generated one. Retrieval drops to the German +full-text index alone (`rag/retrieval.text_search`, no embedding call — and +with the query's terms ORed rather than ANDed, so a whole typed question still +finds something), the +`sources` frame is re-sent with every passage `used: false` (nothing reached a +prompt), and the frontend renders the hits as a list the reader opens +themselves. It is persisted like any other reply — `messages.meta.fallback` +holds the code, and `GET /api/conversations/{id}` returns it as +`messages[].fallback` — so a reload replays the turn instead of showing an +empty assistant bubble. A dead EMBEDDING endpoint alone does not trigger it: +the roles are configured separately, so retrieval falls back to full text and +the chat model still answers. + +`no_answer` is the low-confidence path: retrieval found nothing solid, the +model answers without sources, and the frontend offers to capture the missing +knowledge — which opens the template picker and starts a new draft +(`POST /api/documents`), not a conversation. + +`review_pending` travels with every hit, from the retrieval SQL through the +mode to the citation: a document can be published and still carry an open +question about it, and the answer that leans on it says so (a warning mark on +the source badge, in the "?" inspector, and in the fallback list). It is +snapshotted with the citation like the rest, so reopening a conversation shows +what was true when the answer was given. + +The `excerpt` on a citation is a short (≤280 char) preview of the cited +chunk — the requesting user already passed the permission filter for it. +Assistant messages snapshot their citations into `messages.meta`, so +`GET /api/conversations/{id}` returns them under `messages[].sources` +(empty for user turns) and citations survive reload and re-indexing. + +Frontend consumes this via `fetch` + `ReadableStream` (NOT `EventSource` — +it can't POST) with an `AbortController` wired to the stop button; see +`lib/api/stream.ts`. + +## Documents + +Listing and detail are permission-scoped (same filter as retrieval, plus the +unpublished documents this user owns **or was asked to check**); unreadable +documents return 404 — their existence must not leak. **Editing** requires the +author, an admin, or a colleague with an open review request on the document — +being asked to check something is what grants the right to fix it. The +owner-only decisions (delete, sharing, handing out a review request) stay with +the author or an admin. Every `DocumentSummary`/`DocumentDetail` carries the +per-request `can_edit`, `open_reviews` (the number of unanswered questions) and +`access_reason` (`author | public | department | granted | review`), so the UI +predicts the gate rather than guessing it, and can mark a document that is +readable but not settled. `review` is the reason that ENDS: it means an open +request is the only thing letting this caller in, so answering it takes the +access away — a draft goes back to being its author's alone. + +This is where knowledge is captured: the user writes Markdown into a document +that starts as a `draft`, matures it with AI section refinement, and publishes +it themselves — one action, no approval queue. A `draft` is author-only +(`readable_documents_filter` shows it to no one else, bar a colleague asked to +check it) and never indexed (only `published` documents are searched), so it +never reaches another user or an LLM prompt (rule 2). + +- `POST /api/documents` `{template_id?, title?, visibility?, conversation_id?}` + → 201 with the new `draft`'s `DocumentDetail`. With a `template_id` the draft + opens on that template's Markdown skeleton and rendered title (see + `authoring-templates.md`); a bad template is 404 `not_found` / 422 + `invalid_template`. Without one it starts blank and `title` is required + (422 `title_required`). When + the capture started from a chat, an optional `conversation_id` (owner-scoped; + a foreign or unknown id is ignored) has that conversation's subject summarized + once by the LLM and stored on the draft as `meta.context`, background the + refine prompt then uses to stay on topic. +- `POST /api/documents/suggest-similar` `{conversation_id}` → existing documents + that match the conversation a capture is starting from, as + `[{document_id, title}]` (best first). The match runs over an **LLM + topic summary** of the chat (not the raw last message), then hybrid retrieval + (`rag/similarity.similar_documents`, `exclude_builtin`), so it is filtered by + construction (rule 2) and never offers a help page. Owner-scoped; an empty + list when the conversation is unknown/foreign/empty or nothing is close + enough. Feeds the "matches your conversation" block in the picker so the user + can extend an existing document instead of starting a new one. +- `POST /api/documents/{id}/refine` `{content_md, cursor_line}` → **SSE + stream** that matures the section the cursor sits in. Owner-scoped (author + or admin over a readable document). The server computes the active section + (`app/authoring/sections.py`), streams a refined version of **only** that + section (FIM-style: the rest of the document is prefix/suffix context the + model must not re-emit), and disables the reasoning model's thinking for + ~1s latency. The refinement is **retrieval-aware**: the server first searches + the permission-filtered knowledge base for related, already-published + material the author may read (the current document and help pages excluded, + and only once the section carries enough of its own text) and passes it to + the prompt as grounding, so a suggestion stays consistent with what the + company already documented. Frames: + - `section` `{start_line, end_line}` — 1-based inclusive range the + suggestion will replace, sent first so the client can bind "Accept" to an + exact range before any token arrives. + - `grounding` `{references: [{title, heading_path}]}` — what the suggestion + drew from (the author's own readable material), for the "?" inspector; only + sent when the section matched something. + - `token` `{text}` — next fragment of the refined section. + - `done` `{}` — stream complete. + - `error` `{code}` — an `llm_*` code (see General). + + The request body and the streamed response carry document text; that is fine + on this owner-scoped endpoint (same trust boundary as `GET /api/documents/{id}`) + but nothing here logs content — metadata only (rule 12). +- `POST /api/documents/{id}/suggest-title` → `{title}`, a concise title + suggested from the document's content via `chat_json` (utility). Used at the + review step for a new document that still carries its generic template title; + owner-scoped (author or admin), content in / title out, nothing logged. Falls + back to the current title for an empty document; 503 with the `llm_*` code + when the endpoint fails. +- `POST /api/documents/{id}/publish` → draft → published + `index_document` + job. Author or admin, deliberately NOT every editor: a + colleague asked to check a draft may fix what is wrong in it, but whether the + company gets to read it at all is not their call. An open question about the + content does not block it, it travels with the document instead. A dedicated + transition because PATCH deliberately refuses to publish. Any other status → + 409 `invalid_status`. +- `GET /api/documents/{id}/reviewers` → `[{id, name}]`, the colleagues who can + be asked to check this document: everyone who could read it once published + (author excluded). Editor-only, non-admin, and permission-safe — a dedicated + query, not `/admin/users` — so only id + name leave the server. +- `POST /api/documents/{id}/reviews` `{reviewer_id, question?}` → the document + detail. Asks one colleague to check it, optionally about something specific + ("do the 14 holiday days still hold?", ≤2000 chars). Author/admin only; the + reviewer must be in that read set (422 `invalid_reviewer`), cannot be the + caller (422 `invalid_reviewer`), and cannot already have an open request on + the document (409 `review_already_open`). Until it is answered, the request + lets that colleague read AND edit the document (never search it), and marks + it wherever it appears. +- `POST /api/documents/{id}/reviews/{review_id}/resolve` → the document detail. + The answer: the content was checked. The reviewer answers their own request; + the author or an admin can close one that has become moot, so a question + nobody will answer does not mark a document forever. Unknown request → 404, + already answered → 409 `already_resolved`. +- `PUT /api/documents/{id}/departments` `{department_ids, confirm_lockout?}` → + the document detail. **Multi-department sharing**: replaces the full set of + ADDITIONAL departments a document is shared with (its `doc_permissions` + grants), on top of the owning department. Author/admin; unknown department → + 404; no reindex (grants are evaluated live). Unsharing can drop the editing + admin's own access, so it runs the same self-lockout guard as PATCH (409 + `self_lockout_warning` unless `confirm_lockout`). The owning department is + never part of the set. `GET /api/documents/{id}` returns the current set as + `shared_departments: [{id, name}]`; the `?department=` list filter counts a + shared document under each department it reaches. + +- `GET /api/documents` (filters: department, status, assigned_to_me, + search — the `search` param here is a plain title match, used for cheap + filtering) +- `GET /api/documents/search?q=` → ranked hits through the SAME hybrid + retrieval the chat uses (`rag/retrieval.search`), so the permission + filter is identical by construction (rule 2). Each hit carries the + document plus `heading_path` — the best-matching section, empty when the + match was on the title. Drafts are readable but never chunked, so a title + fallback covers them — the one asymmetry between this endpoint and chat + retrieval. +- `GET /api/documents/{id}` — includes Markdown content, plus `reviews`: + every request on this document oldest-first, open and answered, as + `{id, question, requester_name, reviewer_id, reviewer_name, created_at, + resolved_at, resolved_by_name, is_mine}`. The answered ones are the record of + what was already checked; `is_mine` tells the UI to offer the answer rather + than just show the question. Built-in help documents (`is_builtin`, sourced + from `help/*.md`) are read-only: PATCH and DELETE return 409 + `builtin_readonly`, and their `can_edit` is always false. +- `GET /api/documents` — browse. Returns an envelope + `{items, total, per_page}`, not a bare list: the document list is + the one screen that grows without bound. Query params `department`, + `status`, `search` (title substring), `assigned_to_me` (bool — only + documents with an open review request addressed to this user, the "waiting + for my check" queue), `sort` (`updated` | `created`), `page` (≥1), `per_page` + (1–100, default 30). `total` is + computed over the same permission filter as the page, so it never + reveals how much exists beyond what the caller may read. Built-in help + sorts last in SQL, so it stays last across page boundaries. +- `PATCH /api/documents/{id}` — title, content_md, visibility, and + the archive transition (`status`: only published ↔ archived; publishing has + its own endpoint, because it indexes the document). `visibility` needs the + author or an admin (like sharing and deleting) while title/content only need + an editor — who may READ a document is the owner's decision, a reviewer + corrects the text. Content-affecting edits to + published documents and status transitions enqueue `index_document`. A + visibility change that would remove the editing user's own access runs the + self-lockout guard (409 `self_lockout_warning` for an admin unless + `confirm_lockout` is set; an author always keeps access). +- `GET /api/documents/{id}/history` → `[{id, action, actor_id, actor_name, + visibility, created_at, has_snapshot}]`, the audit trail newest-first: who + changed or checked the document, when. `action` ∈ `created | edited | + published | archived | visibility_changed | review_requested | + review_resolved`; + `has_snapshot` marks the content-bearing events whose frozen version can be + fetched. Same read gate as `GET /api/documents/{id}` — history never leaks to a + user who cannot read the document. +- `GET /api/documents/{id}/versions/{event_id}` → `{id, action, actor_id, + actor_name, created_at, title, content_md, previous_content_md, visibility}`, + one past version's frozen Markdown plus the content it replaced, so the caller + can show what THIS event changed (the detail page reuses the + `unifiedMergeView` line diff). A snapshot is written after its event, so + `previous_content_md` is the closest earlier snapshot, and null for the first + one. Unknown/foreign event → 404. Same read gate. +- `GET /api/documents/export` — a streamed ZIP of the readable knowledge base + as Markdown files with YAML frontmatter (title, status, visibility, + departments (owning + shared)). Permission-filtered by construction + (`readable_documents_filter`), built-in help pages excluded; stdlib + `zipfile`/`io` only, no temp files. Declared before `/{document_id}`. +- `DELETE /api/documents/{id}` — chunks go with it (cascade) +- `GET /api/documents/stats` → `{documents_total, departments_total}`, read by + the landing page's first-run guide to tell a fresh install from a filled one. + `documents_total` counts published documents only. Aggregates carry no titles + and no per-user data (D15), so they are deliberately not permission-filtered. + +## Templates & admin + +- `GET /api/templates`, `GET /api/templates/{id}` — any authenticated user + (the template picker needs them) → `{id, config_id, name, version, + description}`. `id` is the row id used to start a document; `config_id` is + the blueprint id from the YAML (e.g. `prozess`), stable across + installs, so a surface that wants to offer ONE known blueprint can find it + (the profile page's "write about yourself"). The detail carries `yaml`, the + editable source rendered server-side, since the frontend has no YAML + library. Admin-only from here on: + - `POST /api/templates/build` `{template_id, config}` saves a template + from the **form builder**: `config` is the structured + `AuthoringTemplate` (the same schema pasted YAML would parse into, so + the form and the YAML editor share one validation — the frontend has no + YAML library and sends the config instead of serializing it). + `template_id` null creates a row (the derived config id is uniquified + server-side, so a second template with the same name never overwrites + the first); a UUID updates that row in place (409 `id_taken` only if the + config id collides with a *different* row). 422 on schema violations. + - `PUT /api/templates/{id}` `{yaml}` replaces a template, validated on + save; 409 `id_taken` if another template already uses that config id. + - `POST /api/templates/{id}/duplicate` forks one — the copy gets a fresh + config id (`-kopie`), so the two never collide. + - `DELETE /api/templates/{id}` removes a template; documents created from + it are independent and survive (a template is only a starting point). + - **Every template is editable.** There is no `builtin_readonly` here — + that applies only to the built-in help *documents* + (`/api/documents/*`). See `authoring-templates.md` for the reasoning. +- The shipped **catalog** of blueprints, admin-only. A blueprint lives in + `templates/` and has no row until it is added, so `catalog_id` is the + config id, not a UUID: + - `GET /api/templates/catalog` → `{id, name, description, sections, added}` + per blueprint (`sections` = how many skeleton headings carry hints); + `added` is true when a template with that config id already exists. + - `GET /api/templates/catalog/{catalog_id}` → the same plus `yaml`, for + reading the skeleton before adding. 404 `not_found` if unknown. + - `POST /api/templates/catalog/{catalog_id}` copies it into this + instance and returns the new `TemplateDetail`. 409 `already_added` + rather than overwriting an admin's edits. +- `GET /api/departments` — names + ids for filters and pickers, any + authenticated user. +- Admin (requires admin role): + - Users CRUD under `/api/admin/users`: create (409 `email_taken` on + duplicates), update (setting `password` IS the reset and **revokes all + sessions of that user** — including the current one when an admin + changes their own password; admins cannot change their own role or + delete themselves — 409 `self_modification`), delete + (sessions/conversations cascade, documents survive authorless). + - Departments CRUD under `/api/admin/departments` (409 `name_taken`; + deletion leaves members and documents without a department). + - `POST /api/admin/llm/test` — pings all three model roles and reports + which endpoint is broken (first-line support tool). Per role: + `{role, ok, base_url, model, latency_ms, code, error}` — `code` is the + shared `llm_*` code the frontend phrases, `error` the sanitized technical + detail (exception class + role) an admin needs to act. With a body + `{role, base_url?, model?, api_key?}` it probes ONE candidate + configuration without persisting it, so an endpoint can be validated + before saving. + - `GET /api/admin/llm/settings` → stored config per role plus + `*_from_env` / `api_key_set` flags. The key itself is never returned + (rule 12). Values are seeded from `.env` at first start; after that + the DB is authoritative (see `architecture.md`). + - `PUT /api/admin/llm/settings/{role}` `{base_url?, model?, api_key?, + reset_*?}` → stores the value and marks that field as changed here; + `reset_: true` writes back what `.env` currently says and marks + it as coming from the environment again. Applies without a restart. + An omitted `api_key` keeps the stored one. + - `POST /api/admin/llm/models/{role}` `{base_url?, api_key?}` → + `{models, supported, error}`. Calls the endpoint's `GET /v1/models` + server-side so the credentials never reach the browser, letting the UI + offer a dropdown. `supported: false` means the endpoint has no such + route (common, not an error) and the UI keeps free-text entry. + - `GET /api/admin/prompts` → `[{key, content, is_default}]`, every editable + system prompt with its effective text (an override, or the code default) + and whether it is still the default. The UI labels each key from i18n. + - `PUT /api/admin/prompts/{key}` `{content?}` or `{reset: true}` → overrides a + system prompt (applied without a restart) or drops the override back to the + code default. Empty content → 422 `empty_prompt`; unknown key → 404. +- `GET /api/admin/metrics` — in-process metrics snapshot as JSON (LLM calls + per role, job queue depth/duration/retries). Admin only; a Prometheus + text-format exporter is planned for M10. + +## Security notes + +- All LLM output and document content rendered in the frontend passes + through the sanitizing Markdown renderer (DOMPurify). Model output is + untrusted input (stored-XSS vector via document content). +- Retrieval endpoints never accept a user id from the client; the user comes + from the session, and permission filtering happens inside + `rag/retrieval.search` (see `data-model.md`). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4cf8e2c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,465 @@ +# Architecture + +Pablan is a self-hosted knowledge management application for SMEs. Employees +capture knowledge by **writing Markdown directly** into a document in an +editor that matures the section at the cursor with the LLM (a writing-first +flow, not a chatbot interview — D19); the results are Markdown documents. It +answers questions about the knowledge base via RAG, and (as a paid feature) +generates aggregated insights for management. + +Core pain points addressed: knowledge leaves the company when employees do +("tacit knowledge" is never written down), and departments solve problems in +isolation without learning from each other. + +## Deployment model (Variant A — the pragmatic monolith) + +Everything runs on the customer's infrastructure via Docker Compose: + +``` +[Browser] ⇄ [Reverse proxy :443] + ├── /api/* → backend (FastAPI, :8000) + └── /* → frontend (SvelteKit node adapter, :3000) +[backend] ⇄ [PostgreSQL + pgvector] +[backend] ⇄ [LLM endpoints] (OpenAI-compatible: llama.cpp local OR cloud API) +``` + +- **One origin.** The reverse proxy makes frontend and API same-origin: no + CORS, httpOnly session cookies just work, SSE streams flow directly from + FastAPI to the browser without passing through Node. +- **One database.** PostgreSQL holds relational data, documents, embeddings + (pgvector), full-text indexes, background jobs, and auth sessions. No + dedicated vector DB, no Redis, no message broker. +- **Reverse proxy** is a reference config in `deploy/`, not part of the app. + Caddy is the supported customer setup (5-line config, automatic TLS). + +Component overview (built vs. planned): +[`diagrams/components.svg`](diagrams/components.svg) + +## Backend structure (`backend/app/`) + +| Module | Responsibility | +|---|---| +| `models/` | SQLAlchemy 2.0 models — see `data-model.md` | +| `auth/` | Server-side sessions, argon2, permission dependencies | +| `api/` | Thin HTTP routers; no business logic. The big ones (`documents/`, `admin/`, `conversations/`, `authoring/`, `templates/`) are packages split by what a caller is doing | +| `modes/` | Mode engine: query (+ ee: insights) | +| `authoring/` | Capture: authoring-template schema + document/section refinement for the writing editor | +| `llm/` | The ONLY code speaking to LLM endpoints | +| `rag/` | Chunking, indexing, hybrid retrieval with permission filter | +| `ingestion/` | Background jobs via a Postgres `jobs` table | +| `log.py` | Structured JSON logging, correlation ids; content-free by policy (rule 12) | +| `metrics.py` | In-process metrics registry, exposed via `GET /api/admin/metrics` | +| `ee_hooks.py` | Optional import of `pablan_ee`, registers EE modules | + +### How an API package is cut + +By what a caller is doing, not by HTTP verb: `documents/` has `browse`, `crud`, +`history`, `workflow` and `sharing`, with the gates in `access.py` and the +response shapes in `view.py`, so a rule like "an author keeps access to their +own document" exists once. Each module builds its router through the package's +`routing.py`, which is also where a shared dependency lives — the admin gate +sits on the router, so a new admin endpoint is gated by where it is added +rather than by remembering a decorator. + +**Route order matters**: FastAPI matches in registration order, so the module +with the static paths (`/search`, `/export`, `/catalog`) is included before the +one with `/{id}`. + +### Mode engine + +Every interaction type implements the `Mode` protocol: + +```python +class Mode(Protocol): + name: str + async def handle_turn(conv, user_message, db) -> AsyncIterator[ModeEvent] +``` + +`ModeEvent` = `Token | Sources | StateChanged | Done | Error | Degraded` +(`StateChanged` carries a `phase` and an optional `count` — metadata only, +rule 12; `Degraded` says no model could be reached, so the accompanying +`Sources` are a plain full-text result list rather than citations). The +conversations router converts events 1:1 into SSE events (see +`api-protocol.md`). Modes know nothing about HTTP; routers know nothing about +mode logic. Modes register in `modes/registry.py` — this is also the EE +extension point (the insights mode lives entirely in `ee/backend`). + +**`query` (RAG Q&A) is the only core mode.** Capture is deliberately NOT a +mode: it is writing into a Document directly (`app/authoring/`), not a +conversation, so it never touches `handle_turn` or the SSE turn loop. That +keeps rule 5 clean — `modes/` stays the home of the turn protocol. + +### LLM abstraction + +Three independently configured model roles, each just `base_url` + +`api_key` + `model` (env vars `PABLAN_CHAT_*`, `PABLAN_UTILITY_*`, +`PABLAN_EMBEDDING_*`): + +- **chat** — conversation turns (quality matters; Gemma-class 12B+ locally, + Claude/cloud in production) +- **utility** — summarization, entity extraction (cheap + fast) +- **embedding** — multilingual model (e.g. bge-m3); German quality is a + first-class requirement + +The client exposes exactly three functions: `chat_stream`, `chat_json` +(structured output enforced via `response_format` JSON schema + Pydantic +validation + one retry), and `embed`. llama.cpp's server implements all +required endpoints including grammar-constrained JSON. `chat_stream` takes an +optional `extra_body` passed through to the endpoint verbatim — used only by +section refinement to send `chat_template_kwargs.enable_thinking = false`, +which turns off a reasoning model's hidden thinking so the refine call returns +in ~1s instead of ~10s (a mechanical rewrite needs no chain of thought). + +**Configuration: bootstrap from env, then the DB owns it.** At first start +`bootstrap_llm_settings()` copies the `PABLAN_CHAT_*` / `PABLAN_UTILITY_*` / +`PABLAN_EMBEDDING_*` values into `llm_settings`, one row per role. From then +on **the table is authoritative and later `.env` edits are ignored** — a +configuration the admin can change in the UI and the deployment can change +underneath them cannot both be the truth, and silently losing a UI change +on the next restart is the worse of the two failures. + +The environment stays reachable as the value a field can be *reset* to. +Each field carries a `*_from_env` flag recording where its current value +came from, so the UI labels every field "from .env" or "changed here" and +offers a per-field reset that writes back what `.env` currently says. +Provenance is tracked explicitly rather than inferred by comparing against +the environment, which would mislabel every field the moment someone edits +`.env` after install. + +`_role_config()` reads a module-level cache (`app/llm/overrides.py`) +because it is synchronous and hot; the cache is filled at startup and +refreshed on every admin write, which also calls `rebuild_clients()` — the +cached `AsyncOpenAI` instances hold the old URL and key, so changes apply +without a restart. This works because the deployment pins one uvicorn +worker: exactly one cache to refresh. A multi-worker setup would need a +notification channel. + +The `api_key` is stored in plaintext — it must be replayed to the endpoint, +so there is nothing to compare a hash against. It is never returned by the +API (only `api_key_set` / `api_key_from_env` booleans) and never logged +(rule 12). Admins validate a candidate endpoint with `probe()` before +saving, so a wrong URL fails in the settings screen rather than on the next +user question, and `list_models()` asks the endpoint's `GET /v1/models` +**server-side** so the model field can become a dropdown without the +browser ever holding the credentials. Endpoints that do not implement that +route are common; the response carries `supported: false` and the UI keeps +its free-text field rather than showing an error. + +**One gate per endpoint, in front of every call** (`app/llm/gate.py`). A +self-hosted server has a fixed number of parallel slots; sending more than that +does not make it faster, it just moves the queue somewhere Pablan cannot see or +bound, where every waiting request still burns the HTTP timeout. Two colleagues +chatting while a reindex runs is enough. So `chat_stream`, `chat_json` and +`embed` each take a slot first, keyed by **base_url** rather than by role — +chat and utility usually share one server, and the server is what has the +slots. A caller waits at most `PABLAN_LLM_QUEUE_WAIT_SECONDS`, and past +`PABLAN_LLM_MAX_QUEUED` waiters the gate stops admitting at all; both refusals +come back as `llm_busy`, the same code a cloud provider's 429 produces, so the +frontend has one sentence to say. A streaming completion holds its slot until +the last token, because that is how long it occupies the server's. Two +consequences worth naming: a saturated EMBEDDING endpoint makes query mode fall +back to full-text retrieval (it already handles `LLMError` there) rather than +fail, and the query mode asks `endpoint_busy()` before it streams, so a waiting +turn says "the model is busy" instead of blinking a cursor. `probe()` and +`list_models()` deliberately bypass the gate: an admin has to be able to test an +endpoint precisely when it is saturated. `PABLAN_LLM_MAX_PARALLEL` should match +the server's own parallelism (llama.cpp `--parallel`). + +**Admin-editable prompts** mirror this pattern with two simplifications. Every +shipped system prompt (query, no-sources, refinement persona/rules, grounding +framing, topic summary, title) has a CODE default in +`app/prompts/defaults.py`; an admin may override it in `prompt_settings`, applied +without a restart via a module cache (`app/prompts/overrides.py::get_prompt`, +refreshed on every write). The render functions read `get_prompt(key)`, so an +override wins and a missing row falls back to the default — the reset target is +the code default, since prompts have no `.env` layer, and there is no bootstrap. +Prompt-cache friendliness (D22) holds: a value only changes on an admin write, so +`query_system` stays byte-identical between a conversation's turns. + +**Inspectable working context.** Behind a "?" in the chat, the context inspector +shows every passage retrieval surfaced for an answer, marking which grounded it +(`used`) versus which were retrieved but dropped as too weak — so a no-answer is +explained rather than silent. The editor shows the same for a refinement: the +grounding references it drew from, sent as a `grounding` SSE frame. Both surface +only material the requesting user may already read (rule 2) — titles/headings and +short excerpts, never new content. + +### Ingestion queue + +Sequence: [`diagrams/queue-sequence.svg`](diagrams/queue-sequence.svg) + +A `jobs` table + an asyncio loop in the app lifespan, claiming jobs with +`SELECT … FOR UPDATE SKIP LOCKED`. The claim transaction stays open while +the handler runs: a crash rolls back claim and handler writes together, so +jobs are atomic and claimable again after a restart. Retry with exponential +backoff, `last_error` sanitized. Job types: `index_document`, `reindex_all`, +`retention_cleanup` (daily, reschedules itself). +Designed so the loop can later move into a separate worker container +(Variant B) without changing any imports. + +**Constraint: exactly one uvicorn worker per deployment.** One process = +one queue loop = one in-process metrics registry. Scaling beyond this is +the Variant B worker split, NOT more uvicorn workers (the customer compose +pins `--workers 1`). + +## Frontend structure (`frontend/src/`) + +SvelteKit is pure UI — no database access, no auth logic beyond passing the +session cookie through. `hooks.server.ts` validates the session against +`GET /api/auth/me` and fills `locals.user`; the `(app)` route group redirects +to `/login` without it (sequence: +[`diagrams/auth-sequence.svg`](diagrams/auth-sequence.svg)). Chat state lives in Svelte 5 runes classes. Custom +components on top of Bits UI (headless behavior layer); design tokens in +`app.css` (see CLAUDE.md). API types are generated from FastAPI's OpenAPI +schema (`make types`). + +The shell is a collapsible left sidebar (`lib/nav/Sidebar.svelte`): one +primary action on top, recent conversations in the middle, +documents/admin/account anchored at the bottom. Collapse state is +per-device (`localStorage`). The account entry opens a dialog +(`lib/nav/SettingsDialog.svelte`) rather than a menu — settings hold forms +(password, theme, language), which a menu cannot. + +The conversation list is shared between sidebar and chat page through one +module-scope runes store (`lib/chat/conversations.svelte.ts`). + +### One thing per screen + +Every screen was gone over with two questions: what has to be seen without +looking for it, and what has to be findable without being in the way. The +rules that came out of it, applied everywhere: + +- **One primary action, labelled.** The action a visitor most likely came for + is a button with a word on it. Everything else that has to be reachable goes + into `lib/components/Menu.svelte` — a labelled overflow menu, because a row + of bare icons makes the reader guess. +- **Only exceptions are marked.** A document list where every card says + "published · public · yours" says nothing; the one row that carries an + unanswered question disappears into the pattern. Badges are for the + abnormal: open questions, drafts, archived, built-in help. +- **Metadata is one quiet line, not a stack of rows.** The document page shows + status, who may read it, and when it changed in a single grey row above the + text, with the details a click behind the chip that states them + (`lib/documents/AccessPopover.svelte` — visibility and department sharing in + one place, since they are the same question). +- **Controls that are used monthly hide behind a toggle, and say when they are + on.** The document list is a search field and results; the six ways to narrow + it sit behind "Filter", and any filter that IS active stays visible as a + removable chip, because a quietly filtered list lies about what exists. +- **Unrelated jobs become tabs, not a longer scroll.** `/admin` is four + unrelated jobs (users and departments, templates, endpoints, prompts); as + tabs each is named up front and one click away. +- **A page with nothing to do is a page with something missing.** The landing + page volunteers the drafts nobody published and the checks waiting for this + person (`lib/documents/OpenWork.svelte`); the profile answers "what have I + written?". + +The landing page itself is one input (focused on arrival), the two other +things anyone comes for, and then that open work. + +A conversation lives at **`/chat/[id]`**, resolved in a server load so a +pasted link renders the right thing on first paint. The API is +owner-scoped, so an unknown id and someone else's id both come back 404 and +are indistinguishable from the outside — existence must not leak, same +semantics as the documents API. `/chat` is the empty composer and accepts +`?q=` (ask straight away), consumed once. + +Both routes render the same `lib/chat/ChatView.svelte` — the shell that owns +the route-driven conversation switch, the URL sync and the scroll follow, while +one reply is `lib/chat/AssistantTurn.svelte` (streaming, cited, uncovered, or +answered by a plain search) and the input is `lib/chat/Composer.svelte`. The +chat state +is a **module-level singleton** (`chatState` in `lib/chat/state.svelte.ts`) +rather than per-component. The first message on `/chat` creates the +conversation and moves the URL to `/chat/[id]` via `replaceState` — which +unmounts one page component and mounts another. Per-component state would +take the in-flight stream and the messages already on screen down with it. +Reacting to the route parameter rather than to clicks is also the single +place where switching conversations aborts the previous stream. + +Chat is a split view with ONE right-hand slot for a cited document. Citations +render as labelled badges ("Sources:") that reveal the cited passage on hover +and open the document in the slot on click (`lib/chat/SourceBadge.svelte`, +`lib/chat/DocumentPanel.svelte`); a document whose title repeats as its first +heading is rendered without that heading (`bodyWithoutTitle`), because every +surface already shows the title above the text; below `lg` the panel stacks under the +conversation instead. When retrieval takes the low-confidence path, the answer +is followed by an invitation to capture the missing knowledge; it opens the +template picker, which creates a draft (`POST /api/documents`) and navigates to +the writing editor — the router never touches capture. + +### Writing editor + +Capture is a **writing-first editor**, not a chat panel. It starts at +`/documents/new` — a template picker that lists `GET /api/templates`, creates a +draft (`POST /api/documents {template_id}`) and navigates to the editor (the +landing "capture" chip and the chat no-answer/gap links all point here). The +chat links carry `?conversation=`, which makes the picker +**retrieval-aware**: it calls `POST /api/documents/suggest-similar` and, under +the templates, offers existing documents that match the conversation ("matches +your conversation") so the user can **extend** one instead of starting fresh — +picking a match routes to that document's editor with the same +`?conversation=`. The matching runs over an **LLM topic summary** of the chat +(`app/authoring/context.py::summarize_conversation` → `chat_json`), not the raw +last message, then permission-filtered hybrid retrieval +(`rag/similarity.similar_documents`, help pages excluded). Creating a draft with +that `conversation_id` also stores the topic summary as `meta.context`, which +the refine prompt passes as background so refinement is chat-aware. Refinement +is also **retrieval-aware**: before it runs, the server searches the +permission-filtered knowledge base for related, already-published material the +author may read (`rag/similarity.similar_chunks`, the current document and help +pages excluded, and only once the section carries enough of its own text to +match on) and passes it to the prompt as grounding — so a suggestion stays +consistent with what the company already documented, while the prompt keeps it +a reference rather than a source of new facts. Query mode +uses the same topic summary too, but only as a **fallback** — it searches on the +raw message first and re-searches with a conversation topic summary only when the +raw search is low-confidence and there is earlier context (so a clear question +pays no extra latency; see `modes/query.py::_topic_transcript` and decision D21, +backed by `tests/evals/test_topic_retrieval_eval.py`, 4/4 vs. 1/4). The query +prompt is also structured for **prompt caching** (D22): a byte-identical static +system message, the conversation history, then this turn's excerpts + question +last (`modes/prompts.py::render_context_turn`), so the endpoint reuses the cached +system + history prefix and only reprocesses the volatile excerpts. The editor +lives at +`/documents/[id]/edit` (component +`lib/documents/WritingEditor.svelte`), loads the document in a server load +(same 404 semantics as chat), and is the SAME editor a document's detail page +opens for editing (`can_edit` → the edit button routes to `/edit`). It is built +on **CodeMirror 6** (`@codemirror/state`/`view`/`commands`/`language`/ +`lang-markdown`), styled through the design tokens rather than a CodeMirror +theme. + +- **A single full-width CodeMirror editor** over the Markdown, two-way synced to + the document string. It is not continuously autosaved (that would defeat the + review-before-save diff); instead a `beforeNavigate` guard keeps things tidy on + the way out: an abandoned, never-filled template draft (still only its skeleton) + is **discarded** so empty drafts do not pile up in the list, and an edited but + unsaved draft is **saved** (a draft is private and unindexed, so this is cheap) + so leaving never loses work. +- **Section refinement — shown INLINE at the section you are editing.** A typing + pause (debounced on document change) fires `POST /api/documents/{id}/refine` + through an SSE consumer (`lib/api/refine.ts`, the same `fetch` + + `ReadableStream` + `parseFrame` pattern as `stream.ts`). The suggestion streams + into a **CodeMirror block widget inserted right below the active section** (not + a separate pane), so it appears exactly where the cursor is; its body renders + through the sanitizing `$lib/markdown` renderer (rule 10) and it is aborted the + moment the user resumes typing (`AbortController`, like the chat stop button). + **Accept overwrites that section**: the exact `[start_line, end_line]` from the + server's `section` frame is replaced via a CodeMirror `dispatch`, and the widget + clears. The server owns that boundary; the client mirrors it + (`lib/documents/sections.ts`) only to place the widget. A short cooldown after + accept blocks the next refine until enough new editing has accumulated, and a + suggestion computed against a since-edited region is discarded rather than + applied. +- **Save shows the diff** — the editor's one action sits bottom right where + the writing ends: **Save** opens a `Dialog` with a read-only VSCode-style line + diff via `@codemirror/merge`'s `unifiedMergeView` (original = last saved + state, modified = the buffer), and asks again — save, cancel, or, for a draft, + **save and publish**. There is no autosave, because that look at the diff is + the point: you see what you are about to put your name on and can still back + out. (Leaving the page still persists an unsaved draft rather than losing + work; a never-filled template skeleton is discarded instead.) **The title is + edited in that dialog**, next to the diff: this is the moment you notice the + document is still called "Neuer Ablauf", and a ✨ button fills + the field from `POST /api/documents/{id}/suggest-title` when asked (a model + call on demand, not on every save). `lib/documents/SaveDialog.svelte`. +- **The draft says what state it is in.** Picking a documentation type creates + the document immediately (rule 6: the document IS the state) and leaving an + untouched skeleton deletes it again — both right, and both invisible, which + is what made "the document does not exist yet" confusing. The line under the + editor now says which of the three it is: a new draft that will be discarded + if nothing is written, a draft that exists and is private, or the time of the + last save. +- **A dead endpoint is knocked on once, not every two seconds.** After an + `llm_*` failure the suggestions go quiet, say so in one line, and wait before + trying again (two minutes for unreachable, five for misconfigured, thirty + seconds for busy) — with a "try again now" for the writer who knows the + endpoint is back. A successful suggestion clears it. Writing is untouched + either way: the assistant is the optional half of this editor. +- **The editor edits title and text, nothing else.** Who may READ a document is + a property of the document as it stands, not of the text being typed, so + visibility lives on the document page next to the department sharing it + belongs with (`lib/documents/AccessControls.svelte`) — and both are the + owner's decision, unlike the text itself. +- **Publishing is the author's own action.** One click, no queue: from the + save dialog, from the draft card on the document page, or from the drafts + card on the landing page. A reward modal (a check animation) confirms the + knowledge is captured, and offers "view it" or **"have it checked"**. +- **Review requests** — "please check this", addressed to one colleague, + optionally about something specific ("do the 14 holiday days still hold?"). + Orthogonal to the status: it can sit on a draft the author is unsure about or + on a document published months ago, and it marks the document everywhere it + appears — the list card, the detail page, and the sources under a chat answer + (`review_pending`, warning-marked in `SourceBadge`, `ContextInspector`, + `FallbackResults` and the document panel). The author picks from + `GET /api/documents/{id}/reviewers` (candidates who can read the document, id + + name only) and calls `POST /api/documents/{id}/reviews` + (`lib/documents/ReviewRequestForm.svelte`, shared by the detail page and the + publish reward). Being asked grants read AND edit until it is answered, so + the colleague can fix a wrong number instead of filing a second question; + they answer with **"that's correct"** + (`POST /api/documents/{id}/reviews/{review_id}/resolve`), and the author can + close a question that has become moot. The open questions sit above the + document text (`lib/documents/ReviewPanel.svelte`), because a reader has to + see them before trusting it; answered ones stay as a one-line record of who + checked what. A reviewer finds their queue through the `assigned_to_me` + filter — surfaced both as a list pill and, with their own unpublished drafts, + on the landing page (`lib/documents/OpenWork.svelte`, deep-linking to + `/documents?review=1` and `/documents?status=draft`). + +The component set in `lib/components/` is deliberately small: `Button`, +`Input`, `FormField`, `Card`, `Badge`, `Markdown` (the only sanitized +`{@html}` site), plus the Bits UI wrappers `Popover`, `Tooltip`, `Dialog` +and `Tabs`. Tooltip content is plain text by contract, so untrusted strings +(document excerpts, model output) can never become markup. + +## In-product help + +`help/*.md` are the help pages users read inside Pablan (German product +content, YAML frontmatter with `key` and `title`). `app/help_import.py` +upserts them into `documents` on every start, keyed by `key`, flagged +`is_builtin`, published and public, with no author or department. Unchanged +files are skipped; a changed file is rewritten in place and re-indexed, so a +release always ships current help — and Pablan can answer questions about +itself from its own retrieval path. The API refuses to edit or delete them +(409 `builtin_readonly`), because the next deploy would overwrite the edit. + +The **template catalog** is shipped content too, but works the other way +round: `templates/*.yaml` are blueprints that stay on disk until an admin +adds one, and what lands in the `templates` table is then the customer's, +editable and never re-imported (`app/template_catalog.py`). The dividing +line: content describing how *Pablan* works belongs to the product and stays +locked; content describing how *this company* works belongs to the customer. +`docs/authoring-templates.md` records the open questions around both. + +Admins edit templates through a **form builder** (`lib/admin/TemplateBuilder`), +not raw YAML: fields for the persona, per-section heading + hint (add, +remove, reorder), title, language, visibility and model hints. The +skeleton the editor opens with is *derived* from the section headings (one +`## heading` each), so headings and hints never drift apart. The builder +posts the structured config to `POST /api/templates/build` — the frontend +has no YAML library, so it sends the `AuthoringTemplate` rather than +serializing it, and the backend validates the same schema a pasted YAML +goes through. Raw YAML stays one click away as an advanced escape hatch for +existing templates (`PUT /api/templates/{id}`), reachable and reversible +from the builder. + +## EE plugin mechanism + +- Core NEVER imports from `ee/` (enforced by import-linter in CI). +- `ee/backend` is an installable package `pablan_ee` exposing + `register(app, registry)`; activated only if installed AND a license key is + configured. +- `ee/frontend` fills named slots in `lib/ee/registry.ts` via a Vite alias; + without EE the alias points to an empty registry. + +## Growth path + +- **Variant B** (when needed): move the ingestion loop to a worker container; + optionally swap pgvector for Qdrant behind the retrieval interface. +- **Phase 2 — cross-department discovery**: entity/relation extraction during + ingestion into a simple edge table; a nightly job compares new knowledge + against other departments' documents and generates proactive hints. This is + the key market differentiator (see market research summary in README/docs) + but explicitly NOT part of the MVP. diff --git a/docs/authoring-templates.md b/docs/authoring-templates.md new file mode 100644 index 0000000..5e8ef02 --- /dev/null +++ b/docs/authoring-templates.md @@ -0,0 +1,226 @@ +# Authoring templates + +A template is **not** an interview guide. It 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. Templates +are **declarative configuration, not code**: a new document type (a machine +write-up, a decision record, a process) is a new YAML file, not a new +feature. + +`templates/` ships a **catalog of blueprints** that an admin adds from — see +"Catalog and lifecycle" below; the `templates` table holds only what a +customer actually uses, and every row in it is editable. The full template is +stored in `templates.config` (JSONB) and validated on load against +`AuthoringTemplate` (`app/authoring/schema.py`, schema version 1.0). The schema +is versioned (`version` field) and expected to evolve after real use. + +## Format + +```yaml +id: prozess +name: "Ablauf: wie wir das machen" +version: "1.0" +kind: authoring +locale: de +description: > + Ein wiederkehrender Ablauf, Schritt für Schritt — so, dass jemand anderes + ihn allein schafft. + +model: + temperature: 0.4 + min_class_hint: "12b" # UI warning if the endpoint is weaker + +persona: | + Du bist ein präziser Fachredakteur für Arbeitsanweisungen. Du schreibst + sachlich, in vollständigen Sätzen, und machst aus einer Abfolge eine + nummerierte Liste. Zahlen, Fristen, Systemnamen und Zuständigkeiten + behältst du exakt bei und erfindest keine dazu. + +title_template: "Neuer Ablauf" + +skeleton: | + ## Wann das gilt + + ## Schritt für Schritt + + ## Wenn es klemmt + + ## Wer zuständig ist + +sections: + - heading: "Wann das gilt" + hint: > + Der Auslöser: in welcher Situation dieser Ablauf greift, und wo er + nicht gilt. + - heading: "Schritt für Schritt" + hint: > + Die Schritte in ihrer Reihenfolge, jeder als eine Handlung — mit den + Systemen, Formularen und Fristen, die dazugehören. + - heading: "Wenn es klemmt" + hint: > + Die Sonderfälle und die Stellen, an denen es erfahrungsgemäß hakt. + - heading: "Wer zuständig ist" + hint: > + Wer den Ablauf verantwortet und wen man bei Rückfragen anspricht. + +metadata: + visibility: department +``` + +**The headings are the questions a colleague actually asks.** That is the +whole craft in a template: "Wann das gilt / Schritt für Schritt / Wenn es +klemmt" gets filled in, "Worum es geht / Details / Was andere wissen müssen" +does not — it is a blank page wearing a structure. An `skeleton: ""` with no +sections is a legitimate template (`notiz`), and better than headings that ask +for nothing in particular. + +## Fields + +| Field | Meaning | +|---|---| +| `id` | Config id, stable across edits; the catalog and the picker key on it. Not the row UUID. | +| `name` | Human title in the picker. | +| `version` | Revision string, raised by hand when the skeleton or hints change. Read as a string even when the YAML looks like a float (`1.0`). | +| `kind` | Always `authoring`. Replaces the old `mode: interview` — it distinguishes an authoring template from any legacy config still shaped like an interview. | +| `locale` | The language this template's **content** is written in (`de` \| `en` \| null), not a UI string. The picker lists templates in the reader's language first. | +| `description` | One line shown in the picker and catalog. | +| `model.temperature` | Sampling temperature for the refinement call (default `0.4`). | +| `model.min_class_hint` | Advisory model class (e.g. `"12b"`); the UI warns when the configured endpoint looks weaker. | +| `persona` | The **editor voice** — how the refinement model should write. Injected as the system persona for every section of this document. | +| `skeleton` | The Markdown the editor **opens with**: headings the author fills in. This IS the starting content, not a description of it. An empty skeleton yields a blank document. | +| `sections` | Per-heading hints: `[{heading, hint}]`. | +| `title_template` | Draft title, with `{{user.name}}` and `{{date}}` substituted at creation. | +| `metadata.visibility` | Default visibility of the resulting document (`public` \| `department` \| `restricted`), changeable in the editor before publishing. | + +## How a template drives the editor + +1. **Creating a draft.** `POST /api/documents {template_id}` loads the + `AuthoringTemplate`, renders the skeleton (`render_skeleton`, verbatim with + a single trailing newline) and the title (`render_title`, with `{{user.name}}` + / `{{date}}` substituted), and writes a `draft` document authored by the user + (`app/authoring/document.py`). A draft is author-only and never indexed — see + [`data-model.md`](data-model.md). +2. **Writing.** The editor opens on the skeleton. The author writes Markdown + directly; Markdown is the source of truth (rule 1), so there is no derived + render to watch. +3. **Section refinement.** After a typing pause the client asks the model to + mature the section at the cursor (`POST /api/documents/{id}/refine`, SSE). + The whole document travels as prefix/suffix context, but the model + regenerates **only** that section (FIM-style) so a large document is never + re-emitted whole. The prompt (`app/authoring/prompts.py`, natural language — + rule 7) is `persona` + the matching section `hint` + a fixed rule block + ("return only the refined section, keep the heading, invent no facts"). + +**`sections[].heading` must match a skeleton heading exactly.** The server finds +the section the cursor sits in (`app/authoring/sections.py::active_section`, +sharing the heading/fence logic with `rag/chunking.py`), reads the heading text +the section starts with, and looks up its hint by exact string match +(`AuthoringTemplate.hint_for`). A hint whose `heading` does not appear in the +skeleton simply never reaches the model. A section with no hint still gets +refined — persona and the rule block are enough. + +## Language policy + +Shipped template **content** — persona, hints, descriptions, title templates, +skeleton headings — is **German**. Templates are product content for the German +market, exactly like the fixture corpus, NOT UI copy. Schema keys, structure +and section ids stay **English**. File naming is `..yaml` +(`prozess.de.yaml`); a file with no locale suffix belongs to the +default locale, so a customer can drop their own YAML in without learning the +convention. + +## The catalog + +`templates/` ships blueprints for common SME situations. A blueprint is inert: +it has no row in `templates` and cannot be used until an admin adds it. + +| id | Purpose | Visibility of the result | Starter | +|---|---|---|---| +| `notiz` | Anything at all — **no skeleton**, an empty document | department | yes | +| `prozess` | How something is done here, step by step, with what snags | department | yes | +| `stoerung` | What broke, what caused it, what fixed it | department | yes | +| `person` | What someone does and what to ask them about | public | yes | +| `anlage` | A machine: running it, maintaining it, its quirks | department | | +| `entscheidung` | What was decided, why, and what follows | department | | +| `projekt-debrief` | What a project taught, in three questions | department | | + +Two of these deserve their reasoning written down: + +- **`notiz` has no skeleton on purpose.** Three generic headings ("What this + is about / Details / What others need to know") are what a blank page looks + like when it is trying to be helpful, and nobody writes a document that way. + Someone reaching for the open template already knows what they want to say. +- **`person` is written by the person themselves** (the profile page starts + it, `api/account.py PERSONAL_BLUEPRINT`) and is `public`: a directory that + half the company cannot read answers nobody's "who knows about X?". It + replaced an "onboarding" blueprint somebody else was supposed to fill in + FOR a new colleague — nobody does that, and the colleague knows the answers. + +A draft stays private to its author until they publish it, so anything +sensitive can be removed first. + +## Catalog and lifecycle + +**A fresh instance seeds four starter blueprints** — `notiz`, `prozess`, +`stoerung`, `person` (`STARTER_TEMPLATE_IDS` in `app/template_catalog.py`) — +and only while the `templates` table is empty. They are the four occasions on +which anyone actually writes something down: write it down, how we do this, +what broke, who you are. Everything more specific — a machine, a decision, a +project review — is a deliberate add from the catalog, because a picker of ten +options is a picker nobody reads. `seed_starter_templates` is the ONLY automatic +write to the table, and it runs exclusively against an empty table; a startup +upsert would silently discard an admin's edits. + +**Nothing in `templates` is read-only.** A template describes how a company +documents its own knowledge, so the company owns it — including the seeded ones +and every blueprint added later. Admins can edit the YAML, duplicate (fork gets +`-kopie`), and delete; deleting is safe because the catalog can always +supply the blueprint again, and documents created from the template are +independent and survive. + +This is the opposite of the built-in **help documents** (`app/help_import.py`), +which are re-imported on every start and refuse edits (409 `builtin_readonly`). +The rule behind both: content that describes *how Pablan works* belongs to the +product; content that describes *how this company works* belongs to the customer. + +Adding a blueprint twice is refused (409 `already_added`) rather than +overwriting — the second add would silently discard the admin's edits. + +Versioning stays manual: the `version` string in the YAML identifies a revision, +and the editor reminds the admin to raise it when the skeleton or hints change. +There is no version history table — a template is content under the customer's +control, not an audit trail. + +## Validation approach + +Cheap schema validation before any UI work: create a draft from the template, +write a rough section under each heading, and check that refinement matures the +prose without inventing facts, keeps the heading, and answers in the section's +language. A broken blueprint is logged and skipped on load rather than taking +the catalog down (`catalog_invalid`). The refinement prompt is covered by an +eval in `tests/evals`; extend it whenever the prompt or the rule block changes. + +## Open problem: where built-in content lives + +Pablan ships two kinds of content it did not get from the customer: the help +documents and the template catalog. The current split (help documents +re-imported and locked, templates offered and owned) is a decision we are +comfortable defending, but the surrounding questions are open: + +- **How much should a fresh instance start with?** We seed four templates and a + handful of help documents. Should the seed be larger (a demo document set that + shows what a good captured document looks like), configurable at install time, + or nothing at all? +- **Where should the shipped content live?** Today: `templates/` and `help/` as + files in the repo, imported at startup. Files-in-repo is right for two + developers; it is not obviously right once the catalog has thirty entries in + several languages. +- **How does catalog content reach existing instances?** A blueprint improved in + a later release does not reach anyone who already added it, by design. There is + no "update available" signal — a diff view against the current blueprint is the + obvious feature, and it is not built. +- **Should deleting the last template be possible?** It is today. A member who + opens "Capture knowledge" in that state sees an empty picker. + +This is deliberately recorded, not solved. Decide it with a real customer install +in front of us rather than from first principles. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..c65273f --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,289 @@ +# Data model + +All primary keys are UUIDs. All tables get `created_at` / `updated_at` via a +shared mixin (omitted in the ER diagram). Enum-like columns are stored as +`varchar(32)` backed by Python `StrEnum`s — no native Postgres enum types. +Naming rule: chat threads are **conversations**, login sessions are +**auth_sessions** — never mix these up. + +The ER diagram lives in [`diagrams/data-model.svg`](diagrams/data-model.svg) +— one maintained source, updated with every model or migration change. + +## Tables (13) + +(Plus `alembic_version`, Alembic's migration bookkeeping table.) + +### departments +`id, name (unique)` + +### users +`id, email (unique), name, role (member|admin), password_hash (argon2), +locale (varchar(5), nullable), department_id → departments` +A person is described by a DOCUMENT, not by a profile column: the one started +from the person blueprint (`GET /api/account/document`). It is versioned, +approvable and findable by the search, which a text column on `users` was not. +`locale` pins the interface language (`de` | `en`); NULL follows the +browser's `Accept-Language`. One column rather than a preferences table: it +is the only preference that has to follow the person across devices. The +colour theme deliberately does NOT live here — it is per-device and sits in +the browser's `localStorage`. +`department_id` is nullable (`ON DELETE SET NULL`). + +### auth_sessions +`id, user_id → users, expires_at, created_at` +Server-side sessions; the row id doubles as the httpOnly cookie token +(UUIDv4). Sessions expire after 14 days (`PABLAN_AUTH_SESSION_TTL_DAYS`) and +cascade when the user is deleted. Instantly revocable (deleting rows logs the +user out everywhere) — important because "employee leaves the company" is +literally our core use case. No JWT. + +### templates +`id, name, version, config (jsonb)` +`config` holds an authoring template — a Markdown skeleton plus per-section +hints (see `authoring-templates.md`). Every row is the customer's own and +fully editable — there is no read-only template. The YAML files in +`templates/` are a *catalog* of blueprints that an admin adds from; a +blueprint has no row until it is added, and a fresh instance is seeded with +four starter templates. + +### conversations +`id, mode (query|insight), status (active|completed|abandoned), +user_id → users` +One generic table for all modes. The only core mode is `query` (RAG Q&A); EE +adds `insight`. Adding a mode requires no migration. **Capture is no longer a +conversation** — it writes a Document directly (see `app/authoring/`), so this +table holds no per-turn engine state, template link, or result-document link +any more (the `state`, `template_id` and `result_document_id` columns were +dropped). Deleting a user cascades to their conversations. + +### messages +`id, conversation_id → conversations, role (user|assistant|system), +content (text), meta (jsonb), created_at` +Full history is stored: state reconstruction after restarts, debugging/eval +material, and (aggregated only) knowledge-gap reporting. Messages cascade with +their conversation. Privacy rules below. + +`meta` holds an assistant turn's citation snapshot +(`{"sources": [{document_id, title, heading_path, excerpt, used}]}`, `{}` for +user turns). Chunks are disposable derivatives that re-indexing replaces, +so the rendered citation is stored with the message rather than referenced +— reopening an old conversation still shows what was cited. `used` marks +whether a passage grounded the answer or was only retrieved and dropped as too +weak (a no-answer turn); the "?" context inspector shows the full retrieved set, +the cited-source badges only the `used` ones. + +### documents +`id, title, status (draft|published|archived), is_builtin (bool), +visibility (public|department|restricted), content_md (text), meta (jsonb), +author_id → users, department_id → departments` + +**Markdown is the source of truth.** `content_md` holds the canonical +document; everything else (chunks, embeddings) is derived and disposable. +`meta` holds the summary, extracted entities, and — for a captured +document — the `template` config id it was created from, plus (when the +capture started from a chat) the LLM topic summary of that conversation as +`context` and its `conversation_id`, used as background for section refinement. + +**Capture writes here directly.** A captured document is authored by the +user, starting in `draft` (author-only, never indexed), and becoming +`published` when its author says so — one action, no queue. There is no +`source_type`: everything in the table was written in Pablan. The column and +its `upload` member went with the tags removal — the day an import lands, a +provenance column earns its place again, and can be reintroduced then. + +**Three statuses, and only three.** `draft` is being written, `published` is +readable and searchable, `archived` is retired. Doubt about the CONTENT is +deliberately not a status: it is a `review_request` (below), because a +document can be published for months and still carry an unanswered question +about a number in it — which is exactly when readers need to be told. + +**No tags, and no freshness date.** Both were removed rather than kept +around: tags were write-only (free text, no way to know which ones existed, +too inconsistent to filter by, and hybrid search already finds things), and +`verified_until` marked documents without ever asking a person anything. A +review request does that job — it names someone, carries a question, and can +sit on a document published months ago. Treat a UI that displays or edits +tags, or a date that flags a document as stale, as a regression. + +`author_id` and `department_id` are nullable with `ON DELETE SET NULL` — +documents survive their author leaving the company. + +`is_builtin` marks the shipped help pages that describe Pablan itself +(source: `help/*.md`, re-imported on every start). They are published, +public and authorless, and the API refuses to edit or delete them +(409 `builtin_readonly`) — the next deploy would overwrite the change. + +### chunks +`id, document_id → documents, chunk_index (int), content (text), +embedding (vector(1024) — bge-m3; dimension change = migration + reindex), +tsv (tsvector, stored generated column, german config over the content AND +the heading path), meta (jsonb)` + +Chunks are slices of a document (~400 tokens, split along Markdown heading +hierarchy; each chunk stores its heading path in `meta`, e.g. +"Ablauf: Reklamation › Schritt für Schritt", included as context in prompts and +citations). The heading path is not decoration: it is embedded WITH the chunk +(`rag/indexing.embedding_text`) and included in `tsv`, because a section saying +"Solldruck 180 bar" never repeats which machine it belongs to and is otherwise +unreachable by the name the asker uses. What is STORED as `content` stays the +raw section. `meta` also carries denormalized filter fields (department_id, +visibility) so retrieval doesn't need triple joins. Chunks are deleted +and regenerated whenever the document or the embedding model changes, and +cascade on document deletion. +Indexes: HNSW on `embedding` (cosine), GIN on `tsv`; +`(document_id, chunk_index)` is unique. + +### doc_permissions +`document_id → documents, department_id → departments, level (read)` +Additional department read grants on top of `documents.visibility` — the +mechanism behind **multi-department sharing** (a document reaches departments +beyond its owning one). Managed through `PUT /api/documents/{id}/departments` +(author/admin), which replaces the full set of extra departments; the permission +filter's `EXISTS doc_permission` branch already unions them into reads and +retrieval, so sharing is management, not new permission logic. Composite PK +(document_id, department_id); both FKs `CASCADE`. Note the asymmetry with +`documents.department_id`, which is `SET NULL`: deleting a department **orphans** +its owned documents (they keep existing, department set NULL) but **drops** its +grant rows here, silently removing that department's shared access — an admin +lockout vector to weigh before deleting a department. + +### review_requests +`id, document_id → documents, requester_id → users, reviewer_id → users, +question (text, nullable), resolved_at (timestamptz, nullable), +resolved_by_id → users, created_at, updated_at` + +"Please check this", addressed to one colleague, optionally about something +specific ("do the 14 holiday days still hold?"). Open while `resolved_at` is +NULL; the pair (`resolved_at`, `resolved_by_id`) is the answer. All three user +FKs are `ON DELETE SET NULL` (a request outlives the accounts on either side of +it); rows `CASCADE` with their document. + +It is deliberately **not** a status, and it is orthogonal to one: a request can +sit on a draft the author is unsure about OR on a document published months +ago. An open request marks the document wherever it appears — the list, the +detail page, and the sources under a chat answer (`review_pending` on every +search hit). + +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. The grant is +scoped to the open request and disappears with the answer — for a draft that +also means the reviewer's read access ends there. The relationship is loaded +with every document (`lazy="selectin"`), because whether a question is open +decides both who may edit and how the document is marked. + +### document_events +`id, document_id → documents, actor_id → users, action, content_md, +title, visibility, meta, created_at` +An append-only audit trail: who changed or reviewed a document, and when. +`action` is one of `created | edited | published | archived | +visibility_changed | review_requested | review_resolved`. Content-bearing events (`created`, `edited`) +snapshot the Markdown source of truth (`content_md`, `title`, `meta`) so a past +version can be viewed or diffed — the disposable chunks are never snapshotted +(rule 1); pure transitions carry no content snapshot. `visibility` is small, so +it is recorded on every event. `actor_id` is `ON DELETE SET NULL` so the trail +outlives the actor's account (like `author_id`); events `CASCADE` with their +document. + +This is where **"who checked it"** lives: `review_resolved` records the +colleague who answered as `actor_id`, next to the `review_requested` that asked +— the two events are the record that someone with the knowledge looked at this, +which the `review_requests` row alone (one open flag) does not preserve once it +is answered. History is read through the document's own permission gate +(`GET /api/documents/{id}/history`), so it never leaks to a user who cannot read +the document. + +### llm_settings +`id, role (unique), base_url, model, api_key, created_at, updated_at` +Per-role endpoint overrides edited in the admin UI; at most three rows. +NULL means "inherit from the environment" (precedence env < DB, per +field — see `architecture.md`). `api_key` is plaintext because it is +replayed to the endpoint; it is never returned by the API and never +logged. + +### prompt_settings +`id, key (unique), content, created_at, updated_at` +Admin overrides for shipped system prompts. Every prompt has a CODE default +(`app/prompts/defaults.py`); a row exists here only when an admin has changed +one, and `content` is the full replacement. Applied without a restart via a +module cache (`app/prompts/overrides.py`), refreshed on every write — like +`llm_settings`, and single-process by design (`--workers 1`). Resetting a prompt +deletes its row so the code default takes over. Unlike LLM settings there is no +`.env` layer: prompts have no environment representation, so the reset target is +the code default. Keys: `query_system`, `query_no_sources`, `refine_persona`, +`refine_rules`, `grounding_framing`, `topic_summary`, `title`, `bio_persona`, +`bio_rules`. + +### jobs +`id, type, payload (jsonb), status (pending|running|done|failed), +run_after, attempts, last_error` +Postgres-backed background queue, claimed via `FOR UPDATE SKIP LOCKED`. +Indexed on `(status, run_after)` for the claim query. + +## Permission model + +Two layers: + +1. `documents.visibility` as the fast default: + - `public` — whole company + - `department` — author's department only + - `restricted` — only explicit grants +2. `doc_permissions` for additional department grants. + +Authors always see their own documents: any status through the API (writing +needs drafts), published-only in retrieval — unpublished documents are never +searchable, not even for their author. A colleague with an **open review +request** sees the document the same way: an open-request clause in +`readable_documents_filter` but never in `searchable_documents_filter`, so +being asked to check a draft reaches the API without ever leaking into +chat/search. That clause is also the edit gate (`can_edit` = author, admin, or +open reviewer): whoever is asked may fix what they find, until they answer. + +**Self-lockout guard.** Before a visibility or grant change is committed, the +proposed state is evaluated against the read rules (`_guard_self_lockout`, a +Python mirror of `readable_documents_filter`). An author always keeps access as +author, so this only bites an **admin** editing a document they do not own: the +change is refused (409 `self_lockout_warning`) until they resend it with +`confirm_lockout`, after which they may knowingly give up their own access. + +**The filter runs BEFORE the LLM ever sees anything.** Retrieval +(`rag/retrieval.search`) requires a `user` argument and applies the +visibility CTE (visibility rules + doc_permissions + `status = published`) +before vector/full-text search. It is structurally impossible to retrieve a +chunk the requesting user may not read. The filter is shared with the +documents API (`rag/permissions.py`) and always reads the live documents +table — never the denormalized copies in chunk meta, which can be stale +between an edit and the next reindex. + +## Retrieval (hybrid) + +Sequence: [`diagrams/retrieval-sequence.svg`](diagrams/retrieval-sequence.svg) + +One SQL query: visibility CTE → in parallel (a) vector top-20 via +`embedding <=> :qvec` (HNSW) and (b) full-text top-20 via +`tsv @@ websearch_to_tsquery('german', :q)` → merged with Reciprocal Rank +Fusion → top-k. Rationale: pure vector search underperforms on German +technical terms, product names, and error codes; hybrid is the default. + +**The full-text half also works alone.** `chunks.tsv` is a stored generated +column (`to_tsvector('german', content || ' ' || heading path)`) behind a GIN +index — an inverted +index that needs no model at all. `text_search()` uses exactly that, with the +same permission CTE, and query mode falls back to it when the embedding +endpoint is unreachable. Keyword matching finds less than the hybrid path, so +the user is told (`fallback` in `api-protocol.md`), but the knowledge base +stays searchable when nothing is running behind the LLM config. + +## Privacy / GDPR defaults + +- Deleting a user (offboarding) deletes their conversations and messages + via `ON DELETE CASCADE` — the transcript is personal data of the departed + employee; the published document is the legitimate artifact and survives + authorless (`author_id` SET NULL). +- Users can delete their own conversations. +- Configurable retention: query-mode conversations are auto-deleted after a + default of 90 days (`retention_cleanup` job). +- A captured document is private until its author publishes it + (`draft → published`, the author's own action) — nothing enters the + knowledge base without the writer's consent. +- Insights features (EE) only ever receive aggregated, anonymized data — + never per-user raw messages. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..8c5faa5 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,311 @@ +# Decision log + +Short ADR-style records. Context for all: team of 2 developers + 1 +legal/marketing co-founder; product must be self-hostable by SME IT admins; +bias toward boring, simple, understandable technology. + +## D1 — PostgreSQL + pgvector, no dedicated vector DB +One database for relational data, documents, vectors, full-text, jobs and +sessions. `docker compose up` starts 3 containers; nothing extra to operate, +patch and back up. HNSW handles our scale (SMEs, hundreds of thousands of +chunks) easily. Retrieval is capsuled behind `rag/retrieval.py`, so swapping +in Qdrant later (Variant B) is a contained refactor. Bonus: Postgres +full-text (`german` config) enables hybrid search with zero extra +infrastructure. + +## D2 — SQLAlchemy 2.0 + Alembic, not Prisma +Prisma is TypeScript-first; the DB belongs to the FastAPI backend (the +Python client is a community project — not production-grade). Prisma treats +pgvector as `Unsupported("vector")`, forcing raw SQL anyway. The official +pgvector package ships a first-class SQLAlchemy type. + +## D3 — No LangChain / LlamaIndex +Thin custom LLM client (3 functions) + our own mode engine. For a focused +product the framework abstraction costs more than it saves; prompts and +retrieval are our core competence and must stay fully controllable. + +## D4 — OpenAI-compatible API as the only LLM contract +llama.cpp server, vLLM, Ollama and all major clouds speak it. Config is just +base_url/api_key/model — per model role (chat / utility / embedding). +The serving layer applies each model's chat template; we never hand-build +prompt formats. + +## D5 — Server-side sessions, no JWT; auth in FastAPI, not Auth.js +Auth.js would put identity in the SvelteKit server while enforcement +(document permissions, retrieval filtering) lives in FastAPI — two systems +to keep in sync. Server-side sessions are instantly revocable ("employee +leaves" is our core use case), trivial to reason about, and have zero +drawbacks for single-backend self-hosted deployments. + +## D6 — Sessions naming +`conversations` (chat threads) vs `auth_sessions` (logins). Never "session" +unqualified. + +## D7 — Background jobs via Postgres table, no Redis/Celery +`jobs` table + asyncio loop with `FOR UPDATE SKIP LOCKED`. ~80 lines, +survives restarts, built-in retry, no new infrastructure for customers. +Moves unchanged into a worker container when scale demands it. + +## D8 — SvelteKit is pure UI +All `/api` traffic goes through the reverse proxy directly to FastAPI (same +origin: no CORS, cookies and SSE just work, no stream buffering through +Node). SvelteKit renders pages and gates routes via `hooks.server.ts`. + +## D9 — Own components on Bits UI, no component library imports +Bits UI provides the hard invisible parts (focus traps, keyboard +conventions, ARIA state, portal/positioning, outside-click) as a headless +layer; we own 100% of markup and styling. shadcn-svelte was considered (it +copies source rather than importing) but we skip its styling layer entirely. +Discipline required: small base component set, everything from design +tokens. + +## D10 — Caddy as the only supported reverse proxy (for now) +Argument is config ergonomics for foreign IT admins (5-line Caddyfile, +automatic TLS) — not capability. The app is proxy-agnostic (one origin, +`/api/*` to the backend, rest to the frontend), so other proxies can be +added to `deploy/` when a customer actually needs one; until then we +maintain exactly one config. + +## D11 — uv + ruff (lint AND format), pre-commit, Makefile as task runner +One Python toolchain, no Black. `make` because it is universally installed +and gives CI and both developers identical commands. + +## D12 — Markdown as canonical knowledge format +Human-readable, versionable, exportable — no data prison (sales argument). +Chunks/embeddings are disposable derivatives; re-indexing from documents +must always be possible (e.g. embedding model switch). + +## D13 — Step-wise prompt injection for interviews (SUPERSEDED by D19) +Only the current interview point enters the prompt; a separate `chat_json` +bookkeeper call tracks the checklist. Keeps Gemma-class 12B models reliable +and shrinks the local-vs-cloud quality gap. **Superseded:** capture is no +longer a dialog — the interview engine and bookkeeper were removed. The +successor keeps small models fast a different way: FIM section refinement (D19). + +## D14 — Fair Source (FSL) + proprietary `ee/` +See `licensing.md`. + +## D15 — Privacy defaults are product features +Approval-gated publishing, user-deletable conversations, retention job, +aggregation-only insights. Designed for the German works-council/GDPR +conversation from day one — retrofitting this would be painful; leading with +it is a sales argument. + +## D16 — Hybrid dev stack: Postgres in Docker, app processes native +`make dev` starts only Postgres via `docker-compose.dev.yml` (own compose +project name `pablan-dev`) and runs uvicorn `--reload` and vite natively on +the host — fast reloads, IDE-friendly `.venv`/`node_modules`, no bind-mount +quirks. The fully containerized `docker-compose.yml` is the customer +deployment and doubles as the pre-release check (`docker compose up -d +--build` with `PABLAN_DOMAIN=localhost`); separate compose project names let +both run on one machine. + +## D17 — Display rename "Guided documentation" without id migration (SUPERSEDED by D19) +The feature formerly presented as "Interview" was renamed **"Guided +documentation"** in user-facing copy (M8), keeping the `interview` mode id and +module. **Superseded:** the interview mode itself was removed — capture is now +a writing editor (D19), so there is no interview engine to name. + +## D18 — LLM endpoint configuration in the DB, env as fallback +Endpoints move during a pilot (local llama.cpp → cloud, or a new model), +and asking a customer to edit `.env` and restart for that is a support +call. Admins now override `base_url` / `model` / `api_key` per role in the +UI; the environment stays the bootstrap and the per-field fallback, so a +fresh deployment works with no database rows at all. Changes apply without +a restart by rebuilding the cached clients. The key is stored in plaintext +because it must be replayed to the endpoint — it is never returned by the +API and never logged, and the database is the customer's own. + +## D19 — Capture inverted to a writing-first editor +Capture was a dialog: an interviewer asked, a bookkeeper extracted notes, and +the document was a render of those notes — the user never wrote it. That reads +like an interrogation and fights the reality that someone *has* something to +write down. Markdown is already the source of truth (D12/rule 1), so the user +should edit that source directly, not watch a derived render. Capture is now a +**writing editor**: the user writes Markdown into a `Document` (`app/authoring/`), +and after a typing pause the model matures only the section at the cursor. +Consequences that make this cheap and safe on small local models: +- **Drafts are author-only and never indexed.** A captured document starts as + a `draft`, private to its author and outside retrieval, so it can never reach + another user or an LLM prompt (rule 2) before its author publishes it. +- **FIM section refinement.** The whole document is prefix/suffix context but + the model regenerates ONLY the active section, so a large document is never + re-emitted whole. With the reasoning model's thinking disabled + (`enable_thinking = false`) the call returns in ~1s — fast enough for a + "after pause" trigger and reliable on Gemma-class 12B. +- **One editor for capture and edit, with a pre-save diff.** The same editor + writes new drafts and revises existing documents, always showing a VSCode-style + line diff before saving. +- **CodeMirror 6 + `@codemirror/merge` as a deliberate dependency.** CodeMirror + is an editor/diff engine (like marked/dompurify), not a UI component library, + so it is compatible with D9; `@codemirror/merge`'s `unifiedMergeView` provides + the pre-save diff, which is why no separate diff library (jsdiff) is pulled in. + +This removed the interview mode, bookkeeper, checklist, the `Draft`/`Navigable` +protocols, and the conversation capture endpoints; `query` is the only core +mode left. See `architecture.md` and `authoring-templates.md`. + +## D20 — Reviewer access as a read clause, not a new permission layer +An author can ask a colleague to check a document, and that colleague must be +able to read it even while it is an unpublished draft — without anything +leaking into chat/search. So the grant is a single clause (an open +`review_requests` row addressed to the user) added to +`readable_documents_filter` **only** — never to `searchable_documents_filter` — +reusing the existing filter split rather than introducing a status or a grant +table. Reviewer candidates are derived department-granular from the document's +would-be read set (author + public/department/`doc_permissions`) via a +dedicated non-admin, permission-safe query returning id + name only — not by +reusing `/admin/users`. (Superseded in part by D24: the clause is now the open +request, not a `documents.reviewer_id` column, and being asked also grants the +right to EDIT.) + +## D21 — Topic-summary retrieval, in the capture picker only (for now) +When a capture starts from a chat, the picker suggests existing documents to +extend. Retrieving over the raw last chat message is unreliable (the last turn is +often a topic-less follow-up like "thanks"), so the match runs over a short **LLM +topic summary** of the conversation (`chat_json`, utility) instead. An eval +(`tests/evals/test_topic_retrieval_eval.py`) compares topic-summary vs. +raw-message recall on a fixture; measured 4/4 vs. 1/4. On that evidence, query +mode now uses it too, but only as a **fallback**: it searches on the raw message +first and re-searches with a topic summary of the conversation ONLY when the raw +search is low-confidence and there is earlier context (`query.py::_topic_transcript` +returns "" for a first message). So a clear question pays no extra latency and a +first-message miss stays a genuine no-answer, while a topic-losing follow-up +recovers the subject. The same topic summary is stored as `meta.context` on a +chat-originated draft and passed to the refine prompt as background, so refinement +is chat-aware without re-summarizing per call. + +## D22 — Cache-friendly query prompt: static system, volatile excerpts last +Local llama.cpp and cloud OpenAI-compatible endpoints both reuse a request's +longest identical prefix (prompt/KV caching). The old query prompt injected the +per-question excerpts INTO the system message, so everything after the static +rules — including the conversation history — was reprocessed every turn. The +message order is now `[static system rules] [conversation history] [this turn's +excerpts + question]` (`modes/prompts.py::render_context_turn`): the system rules +are byte-identical for every question (cached across all conversations and users) +and the history is byte-identical across a conversation's turns (cached within +it), so only the volatile excerpts + question are new work. The excerpts are +inherently per-question and never cacheable; keeping them out of the shared +prefix is what makes the rest cacheable. No endpoint config is needed — the win +is purely in prompt structure. + + +## D23 — API modules are packages cut by caller intent, with the gate on the router +`api/documents.py` had grown to 1137 lines and `api/admin.py` to 615, which is +what happens when "one module per resource" meets a resource with five different +jobs. They are packages now, cut by what a caller is DOING — browse, the life of +one document, its audit trail, the publish/review workflow, sharing — rather +than by HTTP verb or by schema-vs-route. Two consequences are the actual point: + +- **Shared rules exist once, and are stated.** `documents/access.py` holds every + gate (including the Python mirror of the read filter used to judge a change + before it commits) and `documents/view.py` every response shape. Modules that + used to reach into another module's private helpers (`_readable_document`, + `_role_config`, `_excerpt`, `_HEADING_RE`) now import a named interface. +- **A dependency belongs on the router, not on each endpoint.** Each package has + a `routing.py` that builds its `APIRouter`; `admin` puts `require_admin` + there, and `templates` has two constructors because reading is for everyone + and writing is not. A new endpoint is gated by the router it is added to, + which is not something an author can forget. + +The cost is that route order becomes explicit: FastAPI matches in registration +order, so the module with static paths is included before the one with +`/{id}`. That is stated in each package's `__init__` rather than discovered. + + + +## D24 — Review is a question about a document, not a status of it +Publishing used to run through an approval queue: `draft → pending_approval → +published`, with a delegated reviewer who could approve but not edit. Two things +were wrong with it. It made every author wait for someone else before their +knowledge was findable at all — the opposite of "writing it down must be +cheap" — and it could only express doubt BEFORE publishing, while the doubt that +actually matters ("do the 14 holiday days still hold?") turns up on a document +that has been published for months. + +So the two ideas were separated: + +- **Status says where a document stands**: `draft` (being written, author-only, + unindexed), `published`, `archived`. Publishing is the author's own one-click + action; `pending_approval` is gone. +- **A `ReviewRequest` says what is unsettled about its CONTENT** — one question, + addressed to one colleague, open until answered. It can sit on a draft or on a + document published months ago, and it is orthogonal to the status. + +Consequences that make it work: + +- **An open request marks the document everywhere**, including the sources under + a chat answer (`review_pending` on every search hit, snapshotted with the + citation). A reader who is handed an answer sees that the passage behind it is + not confirmed — which is the case the whole feature exists for. +- **Being asked grants the right to edit** (`can_edit` = author, admin, or open + reviewer), not just to read: a reviewer who spots a wrong number should fix it + rather than file a second question about it. The grant ends with the answer, + and for a draft the read access ends with it too. The owner-only decisions + (delete, sharing, handing out a request) stay with the author or an admin. +- **The audit trail keeps who checked what**: `review_requested` / + `review_resolved` events, with the answering colleague as `actor_id`. The + `review_requests` row alone would only tell you that nothing is open. + +The migration maps `pending_approval` → `draft`, `approved` events → `published`, +drops `submitted` events and `documents.reviewer_id`. + + + +## D25 — Three fields removed rather than kept "for later" +`documents.verified_until`, `meta.tags` and `documents.source_type` all +existed, were all written on every publish, and none of them did anything. +They were removed together. + +- **`verified_until`** was a freshness horizon: 180 days after publishing, the + UI put "Verifizierung fällig" on the document and offered a Re-verify button. + It marked documents and reminded nobody — no mail, no queue, no name attached + — so the badge said only "time has passed", which the `updated_at` in the + same line already said. A `ReviewRequest` (D24) is the thing that actually + asks: it names a person, carries a question, and can sit on a document + published months ago. +- **`tags`** had been write-only since M8: free text with no way to see which + tags already existed, so the values that arrived were too inconsistent to + filter by, and hybrid search covers finding things anyway. Keeping the column + "because the data is real" only kept the template form asking for it. +- **`source_type`** had two members and one of them (`upload`) was unreachable: + nothing can be uploaded. Everything in the table was written in Pablan, so + the column classified nothing. + +The general rule this records: a field nothing reads is not neutral. It costs a +form row, a table column, a line in the export frontmatter, an entry in the ER +diagram, and a paragraph of documentation explaining why it is there. Import +(roadmap epic 19) will want provenance, and can reintroduce a column that +distinguishes values it actually produces. + + + +## D26 — The queue in front of the endpoint is ours, not the server's +A self-hosted llama.cpp server takes every request it is offered and serves +`--parallel` of them at a time; the rest wait inside it. That is a queue Pablan +can neither see, bound, nor explain — and every request waiting in it is still +burning the client's HTTP timeout. Two colleagues chatting while a reindex runs +is enough to turn "slow" into "everything times out at once". + +So the waiting happens in `app/llm/gate.py`, in front of the endpoint, and it +is bounded twice: a caller waits at most `PABLAN_LLM_QUEUE_WAIT_SECONDS` for a +slot, and past `PABLAN_LLM_MAX_QUEUED` waiters the gate stops admitting at all. +An unbounded wait would be the same failure as no gate, just quieter. + +Three details that make it work: + +- **Keyed by base_url, not by role.** chat and utility point at the same server + in the shipped configuration; a per-role limit would let one server be given + twice its slots. +- **Both refusals are `llm_busy`.** A queue timeout and a provider's 429 mean + the same thing to the person waiting, so they get the same sentence. +- **A stream holds its slot to the last token**, because that is how long it + occupies the server's slot. + +What it buys beyond fairness: a saturated EMBEDDING endpoint now degrades query +mode to full-text retrieval (that path already existed for an unreachable +endpoint) instead of failing, and the chat can say "the model is busy" as a +`queued` phase rather than blinking a cursor for twenty seconds. + +`probe()` and `list_models()` stay outside the gate: an admin has to be able to +test an endpoint precisely when it is saturated. diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md new file mode 100644 index 0000000..28a80e2 --- /dev/null +++ b/docs/diagrams/README.md @@ -0,0 +1,24 @@ +# Architecture diagrams + +Hand-authored SVG, one per subject. The SVG **is** the artifact: no diagram +toolchain, no build step, and it renders anywhere a browser or an IDE preview +does. Each file carries its own stylesheet and follows the reader's light or +dark mode through `prefers-color-scheme`. + +**Maintenance rule:** diagrams are as-is documentation like the rest of +`docs/`. When a change lands that affects them, the diagram is edited in the +same change: a stale diagram is a bug. Where docs and code disagree, the code +wins and the doc gets fixed in the same commit. + +**Editing:** every box is a `` plus its `` lines at explicit +coordinates on a plain grid, so a label change is a text edit and a new box is +a copied block with new numbers. Keep the shared ` + + + + + + + + + + + + + + Login and every page request + Server-side sessions: the cookie token IS the auth_sessions row id. + + Browser + + + SvelteKit server + + + FastAPI + + + Postgres + + + + Login: the browser talks to FastAPI directly (D8) + POST /api/auth/login {email, password} + + SELECT user by email + + + Unknown email still burns a dummy argon2 verify, + so timing reveals nothing about who exists. + 401 {detail, code: invalid_credentials} + + INSERT auth_sessions + (id = cookie token, expires_at = now + 14d) + + 200 user + Set-Cookie pablan_session + (httpOnly, SameSite=Lax, Secure per PABLAN_COOKIE_SECURE) + + + + Every page request: handle = sequence(auth, i18n) + GET /some-page (Cookie: pablan_session, Accept-Language) + + GET /api/auth/me (absolute URL, cookie forwarded) + + + The base URL must be absolute: a relative server-side + fetch never reaches the proxy. + SELECT auth_sessions + user, check expires_at + + 200 user (incl. locale) + + + Auth runs FIRST and stashes user.locale in a WeakMap keyed by the + Request: the locale strategy only receives the request, and a second + /me would ask a question we already asked. + + i18n: paraglideMiddleware resolves userPreference > cookie > + Accept-Language > base, then stamps %lang% / %dir% into the document. + render with locals.user, in the resolved locale + + 401 {code: not_authenticated} + + + No user, so the locale falls back to the cookie or Accept-Language. + 303 redirect to /login + + + + Ending a session + + Logout deletes the session row: revoked everywhere, instantly. + + Password change (POST /api/account/password): verify the current + hash, store the new one, then DELETE auth_sessions WHERE + user_id = me AND id <> my session. Other devices are logged out, + this one keeps its cookie. + + + alt + unknown email or wrong password + + + else + credentials valid + + + alt + session valid + + + else + missing, expired or invalid + diff --git a/docs/diagrams/components.svg b/docs/diagrams/components.svg new file mode 100644 index 0000000..9931ee8 --- /dev/null +++ b/docs/diagrams/components.svg @@ -0,0 +1,179 @@ + + Pablan components + + + + + + + + + + + + + + + + Components + One deployment: browser, one proxy, two app processes, one database, and whatever speaks the OpenAI API. + + Browser + + Reverse proxy: Caddy + /api/* to the backend, everything else to the frontend; TLS for PABLAN_DOMAIN + + + + + + SvelteKit frontend (no DB, no auth logic) + + hooks.server.ts + sequence(auth gate, paraglide i18n); cookie passthrough + + typed API client + openapi-fetch over generated types, never hand-written + + chat + /chat, /chat/[id], + one ChatView, SSE + + writing editor + documents/[id]/edit, + CodeMirror + refine SSE + + documents, people, + admin, account + + + paraglide messages + de source, en in sync; + app.css design tokens + + + FastAPI backend (single worker: queue and metrics are per process) + + api/ routers + auth, account, people, documents, templates, admin, conversations (SSE), authoring (SSE) + + auth/ + argon2, + auth_sessions + + modes/ + registry, query; + yields ModeEvents + + authoring/ + template schema, + section refinement + + ingestion/ + jobs queue + + handlers + + rag/ + chunking, indexing, permissions, retrieval (hybrid + text-only + similarity) + + llm/client.py + the ONLY caller of an endpoint: chat_stream, chat_json, embed, probe + + llm/overrides.py + env bootstrap, + then the DB wins + + config, log, metrics + content never reaches a log line + + ee_hooks.py + optional pablan_ee import; core never imports ee/ + + + + + + + + + + + /api/auth/me + + same origin + + PostgreSQL + pgvector + documents (Markdown, the source of truth), chunks (embedding + tsvector), + auth_sessions, conversations, jobs, llm_settings, templates + + OpenAI-compatible endpoints + three roles, each its own base_url + key + model: + chat, utility, embedding (bge-m3). llama.cpp locally, any cloud API in production + + every table + + llm/client.py only + + templates/ + blueprint catalog, de + en; + inert until an admin adds one + + help/ + built-in help pages, German; + re-imported on every start + + + diff --git a/docs/diagrams/data-model.svg b/docs/diagrams/data-model.svg new file mode 100644 index 0000000..bb84bde --- /dev/null +++ b/docs/diagrams/data-model.svg @@ -0,0 +1,381 @@ + + Pablan data model + + + + + + + + + + + + + + + + + + + + + + + Data model + Markdown in documents.content_md is the source of truth; chunks are a disposable derivative. + + employs + + logs in + + starts + + contains + + grants + + audited by + + derived into + + owns + + granted to + + authors + + acted + + is asked / answers + + is questioned by + + + departments + id + uuid + PK + name + string + unique + + + users + id + uuid + PK + email + string + unique + name + string + role + string + member | admin + locale + varchar + de | en, null = browser + password_hash + string + argon2 + department_id + uuid + FK, null, SET NULL + + + auth_sessions + id + uuid + PK, = cookie token + user_id + uuid + FK, CASCADE + expires_at + timestamp + TTL 14d, configurable + + + conversations + id + uuid + PK + mode + string + query | insight + status + string + active | completed + user_id + uuid + FK, CASCADE + + + messages + id + uuid + PK + conversation_id + uuid + FK, CASCADE + role + string + user/assistant/system + content + text + meta + jsonb + citations, fallback code + + + documents + id + uuid + PK + title + string + status + string + draft | published | archived + is_builtin + bool + shipped help page + visibility + string + public | department | + restricted + content_md + text + THE source of truth + meta + jsonb + template, context, summary + author_id + uuid + FK, null, SET NULL + department_id + uuid + FK, null, SET NULL + + + doc_permissions + document_id + uuid + PK, FK, CASCADE + department_id + uuid + PK, FK, CASCADE + level + string + read + + + document_events + id + uuid + PK + document_id + uuid + FK, CASCADE + actor_id + uuid + FK, null, SET NULL + action + string + created | edited | published | + archived | visibility_changed | + review_requested / _resolved + content_md + text + snapshot AFTER the event + title / visibility / meta + snapshot + + + chunks + id + uuid + PK + document_id + uuid + FK, CASCADE + chunk_index + int + unique per document + content + text + embedding + vector + 1024 bge-m3, HNSW cosine + tsv + tsvector + generated german, GIN + meta + jsonb + heading path, filters + + + review_requests + id + uuid + PK + document_id + uuid + FK, CASCADE + requester_id + uuid + FK, null, SET NULL + reviewer_id + uuid + FK, null, grants read+edit + question + text + null, "still 14 days?" + resolved_at + timestamp + null while open + resolved_by_id + uuid + FK, null, who checked it + + + templates + id + uuid + PK + name + string + version + string + schema version, e.g. 1.0 + config + jsonb + authoring template + + + llm_settings + id + uuid + PK + role + string + unique: chat/utility/embed + base_url + string + seeded from env at start + model + string + seeded from env at start + api_key + text + plaintext, never returned + base_url_from_env + bool + provenance, drives reset + model_from_env + bool + provenance, drives reset + api_key_from_env + bool + provenance, drives reset + + + prompt_settings + id + uuid + PK + key + string + unique, e.g. query_system + content + text + full replacement text + + + jobs + id + uuid + PK + type + string + payload + jsonb + status + string + pending/running/done/failed + run_after + timestamp + idx (status, run_after) + attempts + int + last_error + text + sanitized, never content + + Reading the lines + A bar is the one side, a fan the many side. + Optionality is on the column instead: a note + saying null (and SET NULL vs CASCADE) is what + actually matters when a row is deleted. + templates, llm_settings, prompt_settings and + jobs stand alone: config and work, not knowledge. + An open review_request is not a document status: + it can hang on a draft or on one published long ago, + and it marks the document everywhere it appears. + diff --git a/docs/diagrams/queue-sequence.svg b/docs/diagrams/queue-sequence.svg new file mode 100644 index 0000000..97b4625 --- /dev/null +++ b/docs/diagrams/queue-sequence.svg @@ -0,0 +1,126 @@ + + Background jobs: one Postgres table, no broker + + + + + + + + + + + + + + + + Background jobs: one Postgres table, no broker + FOR UPDATE SKIP LOCKED, and the handler runs inside the claim transaction. + + Queue loop + + + Postgres + + + Job handler + + BEGIN + + SELECT job WHERE status=pending AND run_after<=now() + ORDER BY run_after LIMIT 1 FOR UPDATE SKIP LOCKED + + ROLLBACK + + wait one poll interval + + handler(db, job), INSIDE the open claim transaction + + status=done, attempts+1, COMMIT + (the handler's writes commit atomically with the job) + + ROLLBACK (the handler's writes go too) + + follow-up tx: attempts+1, last_error (sanitized), + run_after = now + 30s * 2^(attempts-1) + + + After 5 attempts: status=failed. + + Crash mid-job: the open transaction aborts, the row lock releases, + and the job is still pending and claimable after the restart. + + + alt + no claimable job + + + alt + handler succeeds + + + else + handler raises + + + else + job claimed, row lock held + + + loop + every poll interval, default 1s + diff --git a/docs/diagrams/retrieval-sequence.svg b/docs/diagrams/retrieval-sequence.svg new file mode 100644 index 0000000..6f7e575 --- /dev/null +++ b/docs/diagrams/retrieval-sequence.svg @@ -0,0 +1,157 @@ + + Retrieval: hybrid search, and the similarity path next to it + + + + + + + + + + + + + + + + Retrieval: hybrid search, and the similarity path next to it + The permission CTE is part of the one statement. There is no search without a user. + + Caller (mode / API) + + + rag/retrieval + similarity + + + llm/client.embed + + + Postgres + + + + search(): hybrid, the default + search(db, query, user=..., top_k=5) + + embed([query]) + + query vector (1024, bge-m3) + + ONE statement: + + + 1. CTE "allowed": the LIVE documents table. status=published AND + (public | own department | doc_permissions grant | author). + Never chunk meta, so a change applies without reindexing. + + 2. vector candidates: top-20 by cosine distance (HNSW), + joined to allowed. + + 3. FTS candidates: top-20 by ts_rank_cd over + websearch_to_tsquery('german', query), joined to allowed. + + 4. RRF merge: score = sum of 1/(60+rank), ORDER BY score LIMIT top_k. + rows: chunk, document, title, meta, score, + vector distance, fts rank + + SearchResult[] with citation metadata + (title, heading_path, score, vector_distance, fts_match) + + + No-answer signal: the top result has fts_match=false AND + vector_distance >= 0.45 (calibrated for bge-m3). Callers treat the + whole set as low confidence rather than citing it. + + + text_search(): the same query with no model at all + text_search(db, query, user=...) + + SAME allowed CTE, then the german tsvector GIN index alone + + + The fallback for a dead embedding endpoint: keywords, not meaning, + so the user is told it was a search without a model. No embed call, + which is why it works when nothing runs behind the LLM config. + + + rag/similarity: threshold, not ranking + similar_chunks(db, text, user=..., max_distance=<one of two constants>) + + embed([text]) + + SAME allowed CTE, then pure vector: + ORDER BY cosine distance LIMIT top_k + + rows with a distance that is ALWAYS present + + SimilarChunk[] filtered to distance <= max_distance + (similar_documents groups per document, keeping the min) + + + Why not the RRF path: its score is a fusion rank, not a similarity, + and vector_distance is null for FTS-only hits. A threshold needs a + comparable number. The permission filter is shared; only the ranking + differs. Two calibrated constants exist, CAPTURE_CONTEXT_MAX_DISTANCE + (loose) and DUPLICATE_MAX_DISTANCE (tight); exclude_builtin drops + Pablan's own help pages, because a capture asks what the COMPANY knows. + + Document search (GET /api/documents/search) is the same call with a + larger top_k, grouped per document: one code path, so browsing and + chat can never disagree on access. Query mode surfaces the run as SSE + state events (searching, results(count) | no_answer, answering). + Counts only, never the query text or a passage (rule 12). + diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..d219419 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,214 @@ +# Internationalization + +The interface ships in German and English. **German is the source +language**: messages are authored in `messages/de.json` first, and English +is written in the same change. Product CONTENT stays out of this system +entirely (see "What is not translated"). + +Compiler: [Paraglide JS](https://paraglidejs.com). Messages compile to +tree-shakable functions, so an unused string costs nothing at runtime and +there is no runtime i18n library in the bundle. + +## Where things live + +| Path | What | +|---|---| +| `frontend/project.inlang/settings.json` | locales, base locale, message file pattern | +| `frontend/messages/{de,en}.json` | the messages themselves | +| `frontend/src/lib/paraglide/` | compiler output, generated on every build, gitignored | +| `frontend/src/lib/i18n/locale.svelte.ts` | the reactive locale store and the client strategy | +| `frontend/src/lib/i18n/strategy.server.ts` | the server strategy that reads `users.locale` | +| `frontend/scripts/check-messages.py` | the two lint rules, run by `make lint` | + +`npm run messages` compiles the message files into +`src/lib/paraglide/`. `npm run check` and `npm run lint` both run it first, +because `svelte-kit sync` does not execute vite plugins: without it, a +newly added message is a type error against a stale build, and the fix +looks like it belongs in the component. `npm run dev` and `npm run build` +compile through the vite plugin as usual. + +## Message keys + +Keys are **feature-scoped**, `snake_case`, and read as +`__`: + +``` +settings_theme_light good +settings_password_repeat good +common_cancel good, for genuinely shared words +light bad, collides across features +button_label_2 bad, says nothing +``` + +A key belongs to the surface that owns the string. Reach for `common_*` +only when a word is shared by unrelated features and would be identical in +both languages in every one of them, which is rarer than it looks: +"Cancel" qualifies, "Name" usually does not, because the noun it labels +differs by context in German. + +Never reuse a key just because two strings happen to match in German +today. Translation splits them apart sooner than you expect. + +**Keys are never constructed at runtime.** `m['status_' + doc.status]` is +forbidden, however tempting it looks next to a status enum. Paraglide is a +compiler: it can only check that a key exists, and only tree-shake the ones +you do not use, if every key appears literally in the source. A computed +key silently ships every message in the bundle and turns a typo into a +runtime blank instead of a build error. Write the mapping out: + +```ts +const STATUS_LABELS = $derived({ + draft: m.documents_status_draft(), + published: m.documents_status_published() +}); +``` + +## Using messages + +```svelte + + +

{m.settings_title()}

+``` + +Interpolation passes an object; the placeholder name is the key: + +```json +"landing_greeting_morning": "Guten Morgen, {name}" +``` + +```svelte +{m.landing_greeting_morning({ name })} +``` + +Pluralization lives in the message rather than in the component, as a list +of **variants**. Inline ICU (`{count, plural, one {...}}`) inside a plain +string is NOT parsed by the message-format plugin: it compiles into a +placeholder with a nonsense name, which type-checks against nothing and +renders as broken text. Use the variant form: + +```json +"landing_review_pending": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Ein Dokument wartet auf deine Prüfung.", + "countPlural=other": "{count} Dokumente warten auf deine Prüfung." + } + } +] +``` + +The compiler resolves the category through `Intl.PluralRules` for the +active locale, so a language with more categories than German or English +(Polish, Arabic) only needs more `match` entries, never a code change. + +Never build a sentence by concatenating messages. Word order differs +between languages, so a message has to be a whole sentence with holes in +it, not a sentence assembled from fragments. + +**Option lists must be `$derived`, not `const`.** A constant array of +labels is evaluated once and then never again, so it keeps the language it +was born in: + +```svelte +const THEME_OPTIONS = $derived([{ value: 'light', label: m.settings_theme_light() }]); +``` + +Dates and numbers go through `Intl`, and **the locale is always passed +explicitly**: + +```ts +const dateFormat = $derived(new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium' })); +``` + +Not `new Intl.DateTimeFormat(undefined, ...)` and not +`date.toLocaleDateString()`. Both fall back to the *browser's* locale, +which is a different question from the interface language: someone running +an English browser who set the interface to German would get German labels +around English dates. Passing `i18n.locale` also makes the formatter +`$derived`, so it rebuilds when the language changes. + +## Per-route migration checklist + +A route is done when all of these are true, not just the visible sentences: + +- [ ] Body copy, headings, button labels, empty states, error messages +- [ ] `aria-label` and the `label` prop on icon-only controls +- [ ] `title` attributes (tooltips, truncated text, disabled explanations) +- [ ] `alt` text on images +- [ ] `placeholder` on inputs and textareas +- [ ] `confirm()` / `alert()` text +- [ ] The page `` in `<svelte:head>` +- [ ] Option lists converted from `const` to `$derived` +- [ ] `Intl` formatters take `i18n.locale` explicitly +- [ ] No computed message keys introduced + +The invisible ones matter most in practice, because nothing on screen +reminds you they are wrong. A screen reader user on a German interface +hitting an English `aria-label` gets no visual cue that anything is off. + +## How the locale is resolved + +Strategy chain, highest precedence first +(`frontend/vite.config.ts`): + +1. `custom-userPreference` reads `users.locale`. A language someone chose + should follow them to every device, so this outranks everything. +2. `cookie` keeps that decision available before `/me` has answered. +3. `preferredLanguage` is the browser's `Accept-Language`, the first + contact guess for someone who has never chosen. +4. `baseLocale` is the floor. + +There is deliberately **no `url` strategy**. Pablan is one installation for +one company; `/de/` path prefixes would buy nothing and invalidate every +existing link. + +Server side, `hooks.server.ts` runs `auth` before `i18n`: the auth handle +fetches `/me` anyway, so it stashes the user's locale in a `WeakMap` keyed +by the `Request`, and the custom strategy reads it from there. A strategy +only receives the request, and a second `/me` per page render to answer the +same question would be waste. + +Client side, `i18n.init()` points Paraglide's `getLocale()` at a `$state` +rune. Message functions call `getLocale()` internally, so that read is +tracked by Svelte and **switching the language re-renders the strings in +place**: no reload, no flash of the previous language, and no lost form +input. `setLocale(..., { reload: false })` still runs the chain so the +cookie is right for the next server render. + +## The two lint rules + +`frontend/scripts/check-messages.py`, wired into `make lint`: + +1. **Every locale carries every key.** Paraglide falls back to the base + locale for a missing message, which means an untranslated string ships + silently as German inside an English interface. A missing key fails the + build instead. Keys present in `en` but not in `de` fail too: the source + language defines the set. +2. **No em or en dashes in messages** (`—`, `–`). They are awkward to type, + drift in style when hand-written across a codebase, and in German they + collide with the Gedankenstrich convention. Use a comma, a colon, or two + sentences. The rule reads message files only, so prose in `docs/` and in + code comments is unaffected. + +## What is not translated + +- **Knowledge content.** Documents are whatever language their author + wrote them in. The embedding model is multilingual, so a German document + answers an English question; one eval case covers exactly that. +- **Template content.** Personas and section hints are customer content, not + UI. Blueprints carry a `locale:` field and a filename suffix + (`prozess.de.yaml`), the catalog collapses variants to one entry + per id, and the picker lists templates matching the reader's language + first. See `authoring-templates.md`. +- **Backend strings.** The backend never renders UI-language text. API + errors are `{detail, code}` and the frontend translates by `code`; SSE + `state` events carry counts and markers, and the frontend writes the + sentence. +- **Language names** in the switcher. "Deutsch" stays "Deutsch" in the + English interface, because the point of that entry is to be recognised + by someone who cannot read the language currently on screen. diff --git a/docs/licensing.md b/docs/licensing.md new file mode 100644 index 0000000..7eed640 --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,50 @@ +# Licensing model + +**Decision: Fair Source core (FSL-1.1-ALv2) + proprietary `ee/` layer.** + +## How it works + +- Everything outside `ee/` is licensed under the **Functional Source License + 1.1 with Apache 2.0 future grant** (`LICENSE` in repo root, text from + fsl.software). Anyone may read, audit, self-host and use Pablan internally + for free; offering a competing product/service based on the code is + prohibited. **Each released version automatically becomes Apache 2.0 two + years after its release** — the built-in trust promise: even if Pablan (the + company) disappears, the code inevitably becomes true open source. +- `ee/` is proprietary (`ee/LICENSE`), activated via license key. Planned EE + scope: OIDC/Entra ID SSO, fine-grained permissions beyond the basics, the + management insights module, audit log, priority support. +- The free core is the funnel: small teams start free, grow, need SSO and + permissions — that is where revenue begins (GitLab/Cal.com playbook, + Sentry licensing). + +## Rules that follow from this + +1. **Never call it "Open Source"** in public communication — FSL is not + OSI-approved. The correct, established term is **"Fair Source"** + (fair.io). Using "open source" invites justified openwashing criticism. + The two-year Apache conversion may and should be highlighted. +2. **Core must never import from `ee/`** — enforced by import-linter in CI. + The core only offers extension points (mode registry, frontend slot + registry, `ee_hooks.py`). +3. **CLA required from day one** if external contributions are ever + accepted — without it we can never change the license later. +4. **Register the "Pablan" trademark.** The license protects code; the brand + carries the trust. This also neutralizes forks: a fork may not be offered + commercially (FSL) and must be renamed (trademark). +5. `LICENSE` (root) and `ee/LICENSE` exist from the first commit so the + boundary is unambiguous in the entire history. The final wording of + `ee/LICENSE` and the FSL fine print are owned by the legal co-founder + with an IT lawyer. + +## Rationale (short) + +Considered alternatives: Open Core with MIT/Apache core (maximum trust, but +competitors may resell the core), AGPL + dual licensing (self-hosting +without distribution triggers no obligations → no revenue mechanism for our +deployment model, only FUD), fully proprietary (loses bottom-up adoption — +the only realistic sales channel for a three-person company). FSL + `ee/` +keeps the auditability that our target market actually means by "trust" +(every commit inspectable — critical when companies put their entire +internal knowledge into the system) while protecting against the one real +threat: a competitor commercializing our code. diff --git a/docs/notes.md b/docs/notes.md new file mode 100644 index 0000000..c7fa723 --- /dev/null +++ b/docs/notes.md @@ -0,0 +1,322 @@ +# Implementation notes + +Durable engineering learnings — decisions, calibrations and gotchas that are +still true of the system as it stands. Grouped by theme rather than by when they +were learned. The [roadmap](roadmap.md) says *what* exists; this says *why it is +the way it is* and *what will bite you*. For formal decisions see +[decisions.md](decisions.md); for the as-is contract see +[architecture.md](architecture.md) and [data-model.md](data-model.md). + +## Retrieval & embeddings + +- **One permission filter, evaluated live.** `rag/permissions.py` is the single + source both the API and retrieval use, and it runs against the current tables — + never against the denormalized visibility/department copy stored in chunk meta + (that copy is display-only and can lag a reindex). This is what makes "a chunk the + user may not read is structurally unreachable" true rather than aspirational. +- **Authors vs retrieval.** An author sees their own drafts through the API + (`readable_documents_filter`), but retrieval is published-only + (`searchable_documents_filter`), so a draft never reaches an LLM prompt. Admins + get **no** read-everything bypass — an admin only sees what the normal rules grant. +- **Hybrid search is one SQL statement.** pgvector top-20 + German FTS top-20 fused + with Reciprocal Rank Fusion, `RRF_K=60`. Keeping it one statement is what lets the + permission CTE gate everything before ranking. +- **Thresholds are calibrated against bge-m3 on the fixture corpus, and must be + re-measured if the embedding model changes.** No-answer fires at vector distance + ≥ 0.45 (matched hits land ~0.34–0.45, unrelated ~0.50+). The similarity path + (`similar_chunks`) uses `max_distance` 0.45 for capture-context. +- **A chunk is embedded WITH its heading path** (`rag/indexing.embedding_text`). + A section reads "Solldruck 180 bar" and never repeats which machine it belongs + to, so on its own it is unreachable by the name the asker uses. Measured on the + dev corpus: "Welcher Solldruck gilt für die Hydraulikpresse?" went from a + no-answer (best distance 0.492, above the 0.45 gate) to a confident hit (0.375, + and the section that literally answers it at rank 2). The genuine no-answers + moved too little to matter (Deutschlandticket 0.503 → 0.488), so the gate keeps + separating them. What is STORED as `content` stays the raw section: the path is + context for the vector, not part of the document, and the excerpt a reader sees + must not grow a header. Tests that search for "the exact chunk text" have to + build it through `embedding_text` or they land at distance ~0.96. +- **The chunk `tsv` covers the heading path too** (generated column over + `content || ' ' || meta->>'heading_path'`). Same reason on the keyword side: + "Solldruck Hydraulikpresse" matched no chunk before, because only the document + title carries the machine name. Note what it does NOT fix: `websearch_to_tsquery` + is AND over every surviving lexeme, and German stopword lists keep verbs, so + "Welcher Solldruck **gilt** für die Hydraulikpresse?" still has no keyword match. + Treat `fts_match` as "the keywords really are in there", not as "this was a + keyword question". +- **Calibrate on what the engine renders, not on what a human writes.** Real capture + drafts are compressed notes and sit further from their source than a hand-written + paraphrase, so thresholds tuned on paraphrases miss real matches. +- **`rag/similarity` is deliberately not the hybrid path.** RRF produces a fusion + rank, not a similarity, and its vector distance is null for FTS-only hits — a + threshold needs a comparable number, so similarity is a pure-cosine query with the + threshold applied in Python *after* `ORDER BY … LIMIT` (a distance predicate in + WHERE fights the HNSW index). +- **HNSW post-filtering can under-fill.** For a heavily permission-filtered user, the + fixed candidate fetch can return fewer rows than expected; keep candidate counts + generous. +- **Hybrid retrieval is robust to statement-vs-question phrasing; the pure-cosine path + is not.** A declarative statement ("Die Zentralschmierung … muss aufgefüllt werden.") + retrieves the same top document as the equivalent question, because the German FTS + component matches the keywords regardless of phrasing — guarded by declarative- + statement cases in `golden_queries.yaml`. The pure-cosine similarity path + (`similar_documents`/`similar_chunks`, used by the capture picker and refinement + grounding) has no FTS to lean on and is therefore more sensitive to how a thought is + phrased. It stays pure-cosine on purpose (a comparable distance for its threshold), + so a phrasing-sensitive picker is a known trade-off, not a bug. + +## Permissions & auth + +- **FK delete semantics encode the product.** Authored documents survive their + author (`ON DELETE SET NULL`) — "an employee leaving is exactly the case the + product exists for". The same holds for a document's department. +- **The auth boundary is a full-page navigation.** Login and logout do a real + document load (`window.location.assign`), never a client `goto`. Module-level runes + singletons (conversation list, chat state, resolved locale) live for one page load, + so reloading at the boundary guarantees the next user starts clean. A client + navigation kept the previous user's conversation titles on screen — an information + disclosure. Logout also clears the `PARAGLIDE_LOCALE` cookie so a shared terminal + does not pin one user's language for the next. +- **A private helper imported across modules is a design smell, not a shortcut.** + Four of them had grown (`_readable_document`, `_role_config`, `_excerpt`, + `_HEADING_RE`): each marked a place where one module had quietly become part of + another's interface without saying so. Splitting the big API modules surfaced + all four at once. When a second module wants an underscore name, that name is + the interface and should be renamed, not imported. +- **A test double that patches by module name silently rots.** The fake-embedding + fixture patched `indexing` and `retrieval`; the moment the similarity path moved + into its own module, an "offline" test would have called a real endpoint. A + fixture that names the modules it patches has to be updated with every split, so + it says why in a comment right there. +- **A history snapshot is written AFTER its event.** So the content stored on an + `edited` event is the state that edit produced, not the one it replaced. Diffing + a snapshot against the live document therefore shows the change of the NEXT + entry, and the newest entry shows nothing at all. What a timeline entry has to + answer is "what did this one do", so the version endpoint returns the pair + (`content_md` + `previous_content_md`) and the dialog diffs those. +- **The product degrades to a search engine, not to an error page.** Postgres is + already an inverted index (`chunks.tsv`, a stored `to_tsvector('german', …)` + column with a GIN index), so "no model reachable" costs the generated answer and + the semantic half of retrieval, nothing else. The turn ends with the full-text + hits as a list, marked as a search without a model, and is persisted like a + normal reply so a reload replays it. Worth remembering when adding any other + model-dependent surface: ask what remains without the model. +- **Classify an endpoint failure once, in `LLMError.code`.** "Not reachable" and + "busy, try again" need different words to the user, and a timeout means the + second, not the first: the endpoint took the request and never came back. + Retrieval embeds before the model is ever called, so the failure often happens + in `search()`, not in `chat_stream` — the conversations router catches + `LLMError` centrally rather than every mode remembering to. +- **Enumeration-resistant login.** An unknown email still runs a constant-time dummy + verify, so timing does not reveal which accounts exist. +- **Admin CRUD guardrails.** An admin cannot delete themselves or change their own + role (409 `self_modification`); a password change or reset revokes all of that + user's other sessions. +- **Sharing is grants, not new permission logic.** Multi-department sharing plugs + straight into the `EXISTS doc_permission` branch that was already in + `searchable_documents_filter` — `PUT /documents/{id}/departments` just manages the + rows. No reindex: grants are evaluated live against the table, not denormalized into + chunk meta. Any per-department count therefore measures REACH, not a partition: + a shared document counts under every department it reaches, so such counts can + exceed the distinct document count. +- **Self-lockout only bites admins.** `readable_documents_filter` grants the author + read access unconditionally, so a normal author can never edit themselves out of + their own document; the guard (`_guard_self_lockout`, a Python mirror of the read + rules) only ever fires for an admin changing a document they do not own, who must + resend with `confirm_lockout`. When mirroring a SQL filter in Python for a + pre-commit check, keep the two in the same file so they cannot drift. +- **A `bool = False` Pydantic field serializes as *required* in the generated TS + client** (openapi-typescript), which breaks every existing caller that omitted it. + Use `bool | None = None` (like the other optional fields) and `bool(...)` at the use + site — then it stays optional in `schema.d.ts`. +- **The audit trail is where "who checked it" lives.** `document_events` + (`authoring/history.py::record_event`) appends who-did-what at every transition; + `review_resolved` writes the answering colleague as the event actor — once a + request is answered, the `review_requests` row only says that nothing is open + any more, so the trail is the only place that identity survives. Content-bearing events + (`created`, `edited`) snapshot the Markdown, never the chunks, so per-event + snapshots of the *resulting* state chain into a version diff. Gotcha: a freshly + created document must be `flush()`ed before recording its `created` event — the + UUID PK default lands at flush, not at object construction, so `document.id` is + None until then. History is served through the document's own read gate, so it + cannot leak to a user who may not read the document. + +## LLM & prompts + +- **`chat_template_kwargs.enable_thinking = false` is the big latency win.** On a + reasoning-capable local model it drops section refinement from ~10s to ~1s with no + quality loss, because refinement is a mechanical rewrite. Passed through + `extra_body`; ignored by endpoints that do not support it. +- **Prompt shape for caching.** Keep the system prompt byte-identical and the history + stable, and put the volatile part (retrieval excerpts, the grounding block) last — + the endpoint then reuses the cached prefix and only reprocesses what changed. +- **Content never reaches logs.** `LLMError` is raised `from None` so a model + response cannot ride out in a chained traceback; SQL bind params are stripped; the + openai SDK's DEBUG body-logging is capped unless `PABLAN_DEBUG_LOG_PROMPTS` is set + (never in production). This is canary-tested. +- **One SDK-level retry** covers the occasional llama.cpp `APIConnectionError`; + `chat_json` additionally validates and retries once on a schema mismatch. +- **Prompts are admin-editable, defaulting to code.** Every system prompt renders + through `app/prompts/overrides.py::get_prompt(key)`, which returns a DB override + (`prompt_settings`) or the code default (`app/prompts/defaults.py`). This mirrors the + LLM-settings cache but is simpler: the reset target is the code default (prompts have + no `.env`), and there is no bootstrap — a missing row just means "use the default". + Keep the module-cache clear in the test teardown, or an override leaks into the next + test. Prompt-cache byte-identity (D22) still holds because a value changes only on an + admin write. +- **The context inspector needs the FULL retrieval, so a no-answer keeps its results.** + Query mode used to discard the low-confidence hits (`results = []`); now it keeps them, + sends them in the `sources` event marked `used=False`, and passes only `used` ones to + the prompt. That is what lets the "?" inspector explain a no-answer ("these were close + but too weak") instead of showing nothing. The cited-source badges filter to `used`. +- **The shipped system prompt was picked by measurement, not by taste.** A bench + over 15 objective checks (grounding phase, the load-bearing fact, an honest + "not documented", answer language) on the dev corpus: the long rules-list + version scored 42/45 at 486 chars mean answer, a four-line version 45/45 at 279 + chars. Everything the list spelled out, the model already did; what it was + missing was the one instruction that carries its weight — *say when something + is not documented* — without which the three no-answer cases all produced + plausible filler. Shorter prompt, shorter answers, better score. The bench lives + in the session scratch, not in the repo: it needs a seeded instance and a live + endpoint, which is what `make eval` is for — port a case there when it earns a + permanent floor. +- **Asking about the product is a retrieval question, not a special case.** The + `help/` pages are indexed like anything else, so "how do I share a document?" + is answered through the same path — and `tests/evals/test_self_knowledge_eval.py` + measures it (10/10, and asserts the help pages do NOT outrank company documents + for a company question, which is the failure mode that would matter). +- **Cloud validation is still owed.** Every shipped prompt (query, refinement, + grounding, topic summary, title) has only run against a local Gemma-class model. + The first cloud-model eval pass is roadmap epic 15 — expect to fix behaviour a 26B + model tolerated. + +## Jobs & runtime + +- **The claim transaction stays open for the whole handler.** Claiming a job + (`FOR UPDATE SKIP LOCKED`) and completing it happen in one transaction, so a crash + mid-handler rolls the claim back and the job is retried — at the cost of one pinned + connection per in-flight job. +- **Backoff** is 30s·2^n over 5 attempts; failure bookkeeping runs in its own + transaction. Retention cleanup reschedules itself daily. +- **The endpoint gate is per base_url, not per role** (`llm/gate.py`). chat and + utility point at the same server in the shipped config, so a per-role limit + would let one server take twice its slots. The waiting is bounded twice (a wait + timeout and a queue-length cap) because an unbounded wait in front of a + saturated endpoint is the same failure as no gate at all, just quieter. Both + refusals reuse `llm_busy`, so the UI has one sentence rather than two. The + background job worker is strictly sequential, so a big reindex contributes ONE + concurrent embedding call, not a burst. +- **Exactly one uvicorn worker.** The job loop, the metrics registry and the LLM + client cache are per-process; a second worker would double-run the queue and split + the metrics. The compose file pins `--workers 1`. + +## Frontend & i18n + +- **Citations are snapshotted into `messages.meta`.** Chunks are disposable and get + reindexed, so an answer freezes the exact passages it cited at answer time; the + excerpt is re-cleaned to plain prose on the way out (cleaning is idempotent). +- **SSE via fetch + ReadableStream, not `EventSource`.** The turn is a POST that must + be abortable (the stop button), which `EventSource` cannot do. A client abort still + persists the partial assistant message (`asyncio.shield` on the backend). +- **Theme per device, language per account, both applied pre-paint.** Theme lives in + localStorage and is stamped onto `<html>` by a boot script before first paint; + language comes from `users.locale` and is baked into the first SSR byte — otherwise + the page flashes the wrong theme/language on every load. +- **Paraglide pitfalls.** Source is **de** (informal "du"), en written in the same + change; two lint rules fail the build (a missing `en` key, an em/en dash). There is + no `url` locale strategy (locale is not in the path). `Intl` formatters must be + passed the resolved locale explicitly or they default to the server's. ICU plurals + have their own syntax trap. Recompiling messages under a running dev server can + wedge it — restart after adding keys. +- **`hooks.server.ts` needs an absolute backend URL.** A relative `event.fetch` never + reaches the vite proxy, which silently breaks server-side auth calls. +- **The chat renders Markdown LIVE while streaming.** Rendering plain-text tokens and + then re-rendering to Markdown on completion read as a snap; the turn now goes through + the sanitizing `Markdown` component every token, so the formatted output is unveiled + progressively. Partial constructs (an unclosed `**` or `$`) render as literal text + until closed — a small local flicker, far better than the whole-reply snap. +- **The local model emits real LaTeX**, inline `$...$` and display `$$...$$` with + `\frac`, `\sqrt`, superscripts. `$lib/markdown.ts` runs `marked-katex-extension` + + KaTeX BEFORE DOMPurify, and the KaTeX CSS/fonts are bundled locally (imported in the + root layout — no CDN, the artifacts self-host). DOMPurify keeps `<span class style>` + by default, so the typeset math survives sanitizing; `throwOnError: false` degrades a + malformed formula to text instead of throwing mid-answer. +- **Prefer `SvelteSet` over a plain `Set`** for reactive membership state — eslint + (`svelte/prefer-svelte-reactivity`) enforces it, and mutating it (`add`/`delete`) + is reactive without reassigning. +- **The refine suggestion is an inline CodeMirror block widget, not a side pane.** + It appears right under the section being edited (`WritingEditor.svelte`): a + `WidgetType` wrapping a persistent DOM element (built once, `eq()` compares by + element identity so CodeMirror reuses it) placed by a `StateField` + + `StateEffect`. Tokens stream by mutating that DOM directly; CodeMirror does not + learn a widget's height changed on its own, so call `view.requestMeasure()` + (rAF-throttled) after each update or the lines below overlap. `ignoreEvent()` + returns true so the accept/dismiss buttons handle their own clicks, and their + `mousedown` preventDefault keeps the editor from blurring first. +- **The editor is not continuously autosaved** (that would empty the + diff you are shown before saving). A `beforeNavigate` guard instead discards an + abandoned, never-filled template draft (only headings/blank lines) and saves an + edited-but-unsaved draft on the way out — fixing both the data-loss trap and the + empty-draft clutter without touching the save flow. Skip it once the document + was published from the editor, or it would re-save over the publish. + +- **Seeded documents are built through the real process, not inserted finished** + (`app/seed.py`). Each one gets the history it would have had: the empty draft, + one `edited` snapshot per section as the author works down the page, the + publish, and the questions colleagues asked afterwards — plus a few documents + left as drafts and one archived, so every screen has something to show. Without + it the history view, the version diffs and "recently changed" are empty or lie + in every dev stack. Order the corpus deliberately (the overdue document has to + be an old one, drafts have to be the newest), and give `document_events` and + `review_requests` explicit timestamps: `server_default=now()` would stamp the + whole life of a document at seed time. +- **Bits UI keeps inactive tab panels mounted**, just hidden. On a page of + independent panels (`/admin`) that means all four fetch their data on + arrival, and controls nobody can see sit in the DOM where a click never + reaches them (Playwright finds the element, then times out waiting for it to + be visible). `lib/components/Tabs.svelte` renders only the open panel. +- **A grant that ends needs its own access reason.** Answering a review request + on someone else's draft takes the access away again — correct, and it dropped + the reviewer on a 404 for doing what they were asked. `AccessReason.review` + names the case ("the only thing letting you in is the request"), so the UI can + say thank you instead. It mirrors `readable_documents_filter`, where the + visibility rules only apply to a PUBLISHED document: on a draft, `public` + never explains access, the request does. +- **The classic-search fallback ORs its terms; the hybrid path ANDs them** + (`rag/retrieval._any_term_tsquery`). `websearch_to_tsquery` builds an AND over + every lexeme, which is fine when the vector half carries the recall — but alone + it answers a typed-out question ("Wie läuft die Qualitätsprüfung im + Wareneingang?") with nothing at all, because no single chunk contains every + word. Rewriting the operators between the groups (`' & '` → `' | '` on the + tsquery text) keeps quoted phrases and exclusions intact and leaves the ranking + to `ts_rank_cd`. `NULLIF` the result: a query of nothing but stop words + rewrites to an empty string, and `to_tsquery('')` is a syntax error rather than + an empty match. +- **The fallback needs chunks, so it needs a past embedding run.** `chunks.tsv` + is generated from `chunks.content`, and a chunk row is only written when + `reindex_document` succeeded — which calls the embedding endpoint. A document + published while the embedding endpoint is down has no chunks at all, so the + "classic search" fallback cannot find it either. It keeps an already-indexed + knowledge base usable without a model; it does not index without one. +- **The e2e suite pins English with Accept-Language, which an ACCOUNT language + outranks** — that is the product rule (a language a person picked follows + them to every device), so a dev stack where somebody switched the admin to + German failed every spec that reads a label. The login helper hands the + account back to "follow the browser"; specs that run as that user select + tabs by testid rather than by label. +- **`npm run check` compiles Paraglide with the CLI**, which used to write the + DEFAULT strategy chain over the one `vite.config.ts` configures — leaving the + dev server (and the e2e suite, which pins `Accept-Language`) resolving German + for everyone until the next vite restart. The `messages` script now passes the + same `--strategy` flags. The other half of the trap stands: recompiling while + `vite dev` runs breaks its module graph until restart, which is why `make lint` + skips the recompile when :5173 is listening. + +## History + +A dialogue-driven "interview" capture engine (a state machine that interviewed the +employee and assembled a document from the answers) was built first, then removed +and replaced by the writing-first editor that exists today. Nothing from it remains +in the code path; the roadmap describes building the current system directly. The +only ideas that carried across are generic: template import/upsert by config id, and +the `pyyaml` dependency. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..89ff7dc --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,508 @@ +# Roadmap + +The feature backlog for Pablan, organized by feature area rather than by +delivery date. It doubles as a **rebuild manual**: follow the done epics top to +bottom and you arrive at the system as it stands today; the open epics are the +road ahead. + +**How to read it.** Each epic is a feature area with a one-line intent and a +checklist of stories — `[x]` shipped, `[ ]` not yet. Epics are ordered as a +build sequence (foundation upward). File pointers name where a capability lives, +so a rebuilder can find the pattern to follow. Implementation learnings that +survive across features live in [notes.md](notes.md); the as-is contract lives +in [architecture.md](architecture.md), [data-model.md](data-model.md) and +[api-protocol.md](api-protocol.md). + +--- + +## 1. Platform foundation *(done)* + +The backend skeleton every other feature stands on. + +- [x] Config via `pydantic-settings`, all `PABLAN_*` env vars; per-role LLM + config (base_url / api_key / model for chat, utility, embedding). +- [x] Async SQLAlchemy 2.0 (typed) + Alembic migrations on PostgreSQL + pgvector; + UUID primary keys and a shared timestamp mixin (`models/base.py`). +- [x] The table set — see [data-model.md](data-model.md) and + [diagrams/data-model.svg](diagrams/data-model.svg). +- [x] pytest harness against a real Postgres (docker); retrieval logic gets + SQL-level tests. +- [x] Seed script with a production guard; a shared German fixture corpus + (`tests/fixtures/corpus/` + `golden_queries.yaml`) that seeds and tests draw + from. + +## 2. Authentication & sessions *(done)* + +Server-side sessions, no JWT, and an auth boundary that cannot leak one user's +state to the next. + +- [x] argon2 password hashing; `auth_sessions` table; httpOnly `pablan_session` + cookie (14-day TTL); `{detail, code}` error shape. +- [x] login / logout / me; enumeration-resistant login. +- [x] Session revocation (`auth/sessions.py::revoke_user_sessions`) — a password + change logs out every other session. +- [x] **Full-page auth boundary**: login and logout are document navigations, + never client `goto`, so module-singleton client state (conversation list, + chat state, resolved locale) is guaranteed dead at the boundary and cannot + leak the previous user's data. + +## 3. Frontend foundation *(done)* + +The SvelteKit shell, the design system, the typed API contract, and i18n. + +- [x] Semantic design tokens as CSS variables mapped into Tailwind; the palette + is swappable by editing tokens only; a WCAG-AA contrast gate + (`frontend/scripts/contrast-check.py`) runs in `make lint`. +- [x] A small styled component set on Bits UI (Button, Input, Select, Dialog, + Card, Badge, Tabs, Tooltip, Popover, FormField) — no component-library deps. +- [x] Typed API client: FastAPI OpenAPI → openapi-typescript → openapi-fetch; + `make types` regenerates it after any backend API change. +- [x] `(app)` route group + `hooks.server.ts` session validation (absolute + backend URL, since a relative `event.fetch` never hits the vite proxy). +- [x] The sanitizing Markdown renderer (`components/Markdown.svelte`, DOMPurify) — + all model and document output renders only through it. +- [x] Paraglide i18n: **de** source, **en** written in the same change; two lint + rules (missing `en`, em/en dashes) in `check-messages.py`; the backend never + renders UI strings — it returns `{detail, code}` and the frontend translates. +- [x] Theme per device (localStorage, applied pre-paint by a boot script) and + language per account (`users.locale`). + +## 4. LLM gateway & background jobs *(done)* + +One place that talks to models, and a Postgres-only work queue. + +- [x] `llm/client.py` is the ONLY code that calls LLM endpoints: `chat_stream`, + `chat_json` (structured output via a Pydantic `response_format`), `embed`; + roles chat / utility / embedding, each independently configured; `extra_body` + passthrough (e.g. `enable_thinking:false`). +- [x] In-app LLM config: bootstrap each field from env, then the DB, with + per-field provenance ("from .env" / "changed here"); `llm/test` pings the + three roles; model discovery is server-side. +- [x] Postgres job queue (`jobs` table, `FOR UPDATE SKIP LOCKED`, backoff + 30s·2^n over 5 attempts); the handler runs inside the open claim transaction; + exactly one uvicorn worker (queue and metrics are per-process). +- [x] Scheduled retention cleanup (reschedules itself); an in-process metrics + registry. +- [x] Content-safe logging: metadata only, never prompts / responses / user text; + `LLMError` raised `from None`; an openai-SDK DEBUG-log guard + (`PABLAN_DEBUG_LOG_PROMPTS`, never in production). +- [x] **A failing endpoint says why.** `LLMError.code` classifies every failure + once (`llm_unreachable` / `llm_busy` / `llm_misconfigured` / `llm_failed`); + every surface reports it unchanged and the frontend phrases it + (`lib/api/errors.ts`). The conversations router catches `LLMError` centrally, + so a failure during retrieval (which embeds before the model is called) ends + the turn with the reason instead of a truncated stream. A busy endpoint + queues rather than refuses, so the chat also says so after 12s of silence. + +## 5. Documents & the knowledge model *(done)* + +Markdown documents with a permissioned lifecycle; the single source of truth. + +- [x] Documents stored as Markdown (`content_md`); chunks and embeddings are + disposable derivatives, always re-indexable from the document. +- [x] Lifecycle draft → published → archived; `visibility` (public / + department / restricted). +- [x] Publishing is the author's own action (`POST /documents/{id}/publish`), + plus archive/republish. Whether the CONTENT is trusted is a review + request instead, never a status (epic 16). +- [x] The **single permission filter** (`rag/permissions.py`) shared by the API + and retrieval, evaluated live against the table (never on stale chunk meta): + `readable` adds a user's own drafts and the documents someone asked this + user to check; `searchable` is published-only, so a draft never reaches + another user or an LLM prompt. +- [x] ZIP export of the readable knowledge base (Markdown + YAML frontmatter), + permission-filtered, help pages excluded, stdlib `zipfile`/`io` only. +- [x] Built-in help pages (`help/*.md`) imported into `documents` on start + (`is_builtin`, read-only, not deletable). +- [x] Admin CRUD guardrails (no self-delete / self-demote, 409 `self_modification`); + GDPR surfaces (delete-own-conversation, the retention job). + +## 6. RAG retrieval *(done)* + +Permission-filtered hybrid search, German-first. + +- [x] Heading-aware chunking (the active-section boundary is shared with the + editor); index / reindex jobs; bge-m3 multilingual embeddings + (`EMBEDDING_DIM=1024`). +- [x] Hybrid search: a permission CTE → pgvector top-20 + Postgres `german` FTS + top-20 → Reciprocal Rank Fusion (k=60), all in one SQL statement. +- [x] **Permission filter before the LLM**: there is no search without a user; + an unauthorized chunk is structurally impossible to retrieve. +- [x] Similarity path (`similar_chunks` / `similar_documents`): pure cosine, + permission-filtered, threshold applied in Python after `ORDER BY … LIMIT` + (a WHERE predicate fights the HNSW index); calibrated constants + (no-answer ≥ 0.45, capture-context 0.45). +- [x] Eval suite (`make eval`): recall@5 baseline against the fixture corpus and + the no-answer threshold. +- [x] **The knowledge base stays searchable without any model.** `text_search()` + is the full-text half alone (the `german` tsvector GIN index, no embedding + call), same permission CTE; query mode uses it when the embedding endpoint + is gone, and when no model answers at all the turn ends as a plain hit list + the user opens themselves, labelled as a search without a model. + +## 7. Query chat *(done)* + +Ask the knowledge base a question, get a streamed, cited answer. + +- [x] The `Mode` protocol + registry: a mode yields `ModeEvent`s and the router + converts them to SSE; Query is the only core mode (EE registers insight). +- [x] Conversations + messages; the SSE streaming turn; a stop button (a client + abort persists the partial message via `asyncio.shield`). +- [x] Citations snapshotted into `messages.meta` (chunks are disposable) + the + source side panel; one badge per document, not per chunk. +- [x] No-answer handling lives in retrieval (the ≥0.45 threshold), not the prompt; + a no-answer offers a "capture this knowledge" hand-off into the editor. +- [x] Topic-summary retrieval fallback: on a low-confidence hit with prior + context, re-search on an LLM topic summary of the conversation. +- [x] A prompt-cache-friendly shape: a stable system prompt + history, with the + volatile excerpts + question last. + +## 8. Writing-first capture *(done)* + +The centerpiece: employees write knowledge; the model matures the section at the +cursor. (This replaced an earlier dialogue-driven approach — see +[notes.md](notes.md) History.) + +- [x] A CodeMirror 6 split editor at `/documents/{id}/edit` (also the edit surface + for any existing document); the caret is visible (`drawSelection` + accent + caret color). +- [x] Section refinement `POST /documents/{id}/refine` (SSE): the server owns the + active-section boundary (shared with the chunker), sends a FIM-style natural- + language prompt, and streams a matured version of only that section into the + right pane; `enable_thinking:false` keeps it ~1s; only `delta.content` is read. +- [x] **Retrieval-aware grounding**: before refining, the server searches the + permission-filtered knowledge base for related published material the author + may read (`_grounding` via `similar_chunks`, the current doc and help pages + excluded) and passes it to the prompt as a reference, not a fact source. +- [x] A staleness guard and a post-accept cooldown on "Übernehmen"; a suggestion + computed against a since-edited region is discarded rather than applied. +- [x] A pre-save diff: `@codemirror/merge` `unifiedMergeView` (VSCode-style). + **Save** sits bottom right where the writing ends, shows what changed, and + asks again — save, cancel, or (for a draft) save and publish. +- [x] Authoring templates (`AuthoringTemplate`): a Markdown skeleton + persona + + per-section hints — declarative config, not a Mode. A shipped catalog plus a + **form template builder** (`POST /templates/build`; the skeleton is derived + from the section headings), with raw YAML behind an advanced toggle. +- [x] Capture from a chat: carries the conversation as background context + (`meta.context`) and shows a matching-documents picker (topic summary → + `similar_documents`) to extend an existing document instead. +- [x] A reward flow after publishing (a check animation → View / Have it + checked), a title suggestion in the save dialog, and a streaming token + fade-in. + +## 9. Insight & growth *(open)* + +Aggregate signals about the knowledge base — never per-user data. + +- [ ] A growth surface, in a form that earns its place. The first version (a + `/dashboard` page with bar charts off `GET /documents/dashboard`) was + removed again: numbers nobody acts on are noise, and the charts said + nothing the document list does not. Rebuild only around a question someone + actually asks. +- [x] `GET /documents/stats` — company-wide counts, read by the landing page's + first-run guide. +- [x] The document detail page shows created / last-changed timestamps. + +## 10. Product content & docs *(done)* + +The content that ships with the product, and the docs that describe it. + +- [x] In-product help (`help/*.md`, German) imported as read-only documents, + kept current in the same change as the workflow it documents. +- [x] The shipped template catalog: blueprints on disk, inert until an admin adds + one; an added template becomes the customer's, fully editable. +- [x] Claude-maintained developer docs (`docs/`), hand-authored SVG diagrams + (`docs/diagrams/`, theme-aware, no toolchain), this roadmap, and + [notes.md](notes.md). + +--- + +## 11. Change history & review audit *(done)* + +A git-like record of who changed what, when, and who checked it — including +**who confirmed the content**, which used to be stored nowhere. + +- [x] A `document_events` table: `document_id`, `actor_id`, `action` (created / + edited / published / archived / visibility_changed / + review_requested / review_resolved), + `created_at`, and — for content-bearing events — a snapshot of `content_md`, + title, visibility and meta. Snapshots the Markdown (the source of truth), never + chunks; `actor_id` is SET NULL and events CASCADE with the document. +- [x] An event is recorded at every transition: the content branch of + `update_document`, and create / publish / archive plus asking and + answering a review in `api/documents/` (helper + `authoring/history.py::record_event`). `review_resolved` captures **who** + confirmed the content as the event actor. +- [x] `GET /api/documents/{id}/history` → the events newest-first (actor name, + action, timestamp, `has_snapshot`), plus + `GET /api/documents/{id}/versions/{event_id}` to fetch a past version's content + for viewing or diffing. Both behind the document's own read gate. +- [x] A history timeline on the document detail page + (`lib/documents/DocumentHistory.svelte`) and a version diff reusing the + `unifiedMergeView` (shared theme extracted to `lib/documents/editorTheme.ts`), + with restore of a previous version. +- [x] **An entry shows its OWN change.** A snapshot is written after its event, so + diffing it against the live document made every row show the next row's edit + (the newest edit showed nothing at all). `GET /versions/{event_id}` now also + returns `previous_content_md`, the closest earlier snapshot, and the dialog + diffs the pair. +- [x] The review queue is discoverable from the landing page, deep-linking to + `/documents?review=1` (see epic 16 for the review model itself). + +## 12. People & profiles *(done)* + +A directory colleagues can browse, and ONE way to describe a person: a document +like any other. + +- [x] A member-visible directory: `GET /api/people` (+ `/api/people/{id}`) → + colleagues' `{id, name, role, department}` for any authenticated user (no + password/email leakage; mirrors the permission-scoped shape of + `ReviewerCandidate`), with a directory page (`/people`) and a person page + (`/people/{id}`) wired into the nav. +- [x] **The profile page is the document about you.** `GET /api/account/document` + answers whether the caller wrote one (from the person blueprint) and what to + start it from otherwise; the page opens it for editing or offers to create + it. The blueprint id lives in the backend, so the frontend knows no ids. +- [x] **The self-written bio was removed again.** A short profile blurb and a + document about yourself are the same thing said twice, and only one of them + is findable by the search, versioned and approvable. The `users.bio` column, + both endpoints, the editor and its two prompts are gone. + +## 13. Access control & sharing *(done)* + +Let a document reach more than one department, and stop anyone from accidentally +locking themselves out of it. + +- [x] **Multi-department documents.** `documents.department_id` stays the owning + department; extra departments are shared through the existing `doc_permissions` + join (the `searchable_documents_filter` `EXISTS doc_permission` branch already + unions them in — this was management, not permission logic). + `PUT /api/documents/{id}/departments` replaces the set; a detail-page multi-select + (`lib/documents/DepartmentSharing.svelte`) manages it; and several departments are + reflected in `shared_departments`, the `?department=` filter and the export + frontmatter. +- [x] **Self-lockout guard.** Before committing a visibility or grant change, the + *proposed* state is evaluated against the read rules (`_guard_self_lockout`, a + Python mirror of `readable_documents_filter`). An author keeps access as author, + so this only bites an admin editing a document they do not own: 409 + `self_lockout_warning` unless resent with `confirm_lockout` (surfaced as an + inline "save anyway" in the UI). +- [x] Close the adjacent admin lockout vectors. `delete_department` now refuses to + silently delete a department that still has members, owned documents or grants + (409 `department_in_use` unless `?confirm=true`) — its `doc_permissions` grants + CASCADE away invisibly otherwise. The `delete_user` / `update_user` + department-nulling is deliberate orphaning (documents and users survive + authorless/departmentless, `notes.md`), not a silent lockout, and is documented + in `data-model.md`. + +## 14. Prompt & context transparency *(done)* + +Give admins control over the model's instructions, and make what the model is +working from visible to the user. + +- [x] **Admin-editable system prompts.** Every system prompt (query + no-sources, + refinement persona/rules, grounding framing, topic summary, title) is + editable on the admin UI, stored in `prompt_settings` and applied without a + restart — mirroring the per-role LLM-settings pattern (`app/prompts/overrides.py` + + `api/admin/`, code defaults in `app/prompts/defaults.py`), with a + reset-to-default per prompt (`lib/admin/PromptSettings.svelte`). +- [x] **Always-inspectable working context.** A "?" inspector in the chat + (`lib/chat/ContextInspector.svelte`) shows every retrieved passage, marking + which grounded the answer (`used`) versus which were too weak — so a no-answer + is explained, not silent. The editor shows the grounding references a refinement + drew from, sent as a `grounding` SSE frame. + +## 16. Publish and review *(done)* + +Separating "where does this document stand" from "is what it says right" — see +[decisions.md](decisions.md) D24. + +- [x] Statuses are draft / published / archived; `pending_approval` is gone and + publishing is the author's own one-click action (from the save dialog, the + document page, or the landing page). +- [x] A `review_requests` table: one question, addressed to one colleague, open + until answered (`resolved_at` + `resolved_by_id`). Orthogonal to the status — + it can sit on a draft or on a document published months ago. +- [x] Being asked grants read AND edit until the answer + (`readable_documents_filter`, `can_edit`), so a reviewer fixes a wrong number + instead of filing a second question. Owner-only decisions (delete, sharing, + handing out a request) stay with the author or an admin. +- [x] An open question marks the document everywhere: list card, detail page, the + document panel, and the sources under a chat answer (`review_pending` on + every search hit, snapshotted with the citation). +- [x] The landing page surfaces open work: your unpublished drafts (with publish) + and the documents waiting for your check (`lib/documents/OpenWork.svelte`). +- [x] Follow-through from use: `AccessReason.review` names the access a request + grants, the document page thanks a reviewer instead of 404ing when their + answer ends it, visibility moved out of the editor to the document page + next to department sharing (both owner-only, like publishing), and the + title is edited in the save dialog with a ✨ suggestion on demand. +- [x] Seed data is built through the real process — per-section edit history, + publish events, drafts and review requests — so every history and diff + surface has something true to show. + +## 17. Interface pass *(done)* + +One question asked of every screen: does the important thing catch the eye, and +is everything else still findable? See `architecture.md` "One thing per screen". + +- [x] Ranked actions: one labelled primary button per screen, the rest in a + labelled overflow menu (`lib/components/Menu.svelte`) instead of a row of + bare icons. +- [x] Badges mark exceptions only — an open question, an overdue check, a + draft, an archived or built-in document. "Published / public / yours" is + the normal case and says nothing on every card. +- [x] The document page is the document: one quiet metadata line above the + text, visibility and sharing behind the chip that states them + (`AccessPopover`), history collapsed to the recent entries, and the + leading `# Title` dropped where it repeats the title. +- [x] The document list is a search field and results; filters live behind one + toggle and stay visible as removable chips while they are on. +- [x] `/admin` became four tabs; `/people` groups by department and filters; + the profile lists what you wrote; the landing page focuses its input. + +## 18. A template set people actually use *(done)* + +- [x] The catalog is four starter blueprints and three additions, replacing + eight that read like a taxonomy: `notiz` (no skeleton at all), `prozess`, + `stoerung`, `person`, plus `anlage`, `entscheidung`, `projekt-debrief`. + Headings are the questions a colleague asks ("Wann das gilt / Schritt für + Schritt / Wenn es klemmt"), not shapes of a document ("Worum es geht / + Details"). +- [x] `onboarding-basis` became `person`, written by the person themselves and + public — the profile page starts it. `offboarding`, `kundentermin` and + `lieferantenwissen` are gone. +- [x] The picker is a list, not a grid: a handful of choices read top to + bottom, where the description that decides between them is legible. + +## 19. Decided *(open)* + +Decisions taken on 2026-08-27. The three removals landed on 2026-08-30 (D25); +what is left needs either a screen size or an endpoint to work against. + +- [x] **Remove the verification workflow.** `documents.verified_until`, the + `document_review_days` setting, `POST /{id}/reverify`, the `reverified` + event and every "Verifizierung fällig" surface are gone. It marked + documents and reminded nobody; a review request is the mechanism that + actually asks a person something. +- [x] **Remove tags.** `meta.tags`, the `?tag=` filter, the chunk-meta copy, + `metadata.tags` in the template schema and the fixture frontmatter are + gone; they had been write-only since M8. +- [x] **Remove `source_type` / the upload half.** Nothing can be uploaded, and + `upload` was the value nobody wrote. The idea comes back with an import + (below), which is when a provenance column earns its place again. +- [ ] **Mobile.** Desktop is the main target, but the app has to be usable on a + phone: sidebar, the chat split view, the CodeMirror editor and the new + overflow menus are unverified below `lg`. +- [ ] **Import, as the counterpart to the export.** A self-hosted system has to + be able to read its own ZIP back in (Markdown + YAML frontmatter): + restore after a move, and the first honest answer to "we already have + documentation somewhere else". +- [x] **Carry the chat a capture started from.** A draft started out of a chat + keeps its subject as `meta.context`; extending an EXISTING document out of + the same chat now does too (`PATCH /api/documents/{id} {conversation_id}`), + which was the one path that dropped it. +- [ ] **Show the search query the model actually wrote** — the topic-summary + retry rewrites the question on the low-confidence path, and the context + inspector still shows only what came back, not what was asked. + +## Polish & known issues *(open)* + +Smaller fixes and rough edges to pick up opportunistically. + +- [x] **Refinement suggestions appear inline at the section you are editing**, as + a CodeMirror block widget right below it, instead of in a disconnected + right-hand pane (`WritingEditor.svelte`). +- [x] **The editor no longer loses work or leaves clutter.** A `beforeNavigate` + guard saves an edited-but-unsaved draft on the way out (no autosave, so the + review-before-save diff is preserved) and discards an abandoned, never-filled + template draft so empty drafts do not pile up. The document list also has its + own "capture" entry, not only the landing page. + +- [x] The chat input **stays focused after the first message is sent**: the + `/chat` → `/chat/[id]` navigation swaps the page component, so `ChatView` + re-focuses the composer in `afterNavigate`. +- [x] **Destructive confirmations use the app's modal**, not the browser's + `confirm()`: `lib/components/ConfirmDialog.svelte` backs delete-user, + delete-department, delete-template, delete-document and delete-conversation. +- [x] **The streaming reply no longer snaps.** The assistant turn renders as + sanitized Markdown LIVE as it streams (with a blinking cursor) instead of + fading in plain text and re-rendering to Markdown on completion. +- [x] **Model LaTeX/math renders as math.** The sanitizing renderer runs + `marked-katex-extension` + KaTeX before DOMPurify (`$lib/markdown.ts`), with + KaTeX styles/fonts bundled locally (no CDN) — the local model emits real + `$...$` / `$$...$$` LaTeX, now typeset. +- [x] **The classic-search fallback answers a whole question.** Its terms are + ORed rather than ANDed (`_any_term_tsquery`), so a typed-out sentence still + finds something when no model is reachable; ranking stays `ts_rank_cd`. The + hybrid path keeps the AND, where `fts_match` has to mean "the words are + really in there". Open: the fallback still needs chunks, so a document + published while the embedding endpoint was down is not findable at all + (`notes.md`). +- [~] **Retrieval quality on statements.** Investigated: the hybrid path (query + + search) retrieves the same top document for a declarative statement as for the + question, because the German full-text component catches the keywords + regardless of phrasing — verified and now guarded by declarative-statement + cases in the golden query eval. The pure-cosine similarity path (capture + picker / grounding) is more phrasing-sensitive by design (it needs a + comparable distance for its threshold, `notes.md`); switching it to hybrid is + left open. + +## 15. Hardening & release readiness *(open)* + +Get to "a stranger can deploy from the README alone", with the EE boundary in +place and every quality gate green. + +- [ ] EE boundary: `app/ee_hooks.py` (optional `pablan_ee` import + `register`), + `lib/ee/registry.ts` (empty frontend slot registry + Vite alias), and an + import-linter contract (core never imports `ee/`) wired into `make lint`. +- [ ] Minimal login throttling: in-process, per-user+IP backoff on failed logins + (no new dependency, no Redis). +- [ ] A Prometheus text-format exporter at `/metrics` for the metrics registry, + documented for customer IT. +- [x] An endpoint concurrency gate (`llm/gate.py`): one semaphore per base_url in + front of `chat_stream` / `chat_json` / `embed`, a bounded wait and a bounded + queue, both refusals reported as `llm_busy`, wait time and queue depth + metered, and a `queued` chat phase so a waiting turn says so. +- [ ] SDK-retry visibility: meter actual HTTP attempts, or document the tradeoff + (SDK-internal retries are invisible to our metrics) in the ops docs. +- [ ] Customer ops docs: backup/restore (pg_dump, rehearsed once), the upgrade + path (`alembic upgrade`), the `/metrics` protection note, and the parked + `idle_in_transaction_session_timeout` note for managed Postgres. +- [ ] The full e2e suite green: login, chat streaming, the writing editor, document + approval, permission boundaries. +- [ ] **First cloud-model eval validation of every prompt shipped so far** — the + writing-first refinement, grounding, topic-summary and query prompts have + only run against a local Gemma-class model. Add a `PABLAN_EVAL_CLOUD_*` role to + `.env.example`; the eval picks it up when set and skips it otherwise. Budget + time to fix prompt behaviour a 26B model tolerated. +- [ ] Two anti-scaffolding assertions in the refinement eval: across the eval + set the replies must not share one opening prefix, and at most a third may + share their first word. A model that scaffolds every answer the same way + reads as a form letter, and nothing catches that today. +- [ ] Clean-clone verification: quickstart (dev) and customer deploy (compose + + Caddy) from the README on a fresh checkout; a final pass so every `docs/*.md` + matches the implementation. + +--- + +## Non-goals + +Deliberately out of scope; revisit only with a concrete need. + +- **EE insights implementation** — only extension-point stubs in the core. +- **OIDC / Entra ID SSO.** +- **Cross-department knowledge discovery** as a distinct feature (the capture-time + "matching documents" panel is a deliberate precursor; this is different from epic + 13, which shares one document across departments). +- **CI pipeline setup.** +- **File uploads / attachments** — Markdown is the source of truth; binary storage, + previews and scanning add complexity without serving the capture → RAG loop. +- **Comments / in-tool collaboration** — knowledge flows through conversations with + Pablan, not discussion threads inside the tool. +- **Real-time / concurrent editing** — no use case; SSE streaming is all the + liveness the product needs. +- **Email notifications** — needs SMTP and deliverability support on customer + infrastructure; a candidate for EE later. + +_(Document version history was previously a non-goal; it is now planned as epic 11.)_ diff --git a/ee/LICENSE b/ee/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..ce3ef78 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.svelte-kit +build +test-results +playwright-report +.vscode +Dockerfile diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..deb3b36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +# Playwright +test-results + +# Paraglide compiles messages into here on every build. +src/lib/paraglide diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..1682e3e --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,4 @@ +engine-strict=true +# openapi-typescript declares peer typescript ^5.x but works with our TS 6 +# (verified by `make types`). Remove once its peer range includes ^6. +legacy-peer-deps=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..1e3a280 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,17 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ + +# Generated (make types) +src/lib/api/schema.d.ts + +# Generated by the inlang tooling, not ours to format. +project.inlang/.meta.json +project.inlang/README.md +src/lib/paraglide diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..175f389 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "svelte.svelte-vscode", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..bc31e15 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b5f04d9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json .npmrc ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:24-alpine +WORKDIR /app +ENV NODE_ENV=production +# adapter-node bundles all runtime dependencies into build/; +# package.json is only needed for "type": "module". +COPY --from=build /app/build ./build +COPY package.json ./ +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "build"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a1e24d1 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.16.3 create --template minimal --types ts --add prettier eslint playwright tailwindcss="plugins:none" --install npm frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/e2e/account.spec.ts b/frontend/e2e/account.spec.ts new file mode 100644 index 0000000..3a2002c --- /dev/null +++ b/frontend/e2e/account.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test'; +import { login, DEV_PASSWORD } from './helpers'; + +// Uses max@pablan.dev so a failure here cannot lock the other specs out of +// pablo@pablan.dev. The password is changed back at the end (zero residue). + +const NEW_PASSWORD = 'ben-neues-geheimnis'; + +test('a user changes their own password and stays signed in', async ({ page }) => { + await login(page, 'max@pablan.dev'); + + await page.getByTestId('user-menu').click(); + await page.getByTestId('change-password').click(); + + await page.getByLabel('Current password').fill(DEV_PASSWORD); + await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); + await page.getByLabel('Repeat new password').fill(NEW_PASSWORD); + await page.getByTestId('submit-password').click(); + + await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 }); + + // This session survived the change — no redirect to the login page. + await page.reload(); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page).toHaveURL('/'); + + // The new password is the one that works now. + await page.request.post('/api/auth/logout'); + const stale = await page.request.post('/api/auth/login', { + data: { email: 'max@pablan.dev', password: DEV_PASSWORD } + }); + expect(stale.status()).toBe(401); + + // Change it back, so the suite can run again. + await login(page, 'max@pablan.dev', NEW_PASSWORD); + await page.getByTestId('user-menu').click(); + await page.getByTestId('change-password').click(); + await page.getByLabel('Current password').fill(NEW_PASSWORD); + await page.getByLabel('New password', { exact: true }).fill(DEV_PASSWORD); + await page.getByLabel('Repeat new password').fill(DEV_PASSWORD); + await page.getByTestId('submit-password').click(); + await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 }); + + await page.request.post('/api/auth/logout'); +}); + +test('the wrong current password is rejected', async ({ page }) => { + await login(page, 'max@pablan.dev'); + + await page.getByTestId('user-menu').click(); + await page.getByTestId('change-password').click(); + await page.getByLabel('Current password').fill('definitely-wrong'); + await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); + await page.getByLabel('Repeat new password').fill(NEW_PASSWORD); + await page.getByTestId('submit-password').click(); + + await expect(page.getByRole('alert')).toContainText('current password'); + // The old password still works. + await page.request.post('/api/auth/logout'); + const still = await page.request.post('/api/auth/login', { + data: { email: 'max@pablan.dev', password: DEV_PASSWORD } + }); + expect(still.status()).toBe(200); + await page.request.post('/api/auth/logout'); +}); + +test('the theme survives a reload without flashing the other one', async ({ page }) => { + await login(page, 'max@pablan.dev'); + + await page.getByTestId('user-menu').click(); + await page.getByTestId('settings-dialog').waitFor(); + await page.getByTestId('theme-switch').getByRole('button', { name: 'Light' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + + // The boot script applies it before hydration, so it is already correct + // on the very first frame after a reload. + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + + // Back to following the OS, so the next spec starts clean. + await page.getByTestId('user-menu').click(); + await page.getByTestId('theme-switch').getByRole('button', { name: 'System' }).click(); + await expect(page.locator('html')).not.toHaveAttribute('data-theme', /.*/); + await page.request.post('/api/auth/logout'); +}); + +test('the language switch flips the interface in place, with no reload', async ({ page }) => { + await login(page, 'max@pablan.dev'); + + await page.getByTestId('user-menu').click(); + const dialog = page.getByTestId('settings-dialog'); + const locales = page.getByTestId('locale-switch'); + + // Start from English, whatever a previous run left behind. + await locales.getByRole('button', { name: 'English' }).click(); + await expect(dialog).toContainText('Settings'); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + + // A marker on window survives a re-render but not a reload, and a typed + // value survives neither if the DOM is rebuilt: together they are the + // assertion that "no reload artifacts" actually holds. + await page.evaluate(() => ((window as unknown as Record<string, string>).__i18nMarker = 'alive')); + await page.getByTestId('change-password').click(); + await page.getByLabel('Current password').fill('typed-before-switch'); + + await locales.getByRole('button', { name: 'Deutsch' }).click(); + + await expect(dialog).toContainText('Einstellungen'); + await expect(dialog).toContainText('Passwort ändern'); + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + expect( + await page.evaluate(() => (window as unknown as Record<string, string>).__i18nMarker) + ).toBe('alive'); + await expect(page.getByLabel('Aktuelles Passwort')).toHaveValue('typed-before-switch'); + + // The choice is on the account, not just in the tab. + await expect + .poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale) + .toBe('de'); + + // Zero residue: back to following the browser. + await locales.getByRole('button', { name: 'Automatisch' }).click(); + await expect + .poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale) + .toBe(null); + await page.request.post('/api/auth/logout'); +}); diff --git a/frontend/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts new file mode 100644 index 0000000..5d520bb --- /dev/null +++ b/frontend/e2e/admin.spec.ts @@ -0,0 +1,217 @@ +import { expect, test } from '@playwright/test'; +import { login, logout, openAdmin, openAdminTab, openDocuments } from './helpers'; + +// Admin creates a department and a user; the new user logs in and only +// sees what their (new, grantless) department allows. Plus the LLM panel +// against the real endpoints. + +test.setTimeout(120_000); + +const RUN = Date.now(); +const DEPARTMENT = `QS-${RUN}`; +const EMAIL = `quinn-${RUN}@pablan.dev`; + +test('admin creates department + user; the new user is correctly scoped', async ({ page }) => { + await login(page, 'florian@pablan.dev', 'pablan-dev'); + await openAdmin(page); + await page.locator('body[data-hydrated]').waitFor(); + + // Department first, so the user form can pick it. Creating is a dialog + // for both, so each starts with its trigger. + await openAdminTab(page, 'people'); + await page.getByTestId('new-department').click(); + await page.locator('#new-department-name').fill(DEPARTMENT); + await page.getByTestId('create-department').click(); + await expect(page.getByTestId('department-list')).toContainText(DEPARTMENT); + + await page.getByTestId('new-user').click(); + await page.locator('#new-email').fill(EMAIL); + await page.locator('#new-name').fill('Quinn Neu'); + await page.locator('#new-department').selectOption({ label: DEPARTMENT }); + await page.locator('#new-password').fill('quinn-secret-1'); + await page.getByTestId('create-user').click(); + await expect(page.getByTestId('user-table')).toContainText(EMAIL); + + // LLM endpoint panel against the real endpoints — its own tab now. + await openAdminTab(page, 'llm'); + await page.getByTestId('llm-test').click(); + await expect(page.getByTestId('llm-results')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('llm-results').getByText('ok')).toHaveCount(3); + + await logout(page); + + // The new user sees public documents, but no other department's ones. + await login(page, EMAIL, 'quinn-secret-1'); + await openDocuments(page); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.getByTestId('document-list')).toContainText('Reklamationsprozess', { + timeout: 10_000 + }); + await expect(page.getByTestId('document-list')).not.toContainText('Wartungsplan CNC-Fräse'); + await expect(page.getByTestId('document-list')).not.toContainText('CRM-Pflege'); + // No admin nav for members. + await expect(page.getByRole('link', { name: 'Admin', exact: true })).toHaveCount(0); + + // Cleanup so the spec is re-runnable. + await logout(page); + await login(page, 'florian@pablan.dev', 'pablan-dev'); + const users = (await (await page.request.get('/api/admin/users')).json()).items; + const created = users.find((u: { email: string }) => u.email === EMAIL); + if (created) await page.request.delete(`/api/admin/users/${created.id}`); + const departments = await (await page.request.get('/api/departments')).json(); + const dept = departments.find((d: { name: string }) => d.name === DEPARTMENT); + if (dept) await page.request.delete(`/api/admin/departments/${dept.id}`); + await page.request.post('/api/auth/logout'); +}); + +test('an admin changes an LLM endpoint and resets it to the .env value', async ({ page }) => { + await login(page, 'florian@pablan.dev'); + await openAdmin(page); + await page.locator('body[data-hydrated]').waitFor(); + + await openAdminTab(page, 'llm'); + const settings = page.getByTestId('llm-settings'); + // Bootstrapped from .env, so every field starts out attributed to it. + await expect(settings).toContainText('from .env'); + const envUrl = await page.locator('#chat-url').inputValue(); + expect(envUrl).not.toEqual(''); + + // A bogus endpoint must fail the test, and Save stays locked behind it. + await page.locator('#chat-url').fill('http://definitely.invalid/v1'); + await page.getByTestId('test-chat').click(); + await expect(settings).toContainText('endpoint failed', { timeout: 30_000 }); + await expect(page.getByTestId('save-chat')).toBeDisabled(); + + // The real endpoint passes, so it can be stored, and applies at once. + await page.locator('#chat-url').fill(envUrl); + await page.getByTestId('test-chat').click(); + await expect(settings).toContainText('ok ·', { timeout: 30_000 }); + await page.locator('#chat-url').fill(`${envUrl}/`); + await page.getByTestId('save-chat').click(); + await expect(settings).toContainText('changed here', { timeout: 15_000 }); + + // The endpoint reports what it serves, so the model becomes a dropdown. + await page.getByTestId('discover-chat').click(); + await expect( + page.getByTestId('model-select-chat').or(page.getByTestId('no-model-list-chat')) + ).toBeVisible({ timeout: 30_000 }); + + // Reset to .env, so the run leaves no configuration behind (zero residue). + await settings.getByLabel('Reset to .env').first().click(); + await expect(settings.getByText('changed here')).toHaveCount(0, { timeout: 15_000 }); + await expect(page.locator('#chat-url')).toHaveValue(envUrl); + await logout(page); +}); + +test('an admin forks a template, edits the fork and deletes it', async ({ page }) => { + await login(page, 'florian@pablan.dev'); + await openAdmin(page); + await page.locator('body[data-hydrated]').waitFor(); + + await openAdminTab(page, 'templates'); + const templates = page.getByTestId('template-list'); + const firstRow = templates.locator('li').first(); + await expect(firstRow).toBeVisible({ timeout: 15_000 }); + await firstRow.getByLabel('Duplicate').click(); + + // The fork opens in the form builder; the raw YAML is behind its toggle, + // which is where a broken template can be typed at all. + await page.getByTestId('builder-show-yaml').click(); + const editor = page.getByTestId('template-editor'); + await expect(editor).toBeVisible({ timeout: 15_000 }); + const original = await editor.inputValue(); + await editor.fill('id: broken\nname: nope'); + await page.getByTestId('save-template').click(); + await expect(page.getByTestId('template-error')).toBeVisible(); + + // Valid YAML saves, and the fork appears in the list. + await editor.fill(original); + await page.getByTestId('save-template').click(); + await expect(templates).toContainText('(2)', { timeout: 15_000 }); + + // Remove it again (zero residue). Destructive actions ask in the app's own + // modal, not the browser's. + const fork = templates.locator('li').filter({ hasText: '(2)' }).first(); + await fork.getByLabel('Delete').click(); + await page.getByTestId('confirm-accept').click(); + await expect(templates.getByText('(2)')).toHaveCount(0, { timeout: 15_000 }); + await logout(page); +}); + +test('an admin adds a template from the catalog and removes it again', async ({ page }) => { + await login(page, 'florian@pablan.dev'); + await openAdmin(page); + await page.locator('body[data-hydrated]').waitFor(); + + await openAdminTab(page, 'templates'); + await page.getByTestId('toggle-catalog').click(); + + // A blueprint can be read before it is added — that is what "View" is for. + // Deliberately one the starter set does NOT install: adding a blueprint + // that is already there would make the delete below ambiguous. + const catalog = page.getByTestId('template-catalog'); + const blueprint = catalog.locator('li').filter({ hasText: 'Anlage' }).first(); + await expect(blueprint).toBeVisible({ timeout: 15_000 }); + await blueprint.getByLabel('View').click(); + + const editor = page.getByTestId('template-editor'); + await expect(editor).toBeVisible({ timeout: 15_000 }); + // Nothing on disk is editable — it has no row to save to yet. + await expect(editor).toHaveAttribute('readonly', ''); + + // Adding drops it into the instance and opens it for adapting straight + // away, which is what an admin does next — so the row is NOT on screen + // afterwards, and the instance's own list is what proves the add. (The + // panel around the list holds the open editor too, so asserting text on it + // would match the blueprint YAML and prove nothing.) + await page.getByTestId('add-template').click(); + const listed = async () => + (await (await page.request.get('/api/templates')).json()).find((entry: { name: string }) => + entry.name.includes('Anlage') + ); + await expect.poll(listed, { timeout: 15_000 }).toBeTruthy(); + + // Zero residue. Through the API: the UI is sitting in the editor it just + // opened, and this spec is about the catalog, not about leaving a form. + const added = await listed(); + expect((await page.request.delete(`/api/templates/${added.id}`)).status()).toBe(204); + await logout(page); +}); + +test('an admin edits a user in the modal and pages the list', async ({ page }) => { + await login(page, 'florian@pablan.dev'); + await openAdmin(page); + await page.locator('body[data-hydrated]').waitFor(); + + // Address the row by who it is, never by position: this test changes + // data, and after the search below `.first()` is a different person. + await openAdminTab(page, 'people'); + const row = page.getByTestId('user-table').locator('tr').filter({ hasText: 'max@pablan.dev' }); + const dialog = page.getByTestId('user-dialog'); + + // Editing is a dialog, not an expanded row. + await row.getByTestId('edit-user').click(); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + const original = await page.locator('#edit-name').inputValue(); + await page.locator('#edit-name').fill(`${original} (e2e)`); + await page.getByTestId('save-user').click(); + await expect(dialog).toHaveCount(0, { timeout: 15_000 }); + await expect(row).toContainText(`${original} (e2e)`); + + // Search narrows the list server-side. + await page.getByTestId('user-search').fill('pablo'); + await expect + .poll(async () => page.getByTestId('user-table').locator('tr').count(), { timeout: 15_000 }) + .toBe(1); + await page.getByTestId('user-search').fill(''); + await expect(row).toBeVisible({ timeout: 15_000 }); + + // Zero residue: put the name back on the same row it came from. + await row.getByTestId('edit-user').click(); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await page.locator('#edit-name').fill(original); + await page.getByTestId('save-user').click(); + await expect(dialog).toHaveCount(0, { timeout: 15_000 }); + await expect(row).toContainText(original); + await logout(page); +}); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..5d08f4c --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page } from '@playwright/test'; +import { login, logout } from './helpers'; + +// Uses the seeded dev users (make seed): pablo@pablan.dev / pablan-dev. + +async function gotoHydrated(page: Page, path: string) { + await page.goto(path); + await page.locator('body[data-hydrated]').waitFor(); +} + +async function signInOnCurrentPage(page: Page, email: string) { + // Fills the login form already on screen — NO page.goto. A full + // navigation would reset the module singletons and hide the very leak + // this exercises. Wait for hydration first, so the client submit handler + // (which does the full-reload navigation) is wired up. Selectors are by + // attribute, not label: the logged-out login page is rendered in + // whatever language the previous user left, which is part of the point. + await page.locator('body[data-hydrated]').waitFor(); + await page.locator('input[name="email"]').fill(email); + await page.locator('input[name="password"]').fill('pablan-dev'); + await page.locator('input[name="password"]').press('Enter'); + await expect(page).toHaveURL('/'); + await page.locator('body[data-hydrated]').waitFor(); +} + +test('redirects anonymous visitors to the login page', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); +}); + +test('shows an error for wrong credentials', async ({ page }) => { + await gotoHydrated(page, '/login'); + await page.getByLabel('Email').fill('pablo@pablan.dev'); + await page.getByLabel('Password').fill('definitely-wrong'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('alert')).toHaveText('Email or password is incorrect.'); + await expect(page).toHaveURL(/\/login$/); +}); + +test('login and logout round-trip', async ({ page }) => { + await gotoHydrated(page, '/login'); + await page.getByLabel('Email').fill('pablo@pablan.dev'); + await page.getByLabel('Password').fill('pablan-dev'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page).toHaveURL('/'); + await expect(page.getByTestId('sidebar').getByText('Pablo')).toBeVisible(); + // The landing greets by first name and invites capture. + await expect(page.getByRole('heading', { name: /Pablo/ })).toBeVisible(); + + await logout(page); + + await page.goto('/'); + await expect(page).toHaveURL(/\/login$/); +}); + +// Regression for the P0 session-state leak: switching users in one browser +// context must not carry the previous user's conversation titles (an +// information disclosure) or their interface language across the boundary. +// Deliberately drives the real in-app flow — logout button, then the login +// form on the page it lands on — with no page.goto between users, which is +// what masked this in the other specs. +const MARKER = 'Zebrafrage-Session-P0-Test'; +let leakConversationId: string | null = null; + +test.afterEach(async ({ page }) => { + // End whatever session is current (max's UI login is never logged out by + // the test itself), then re-auth as pablo to undo his residue. + await page.request.post('/api/auth/logout'); + await page.request.post('/api/auth/login', { + data: { email: 'pablo@pablan.dev', password: 'pablan-dev' } + }); + await page.request.put('/api/account/locale', { data: { locale: null } }); + if (leakConversationId) { + await page.request.delete(`/api/conversations/${leakConversationId}`); + leakConversationId = null; + } + await page.request.post('/api/auth/logout'); +}); + +test('a different user does not inherit the previous session state', async ({ page }) => { + // Pablo: German interface and one conversation with a recognizable title. + await login(page, 'pablo@pablan.dev'); + await page.request.put('/api/account/locale', { data: { locale: 'de' } }); + const created = await page.request.post('/api/conversations', { data: { mode: 'query' } }); + leakConversationId = (await created.json()).id; + await page.request.post(`/api/conversations/${leakConversationId}/messages`, { + data: { content: MARKER } + }); + + // Reload (pablo → pablo) so the German setting and the new conversation + // are on screen before the switch. + await page.goto('/'); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.getByTestId('sidebar')).toContainText(MARKER); + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + + // Switch to Max through the UI, no page.goto. + await logout(page); + await page.locator('body[data-hydrated]').waitFor(); + await signInOnCurrentPage(page, 'max@pablan.dev'); + + // Max sees only his own world. + await expect(page.getByTestId('sidebar')).not.toContainText(MARKER); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); +}); diff --git a/frontend/e2e/capture.spec.ts b/frontend/e2e/capture.spec.ts new file mode 100644 index 0000000..773ab1b --- /dev/null +++ b/frontend/e2e/capture.spec.ts @@ -0,0 +1,91 @@ +import { expect, test, type Page } from '@playwright/test'; +import { login as loginAs } from './helpers'; + +// Writing-first capture as a USER sees it: pick a documentation type, write in +// the editor, get a refined version of the section at the cursor, accept it, +// read the diff before saving, and publish. The refine step drives the real +// LLM, so it can take a few seconds. + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +// Template names are matched in German on purpose: a template is product +// CONTENT in the instance's own language (PABLAN_DEFAULT_LOCALE), not UI copy +// that follows the reader's language setting. + +/** Draft documents this spec created, so cleanup removes only its own. */ +let created: string[] = []; + +async function newDraft(page: Page, templateText: string): Promise<string> { + await page.goto('/documents/new'); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('capture-template').filter({ hasText: templateText }).first().click(); + await expect(page).toHaveURL(/\/documents\/[0-9a-f-]{36}\/edit$/); + const id = page.url().split('/')[4]; + created.push(id); + await page.locator('.cm-content').waitFor(); + return id; +} + +test.afterEach(async ({ page }) => { + for (const id of created) { + await page.request.delete(`/api/documents/${id}`); + } + created = []; + await page.request.post('/api/auth/logout'); +}); + +test('picking a type opens the editor on the template skeleton', async ({ page }) => { + await loginAs(page, 'pablo@pablan.dev'); + await newDraft(page, 'Über dich'); + + // The editor opens on the skeleton headings, not an empty box, and nothing + // else: the suggestion is a block inside the text that appears when one + // streams, so before any typing there is no widget and nothing to accept. + await expect(page.getByTestId('editor-source')).toContainText('##'); + await expect(page.getByTestId('editor-suggestion')).toHaveCount(0); + await expect(page.getByTestId('editor-accept')).toHaveCount(0); +}); + +test('a typing pause yields a section suggestion that overwrites on accept', async ({ page }) => { + await loginAs(page, 'pablo@pablan.dev'); + await newDraft(page, 'Über dich'); + + // Write rough notes under the first heading. + await page.locator('.cm-content').click(); + await page.keyboard.press('Control+Home'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.type( + 'also der neue kollege wartet die cnc maschinen und ist ansprechpartner für den einkauf.' + ); + + // After the pause the model streams a refined version of the section. + await expect(page.getByTestId('editor-accept')).toBeVisible({ timeout: 40_000 }); + await page.getByTestId('editor-accept').click(); + + // Accepting consumes the suggestion (the pane returns to its empty state) + // and replaces the rough notes — the lowercase draft phrasing is gone. + await expect(page.getByTestId('editor-accept')).toHaveCount(0); + await expect(page.getByTestId('editor-source')).not.toContainText('also der neue kollege'); + + // Saving shows what changed first, VSCode-style, and asks again. + await page.getByTestId('editor-open-save').click(); + await expect(page.getByTestId('editor-diff')).toBeVisible(); +}); + +test('a draft is published from the editor in one step', async ({ page }) => { + await loginAs(page, 'pablo@pablan.dev'); + const id = await newDraft(page, 'Notiz'); + + await page.locator('.cm-content').click(); + await page.keyboard.type('\nEin kurzer, brauchbarer Inhalt zum Veröffentlichen.'); + + await page.getByTestId('editor-open-save').click(); + await page.getByTestId('editor-publish').click(); + + // Published, and the author is offered a colleague to check it. + await expect(page.getByTestId('capture-success')).toBeVisible(); + await page.getByTestId('success-view').click(); + await expect(page).toHaveURL(new RegExp(`/documents/${id}$`)); + await expect(page.getByTestId('document-status')).toHaveAttribute('data-status', 'published'); +}); diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts new file mode 100644 index 0000000..bdf3975 --- /dev/null +++ b/frontend/e2e/chat.spec.ts @@ -0,0 +1,246 @@ +import { expect, test, type Page } from '@playwright/test'; +import { login as loginAs, openChat, waitForTurnEnd } from './helpers'; + +// Runs against the dev stack with the REAL LLM endpoints and the seeded, +// indexed corpus (make seed + backend running). Streaming answers from the +// local model take seconds — generous timeouts on stream assertions. + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(120_000); + +async function login(page: Page) { + await loginAs(page, 'pablo@pablan.dev'); +} + +async function deleteNewestConversation(page: Page) { + const list = await (await page.request.get('/api/conversations')).json(); + if (list.length > 0) { + await page.request.delete(`/api/conversations/${list[0].id}`); + } +} + +// Zero-residue: every test cleans up its conversation; the session goes here. +test.afterEach(async ({ page }) => { + await page.request.post('/api/auth/logout'); +}); + +test('streams an answer with citations from the corpus', async ({ page }) => { + await login(page); + await openChat(page); + + await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?'); + await page.getByTestId('send-button').click(); + + // Retrieval progress is reported before the answer arrives. + await expect(page.getByTestId('retrieval-status')).toBeVisible({ timeout: 30_000 }); + + const assistant = page.getByTestId('assistant-message').last(); + await expect(assistant).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('sources').last()).toContainText( + 'Urlaubsanträge und Abwesenheiten', + { timeout: 30_000 } + ); + // Wait for the stream to finish, then check we got a real answer. + await waitForTurnEnd(page); + const answer = (await assistant.innerText()).trim(); + expect(answer.length).toBeGreaterThan(40); + // Transient: the status line disappears once the turn ends. + await expect(page.getByTestId('retrieval-status')).toHaveCount(0); + + // One badge per cited document, however many of its sections matched. + // Retrieval works on chunks, so this is exactly where duplicates appear. + const titles = await page + .getByTestId('sources') + .last() + .getByTestId('source-badge') + .allInnerTexts(); + const documentNames = titles.map((text) => text.replace(/\d+ sections$/, '').trim()); + expect(new Set(documentNames).size).toBe(documentNames.length); + + await deleteNewestConversation(page); +}); + +test('a citation shows its excerpt on hover and opens the document beside the chat', async ({ + page +}) => { + await login(page); + await openChat(page); + + await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?'); + await page.getByTestId('send-button').click(); + await expect(page.getByTestId('sources').last()).toBeVisible({ timeout: 45_000 }); + await waitForTurnEnd(page); + + const badge = page.getByTestId('source-badge').first(); + await badge.hover(); + // The popover carries the cited passage as plain text. + const tooltip = page.getByTestId('tooltip-content'); + await expect(tooltip).toBeVisible({ timeout: 10_000 }); + expect((await tooltip.innerText()).trim().length).toBeGreaterThan(20); + + await badge.click(); + const panel = page.getByTestId('document-panel'); + await expect(panel).toBeVisible(); + await expect(panel).toContainText('Urlaub', { timeout: 15_000 }); + + await panel.getByLabel('Close document panel').click(); + await expect(panel).toHaveCount(0); + await deleteNewestConversation(page); +}); + +test('an undocumented question offers to capture the knowledge', async ({ page }) => { + await login(page); + await openChat(page); + + // One of the eval-calibrated no-answer queries (tests/fixtures/ + // golden_queries.yaml), so this reliably takes the low-confidence path. + await page.getByTestId('chat-input').fill('Welche Gerichte gibt es in der Kantine für Veganer?'); + await page.getByTestId('send-button').click(); + await waitForTurnEnd(page); + + // The answer stands on general knowledge, but is labelled source-free + // and offers to close the gap by writing it down. + await expect(page.getByTestId('no-sources-note')).toBeVisible(); + // The link carries the conversation, so the draft starts with the question + // that exposed the gap as background. + await expect(page.getByTestId('capture-gap')).toHaveAttribute( + 'href', + /\/documents\/new\?conversation=/ + ); + await page.getByTestId('capture-gap').click(); + await expect(page).toHaveURL(/\/documents\/new\?conversation=/); + + // Only the query conversation was created; clean it up. + await deleteNewestConversation(page); +}); + +test('stop button aborts the stream and the partial answer survives reload', async ({ page }) => { + await login(page); + await openChat(page); + + await page + .getByTestId('chat-input') + .fill('Erkläre ausführlich und Schritt für Schritt den kompletten Reklamationsprozess.'); + await page.getByTestId('send-button').click(); + + // Wait until answer TOKENS have arrived (the .markdown body, not the + // sources badges which render first), then stop mid-stream. + const assistant = page.getByTestId('assistant-message').last(); + await expect(assistant).toBeVisible({ timeout: 30_000 }); + const answerBody = assistant.locator('.markdown'); + await expect + .poll(async () => (await answerBody.innerText()).trim().length, { timeout: 40_000 }) + .toBeGreaterThan(15); + const stop = page.getByTestId('stop-button'); + if (await stop.isVisible()) { + await stop.click(); + } + await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 90_000 }); + + // The (partial) assistant message is persisted server-side. + await page.reload(); + await page.locator('body[data-hydrated]').waitFor(); + // Sidebar rows are links; the only button in a row is "delete". + await page.getByTestId('conversation-list').getByRole('link').first().click(); + const restored = page.getByTestId('assistant-message').last(); + await expect(restored).toBeVisible({ timeout: 15_000 }); + expect((await restored.innerText()).trim().length).toBeGreaterThan(10); + await deleteNewestConversation(page); +}); + +test('deleting a conversation removes it from the list', async ({ page }) => { + await login(page); + // Create a dedicated conversation via the API so the test never deletes + // pre-existing user data (and doesn't depend on earlier tests' residue). + await page.request.post('/api/conversations', { data: { mode: 'query' } }); + await openChat(page); + + const list = page.getByTestId('conversation-list'); + await expect.poll(async () => list.locator('li').count(), { timeout: 15_000 }).toBeGreaterThan(0); + const before = await list.locator('li').count(); + + // Newest first: the untitled conversation we just created. Deleting asks + // first — it is destructive and the row carries no undo. + const ownRow = list.locator('li').filter({ hasText: 'New conversation' }).first(); + await ownRow.hover(); + await ownRow.getByLabel('Delete conversation').click(); + await page.getByTestId('confirm-accept').click(); + await expect.poll(async () => list.locator('li').count()).toBe(before - 1); +}); + +test('Pablan answers questions about itself, in the language they were asked', async ({ page }) => { + await login(page); + await openChat(page); + + // Deictic phrasing ("this app") and English, against German help pages: + // the built-in documentation has to carry both the self-reference and + // survive the language switch. + await page.getByTestId('chat-input').fill('How does this app work?'); + await page.getByTestId('send-button').click(); + await waitForTurnEnd(page); + + const assistant = page.getByTestId('assistant-message').last(); + await expect(assistant).toContainText('Pablan'); + await expect(page.getByTestId('sources').last()).toContainText('Pablan:', { + timeout: 15_000 + }); + // It was answered FROM the knowledge base, not from general knowledge. + await expect(page.getByTestId('no-sources-note')).toHaveCount(0); + + await deleteNewestConversation(page); +}); + +test('the first message moves the URL to the conversation, and Back leaves it', async ({ + page +}) => { + await login(page); + await openChat(page); + await expect(page).toHaveURL('/chat'); + + await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?'); + await page.getByTestId('send-button').click(); + + // The conversation gets its own address as soon as it exists, while the + // answer is still streaming. + await expect(page).toHaveURL(/\/chat\/[0-9a-f-]{36}$/, { timeout: 30_000 }); + const conversationUrl = page.url(); + await waitForTurnEnd(page); + // The stream survived the navigation: the state outlives the route. + expect( + (await page.getByTestId('assistant-message').last().innerText()).trim().length + ).toBeGreaterThan(20); + + // replaceState, so Back never lands on an orphaned empty composer. + await page.goBack(); + await expect(page).not.toHaveURL(conversationUrl); + + await deleteNewestConversation(page); +}); + +test('switching conversations abandons the previous view, and a foreign id is 404', async ({ + page +}) => { + await login(page); + // Two conversations, created via the API so nothing depends on ordering. + const first = await ( + await page.request.post('/api/conversations', { data: { mode: 'query' } }) + ).json(); + const second = await ( + await page.request.post('/api/conversations', { data: { mode: 'query' } }) + ).json(); + + await page.goto(`/chat/${first.id}`); + await page.locator('body[data-hydrated]').waitFor(); + await page.goto(`/chat/${second.id}`); + await page.locator('body[data-hydrated]').waitFor(); + // Nothing bled across: the second conversation is empty. + await expect(page.getByTestId('assistant-message')).toHaveCount(0); + + // An id that is not yours is indistinguishable from one that does not + // exist — existence must not leak. + await page.goto('/chat/00000000-0000-4000-8000-000000000000'); + await expect(page.locator('body')).toContainText('404'); + + await page.request.delete(`/api/conversations/${first.id}`); + await page.request.delete(`/api/conversations/${second.id}`); +}); diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts new file mode 100644 index 0000000..0743dec --- /dev/null +++ b/frontend/e2e/documents.spec.ts @@ -0,0 +1,183 @@ +import { expect, test, type Page } from '@playwright/test'; +import { login as loginAs } from './helpers'; + +// The full knowledge loop against the REAL model: author a document in the +// writing editor (content set via the API for speed, the way the editor saves +// it), publish it in the UI, then retrieve the new knowledge via chat. +// Uses the seeded dev stack (make seed). + +test.setTimeout(240_000); + +async function login(page: Page) { + await loginAs(page, 'pablo@pablan.dev'); +} + +// Zero-residue: each test's login session goes here. +test.afterEach(async ({ page }) => { + await page.request.post('/api/auth/logout'); +}); + +test('author → publish → ask-about-it, entirely in the product', async ({ page }) => { + await login(page); + + // --- create a draft from a template (API, browser cookies shared) ----- + const templates = await (await page.request.get('/api/templates')).json(); + // German: a template is product content in the instance's language. + const blueprint = templates.find((t: { name: string }) => t.name.startsWith('Ablauf')); + expect(blueprint).toBeTruthy(); + + const draft = await ( + await page.request.post('/api/documents', { data: { template_id: blueprint.id } }) + ).json(); + const documentId = draft.id; + expect(draft.status).toBe('draft'); + + // Write the document. Done via the API (the editor autosaves the same way) + // so this loop does not depend on the LLM suggestion — capture.spec covers + // that path. + await page.request.patch(`/api/documents/${documentId}`, { + data: { + content_md: + '## Rolle und Aufgaben\n\nInstandhaltung der CNC-Maschinen in Halle 1.\n\n' + + '## Ansprechpartner\n\nDie interne Notfallnummer der Instandhaltung ist die 4455.' + } + }); + + // --- publish it in the UI -------------------------------------------- + await page.goto(`/documents/${documentId}`); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('publish-button').click(); + await expect(page.getByTestId('document-status')).toHaveAttribute('data-status', 'published', { + timeout: 15_000 + }); + + // --- the new knowledge is retrievable via chat ------------------------ + await page.waitForTimeout(5_000); // let the index job embed the chunks + await page.getByTestId('sidebar-new-conversation').click(); + await page + .getByTestId('chat-input') + .fill('Wie lautet die interne Notfallnummer der Instandhaltung?'); + await page.getByTestId('send-button').click(); + await expect(page.getByTestId('sources').last()).toContainText('Ablauf', { + timeout: 45_000 + }); + await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 }); + expect(await page.getByTestId('assistant-message').last().innerText()).toContain('4455'); + + // --- cleanup so the spec is re-runnable ------------------------------- + await page.request.delete(`/api/documents/${documentId}`); + // The chat question above auto-created a query conversation (newest first). + const conversations = await (await page.request.get('/api/conversations')).json(); + if (conversations.length > 0) { + await page.request.delete(`/api/conversations/${conversations[0].id}`); + } +}); + +// Edit rights are deliberately NOT shown here — editing starts by opening +// the document, so the list only answers "why can I see this?". +test('the list marks what needs attention and filters by access', async ({ page }) => { + await login(page); + await page.goto('/documents'); + await page.locator('body[data-hydrated]').waitFor(); + + const list = page.getByTestId('document-list'); + await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 }); + + // Rows are quiet unless something is off: the document somebody asked this + // user to check is flagged, and the normal case (published, yours, public) + // carries no badge. + await expect(page.getByTestId('open-review-badge').first()).toBeVisible(); + + // Built-in help pages read as product content, not as someone's document. + await expect(list).toContainText('Built-in'); + + // Filtering by access is client-side over the same rows: a + // department-only document disappears under "Public" and comes back. + const before = await list.locator('li').count(); + // The narrowing controls live behind the filter toggle; only the filters + // that are ON stay visible, as removable chips. + await page.getByTestId('filters-toggle').click(); + await page.getByTestId('access-filter').getByRole('button', { name: 'Public' }).click(); + await expect(list).not.toContainText('Wartungsplan CNC-Fräse'); + expect(await list.locator('li').count()).toBeLessThan(before); + + await page.getByTestId('access-filter').getByRole('button', { name: 'All' }).click(); + await expect(list).toContainText('Wartungsplan CNC-Fräse'); + + // A filter that is on stays visible after the panel is closed, and can be + // dropped from there — a quietly filtered list would lie about what exists. + await page.getByTestId('access-filter').getByRole('button', { name: 'Public' }).click(); + await page.getByTestId('filters-toggle').click(); + await expect(page.getByTestId('active-filters')).toContainText('Public'); + await page.getByTestId('active-filters').getByRole('button').first().click(); + await expect(list).toContainText('Wartungsplan CNC-Fräse'); +}); + +test('a built-in help page cannot be edited or deleted', async ({ page }) => { + await login(page); + const found = (await (await page.request.get('/api/documents?search=Überblick')).json()).items; + const builtin = found.find((d: { is_builtin: boolean }) => d.is_builtin); + expect(builtin).toBeTruthy(); + + await page.goto(`/documents/${builtin.id}`); + await page.locator('body[data-hydrated]').waitFor(); + const actions = page.getByRole('main'); + await expect(actions.getByLabel('Edit')).toHaveCount(0); + await expect(actions.getByLabel('Delete')).toHaveCount(0); + + // The API refuses the edit as well, not just the UI. + const patched = await page.request.patch(`/api/documents/${builtin.id}`, { + data: { title: 'Hijacked' } + }); + expect(patched.status()).toBe(409); +}); + +test('search finds a document by meaning, not just by title', async ({ page }) => { + await login(page); + await page.goto('/documents'); + await page.locator('body[data-hydrated]').waitFor(); + + // Wording that appears nowhere in the title — only hybrid retrieval finds it. + await page.getByTestId('document-search').fill('Wie oft wird die Fräse gewartet'); + + const list = page.getByTestId('document-list'); + await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 20_000 }); + // Hits name the section they matched. + await expect(list.locator('li').first()).toContainText('Wartungsplan'); +}); + +test('the list can be sorted and paged, and the sort sticks', async ({ page }) => { + await login(page); + await page.goto('/documents'); + await page.locator('body[data-hydrated]').waitFor(); + + const list = page.getByTestId('document-list'); + await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 }); + // Grid is the only layout for now. + await expect(list).toHaveAttribute('data-layout', 'grid'); + + // Sorting is server-side, so the set stays the same size. + await page.getByTestId('filters-toggle').click(); + await page.getByTestId('sort-select').selectOption('created'); + await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 }); + + // The sort choice is per device and survives a reload (the filter panel + // itself does not — it opens closed, which is the point of it). + await page.reload(); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('filters-toggle').click(); + await expect(page.getByTestId('sort-select')).toHaveValue('created', { timeout: 15_000 }); + + // Paging: force a small page so the pager appears regardless of corpus size. + const small = await (await page.request.get('/api/documents?per_page=2&page=1')).json(); + expect(small.items.length).toBeLessThanOrEqual(2); + expect(small.total).toBeGreaterThan(2); + const second = await (await page.request.get('/api/documents?per_page=2&page=2')).json(); + const firstIds = small.items.map((d: { id: string }) => d.id); + const secondIds = second.items.map((d: { id: string }) => d.id); + expect(firstIds.some((id: string) => secondIds.includes(id))).toBe(false); + + // Zero residue: back to the default sort (the panel is still open). + await page.getByTestId('sort-select').selectOption('updated'); + await expect(page.getByTestId('sort-select')).toHaveValue('updated'); +}); diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..21362ba --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,3 @@ +import { globalSetup } from './residue'; + +export default globalSetup; diff --git a/frontend/e2e/global-teardown.ts b/frontend/e2e/global-teardown.ts new file mode 100644 index 0000000..c2250bc --- /dev/null +++ b/frontend/e2e/global-teardown.ts @@ -0,0 +1,3 @@ +import { globalTeardown } from './residue'; + +export default globalTeardown; diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts new file mode 100644 index 0000000..a4e87dd --- /dev/null +++ b/frontend/e2e/helpers.ts @@ -0,0 +1,71 @@ +// Shared navigation helpers. Every spec used to inline these; they live +// here so a change to the app shell is a one-file fix. + +import { expect, type Page } from '@playwright/test'; + +export const DEV_PASSWORD = 'pablan-dev'; + +export async function login(page: Page, email: string, password = DEV_PASSWORD) { + await page.goto('/login'); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL('/'); + // The specs assert English copy and pin it with Accept-Language, which is + // the strategy for a visitor with no ACCOUNT preference. A language picked + // in the settings dialog outranks it (that is the product rule), so a dev + // stack where somebody switched to German would fail every spec that reads + // a label. Hand the account back to "follow the browser" on the way in. + await page.request.put('/api/account/locale', { data: { locale: null } }); + // Wait for the landing page to hydrate: filling an input before Svelte + // binds it leaves the state empty and submit buttons disabled. + await page.locator('body[data-hydrated]').waitFor(); +} + +/** Log out through the sidebar user menu. */ +export async function logout(page: Page) { + // Login/logout are full document reloads (session state must not survive + // the boundary), so the page may be freshly server-rendered and not yet + // interactive when a test reaches here; a click before hydration does + // nothing. A real user is far slower than hydration. + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('user-menu').click(); + await page.getByTestId('logout').click(); + await expect(page).toHaveURL(/\/login$/); +} + +export async function openChat(page: Page) { + // By testid: an untitled conversation row carries the same label. + await page.getByTestId('sidebar-new-conversation').click(); + await expect(page).toHaveURL(/\/chat/); +} + +export async function openDocuments(page: Page) { + await page.getByTestId('sidebar').getByRole('link', { name: 'Documents' }).click(); + await expect(page).toHaveURL(/\/documents$/); +} + +export async function openAdmin(page: Page) { + await page.getByTestId('sidebar').getByRole('link', { name: 'Admin' }).click(); + await expect(page).toHaveURL(/\/admin$/); +} + +/** The admin page is four unrelated jobs behind four tabs; every spec has to + * say which one it is doing. + * + * By testid, not by label: the account's own language setting outranks the + * Accept-Language this suite pins (that is the product rule — a language a + * person chose follows them), and the admin used here has picked one. */ +export async function openAdminTab(page: Page, tab: 'people' | 'templates' | 'llm' | 'prompts') { + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId(`tab-${tab}`).click(); +} + +/** Call right after triggering a turn: waits for it to actually start (the + * stop button appears) and then to finish. Waiting on the send button alone + * races, because it is still visible for a moment after the click. */ +export async function waitForTurnEnd(page: Page) { + await expect(page.getByTestId('stop-button')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 120_000 }); +} diff --git a/frontend/e2e/landing.spec.ts b/frontend/e2e/landing.spec.ts new file mode 100644 index 0000000..5926e71 --- /dev/null +++ b/frontend/e2e/landing.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { login, logout } from './helpers'; + +// The landing is the front door: one input that starts a real conversation, +// and three chips. + +test.setTimeout(120_000); + +test.afterEach(async ({ page }) => { + await page.request.post('/api/auth/logout'); +}); + +test('the landing input starts a conversation and streams an answer', async ({ page }) => { + await login(page, 'pablo@pablan.dev'); + + await page.getByTestId('landing-input').fill('Wie beantrage ich Urlaub?'); + await page.getByTestId('landing-send').click(); + + // Hand-off to the chat page, which sends the question straight away. + await expect(page).toHaveURL(/\/chat/); + const assistant = page.getByTestId('assistant-message').last(); + await expect(assistant).toBeVisible({ timeout: 45_000 }); + // The turn is over once the stop button is gone. + await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 90_000 }); + expect((await assistant.innerText()).trim().length).toBeGreaterThan(40); + + // The conversation shows up in the sidebar. + await expect(page.getByTestId('conversation-list')).toContainText('Urlaub'); + + // Reloading reopens the same conversation instead of re-asking: the + // hand-off parameter is replaced by the conversation's own route. + const list = await (await page.request.get('/api/conversations')).json(); + const created = list.find((c: { title: string }) => c.title?.includes('Urlaub')); + expect(created).toBeTruthy(); + await expect(page).toHaveURL(`/chat/${created.id}`); + + await page.reload(); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.getByTestId('assistant-message')).toHaveCount(1); + const after = await (await page.request.get(`/api/conversations/${created.id}`)).json(); + expect(after.messages).toHaveLength(2); // still one question, one answer + + await page.request.delete(`/api/conversations/${created.id}`); +}); + +test('the chips lead to capture and documents', async ({ page }) => { + await login(page, 'pablo@pablan.dev'); + + await page.getByTestId('chip-find').click(); + await expect(page).toHaveURL(/\/documents$/); + + await page.goBack(); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('chip-capture').click(); + await expect(page).toHaveURL(/\/documents\/new$/); + await expect(page.getByTestId('capture-template').first()).toBeVisible({ timeout: 15_000 }); + await logout(page); +}); diff --git a/frontend/e2e/permissions.spec.ts b/frontend/e2e/permissions.spec.ts new file mode 100644 index 0000000..714cded --- /dev/null +++ b/frontend/e2e/permissions.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, type Page } from '@playwright/test'; +import { login as loginAs, logout } from './helpers'; + +// Permission boundary in the browser, against the restricted corpus +// document "Wissenssicherung: Werner Krause" (visibility: restricted, +// granted to Engineering only). Pablo (Engineering) sees it, Max (Sales) +// must not — in the list, in the detail view, and in chat sources. + +test.setTimeout(120_000); + +async function login(page: Page, email: string) { + await loginAs(page, email); +} + +test('restricted corpus document stays invisible across list, detail and chat', async ({ + page +}) => { + // Pablo (Engineering, has the grant) can see it — and we grab the id. + await login(page, 'pablo@pablan.dev'); + const fromApi = (await (await page.request.get('/api/documents?search=Wissenssicherung')).json()) + .items; + expect(fromApi.length).toBeGreaterThan(0); + const restrictedId = fromApi[0].id; + + await page.goto('/documents'); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('document-search').fill('Wissenssicherung'); + await expect(page.getByTestId('document-list')).toContainText('Wissenssicherung: Werner Krause', { + timeout: 10_000 + }); + await logout(page); + + // Max (Sales): list is empty, detail 404s, chat cites nothing restricted. + await login(page, 'max@pablan.dev'); + await page.goto('/documents'); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('document-search').fill('Wissenssicherung Werner Krause'); + // Search ranks by meaning, so it returns related documents rather than + // nothing — what matters is that the restricted one is never among them. + await expect( + page.getByTestId('document-list').or(page.getByText('No documents match')) + ).toBeVisible({ + timeout: 15_000 + }); + await expect(page.locator('body')).not.toContainText('Wissenssicherung: Werner Krause'); + + await page.goto(`/documents/${restrictedId}`); + await expect(page.getByText('Document not found')).toBeVisible(); + + await page.getByTestId('sidebar-new-conversation').click(); + await page + .getByTestId('chat-input') + .fill('Welcher Servotec-Techniker kennt die F-350 am besten?'); + await page.getByTestId('send-button').click(); + await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 }); + const sources = page.getByTestId('sources'); + if ((await sources.count()) > 0) { + await expect(sources.last()).not.toContainText('Wissenssicherung'); + } + + // Cleanup: ben's chat question created a conversation; drop it + session. + const conversations = await (await page.request.get('/api/conversations')).json(); + if (conversations.length > 0) { + await page.request.delete(`/api/conversations/${conversations[0].id}`); + } + await page.request.post('/api/auth/logout'); +}); diff --git a/frontend/e2e/residue.ts b/frontend/e2e/residue.ts new file mode 100644 index 0000000..9b7f55b --- /dev/null +++ b/frontend/e2e/residue.ts @@ -0,0 +1,58 @@ +// Zero-residue guard: the e2e suite must leave the dev database exactly as +// it found it. globalSetup snapshots row counts, globalTeardown re-counts +// and fails the run on any difference. +// +// Documented exception: done/failed rows in `jobs` are execution history +// (the queue's audit trail); only unprocessed jobs count as residue. + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const TABLES = [ + 'users', + 'departments', + 'templates', + 'conversations', + 'messages', + 'documents', + 'chunks', + 'doc_permissions', + 'auth_sessions', + 'llm_settings' +]; + +const SNAPSHOT = path.join(import.meta.dirname, '..', 'test-results', 'row-counts.json'); + +function query(sql: string): string { + return execSync(`docker exec pablan-dev-postgres-1 psql -tA -U pablan -d pablan -c "${sql}"`) + .toString() + .trim(); +} + +export function snapshotCounts(): Record<string, number> { + const counts: Record<string, number> = {}; + for (const table of TABLES) { + counts[table] = Number(query(`SELECT count(*) FROM ${table}`)); + } + counts['jobs (unprocessed)'] = Number( + query(`SELECT count(*) FROM jobs WHERE status IN ('pending','running')`) + ); + return counts; +} + +export async function globalSetup(): Promise<void> { + fs.mkdirSync(path.dirname(SNAPSHOT), { recursive: true }); + fs.writeFileSync(SNAPSHOT, JSON.stringify(snapshotCounts(), null, 2)); +} + +export async function globalTeardown(): Promise<void> { + const before = JSON.parse(fs.readFileSync(SNAPSHOT, 'utf-8')) as Record<string, number>; + const after = snapshotCounts(); + const diffs = Object.keys(after) + .filter((key) => before[key] !== after[key]) + .map((key) => ` ${key}: ${before[key]} -> ${after[key]}`); + if (diffs.length > 0) { + throw new Error(`e2e suite left residue in the database:\n${diffs.join('\n')}`); + } +} diff --git a/frontend/e2e/reviews.spec.ts b/frontend/e2e/reviews.spec.ts new file mode 100644 index 0000000..6708b54 --- /dev/null +++ b/frontend/e2e/reviews.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { login as loginAs, logout } from './helpers'; + +// Asking a colleague to check something, end to end and without the model: +// the author asks, the document is marked as unsettled for everyone, the +// colleague answers, and the mark goes away with the answer. + +test.setTimeout(120_000); + +test('a question travels with the document until the colleague answers it', async ({ page }) => { + await loginAs(page, 'pablo@pablan.dev'); + + // A published document everyone may read, so Max is a candidate reviewer. + const created = await ( + await page.request.post('/api/documents', { + data: { title: 'Notfallnummern Halle 1', visibility: 'public' } + }) + ).json(); + const documentId = created.id; + await page.request.patch(`/api/documents/${documentId}`, { + data: { content_md: '## Notfall\n\nDie Nummer der Instandhaltung ist die 4455.' } + }); + await page.request.post(`/api/documents/${documentId}/publish`); + + await page.goto(`/documents/${documentId}`); + await page.locator('body[data-hydrated]').waitFor(); + await page.getByTestId('document-menu').click(); + await page.getByTestId('document-ask-review').click(); + await page.getByTestId('reviewer-select').selectOption({ label: 'Max' }); + await page.getByTestId('review-question').fill('Stimmt die 4455 noch?'); + await page.getByTestId('review-ask-send').click(); + + // The question is on the document, and it says who is waiting on whom. + await expect(page.getByTestId('open-reviews')).toContainText('Stimmt die 4455 noch?'); + await expect(page.getByTestId('open-reviews')).toContainText('Max'); + // The author cannot answer their own question, only drop it. + await expect(page.getByTestId('review-confirm')).toHaveCount(0); + await expect(page.getByTestId('review-close')).toBeVisible(); + await logout(page); + + // Max was asked: it is in his queue, he may edit it, and he answers. + await loginAs(page, 'max@pablan.dev'); + await page.goto('/documents?review=1'); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.getByTestId('document-list')).toContainText('Notfallnummern Halle 1'); + await expect(page.getByTestId('open-review-badge').first()).toBeVisible(); + + await page.goto(`/documents/${documentId}`); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.getByTestId('document-edit')).toBeVisible(); + await page.getByTestId('review-confirm').click(); + + // Answered: no open question left, and the record says who checked it. + await expect(page.getByTestId('open-reviews')).toHaveCount(0); + await expect(page.getByTestId('answered-reviews')).toContainText('Max'); + // The grant went with the answer — Max may read it, not change it. + await expect(page.getByTestId('document-edit')).toHaveCount(0); + await logout(page); + + await loginAs(page, 'pablo@pablan.dev'); + await page.request.delete(`/api/documents/${documentId}`); + await page.request.post('/api/auth/logout'); +}); + +test('answering on a draft hands it back instead of dropping you on a 404', async ({ page }) => { + await loginAs(page, 'pablo@pablan.dev'); + const created = await ( + await page.request.post('/api/documents', { + data: { title: 'Entwurf zum Gegenlesen', visibility: 'public' } + }) + ).json(); + const documentId = created.id; + await page.request.patch(`/api/documents/${documentId}`, { + data: { content_md: '## Stand\n\nNoch nicht fertig.' } + }); + const max = ( + await (await page.request.get(`/api/documents/${documentId}/reviewers`)).json() + ).find((candidate: { name: string }) => candidate.name === 'Max'); + await page.request.post(`/api/documents/${documentId}/reviews`, { + data: { reviewer_id: max.id, question: 'Passt der Stand so?' } + }); + await logout(page); + + // Max only sees the draft because he was asked, and the page says so. + await loginAs(page, 'max@pablan.dev'); + await page.goto(`/documents/${documentId}`); + await page.locator('body[data-hydrated]').waitFor(); + await expect(page.getByTestId('open-reviews')).toContainText('Passt der Stand so?'); + // He can see who may read it, and change neither that nor the publish state. + await page.getByTestId('access-chip').click(); + await expect(page.getByTestId('access-controls')).toBeVisible(); + await expect(page.getByTestId('visibility-select')).toHaveCount(0); + await expect(page.getByTestId('share-save')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('publish-button')).toHaveCount(0); + + // Answering ends that access, so the page thanks him instead of 404ing. + await page.getByTestId('review-confirm').click(); + await expect(page.getByTestId('review-done')).toBeVisible(); + await logout(page); + + await loginAs(page, 'pablo@pablan.dev'); + await page.request.delete(`/api/documents/${documentId}`); + await page.request.post('/api/auth/logout'); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ed35999 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,41 @@ +import prettier from 'eslint-config-prettier'; +import path from 'node:path'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig, includeIgnoreFile } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; + +const gitignorePath = path.resolve(import.meta.dirname, '.gitignore'); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ts.configs.recommended, + svelte.configs.recommended, + prettier, + svelte.configs.prettier, + { + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser + } + } + }, + { + // Override or add rule settings here, such as: + // 'svelte/button-has-type': 'error' + rules: {} + } +); diff --git a/frontend/messages/de.json b/frontend/messages/de.json new file mode 100644 index 0000000..42f1fc3 --- /dev/null +++ b/frontend/messages/de.json @@ -0,0 +1,501 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "settings_title": "Einstellungen", + "settings_section_appearance": "Darstellung", + "settings_section_language": "Sprache", + "settings_section_security": "Sicherheit", + "settings_theme_system": "System", + "settings_theme_light": "Hell", + "settings_theme_dark": "Dunkel", + "settings_locale_automatic": "Automatisch", + "settings_locale_hint": "Automatisch folgt deinem Browser. Deine Auswahl wird in deinem Konto gespeichert und gilt auf jedem Gerät.", + "settings_password_change": "Passwort ändern", + "settings_password_current": "Aktuelles Passwort", + "settings_password_new": "Neues Passwort", + "settings_password_repeat": "Neues Passwort wiederholen", + "settings_password_other_devices": "Deine anderen Geräte werden abgemeldet, dieses bleibt angemeldet.", + "settings_password_changed": "Passwort geändert. Andere Geräte wurden abgemeldet.", + "settings_password_mismatch": "Die neuen Passwörter stimmen nicht überein.", + "settings_password_wrong_current": "Das ist nicht dein aktuelles Passwort.", + "settings_password_failed": "Passwort konnte nicht geändert werden. Bitte versuch es erneut.", + "settings_administration": "Administration", + "settings_logout": "Abmelden", + "common_cancel": "Abbrechen", + "common_delete": "Löschen", + "login_page_title": "Anmelden", + "login_subtitle": "Melde dich mit deinem Firmenkonto an.", + "login_email": "E-Mail", + "login_password": "Passwort", + "login_submit": "Anmelden", + "login_submitting": "Wird angemeldet …", + "login_error_credentials": "E-Mail oder Passwort ist falsch.", + "login_error_generic": "Anmeldung fehlgeschlagen. Bitte versuch es später erneut.", + "nav_new_conversation": "Neues Gespräch", + "nav_documents": "Dokumente", + "nav_administration": "Administration", + "nav_settings": "Einstellungen", + "nav_sidebar_expand": "Seitenleiste ausklappen", + "nav_sidebar_collapse": "Seitenleiste einklappen", + "nav_conversation_untitled": "Neues Gespräch", + "nav_conversation_delete": "Gespräch löschen", + "landing_greeting_morning": "Guten Morgen, {name}", + "landing_greeting_afternoon": "Guten Tag, {name}", + "landing_greeting_evening": "Guten Abend, {name}", + "landing_prompt": "Was möchtest du heute festhalten?", + "landing_input_placeholder": "Stell eine Frage oder beschreib, was du weißt …", + "landing_input_note": "Antworten nennen die Dokumente, aus denen sie stammen.", + "landing_ask": "Fragen", + "landing_chip_capture": "Wissen festhalten", + "landing_chip_find": "Dokument finden", + "landing_setup_title": "Pablan einrichten", + "landing_setup_departments": "Abteilungen anlegen", + "landing_setup_departments_hint": "Sie entscheiden, wer was sieht.", + "landing_setup_invite": "Kolleginnen und Kollegen einladen", + "landing_setup_first_capture": "Die erste geführte Dokumentation starten", + "documents_page_title": "Dokumente", + "documents_search_placeholder": "Wissensbasis durchsuchen …", + "documents_filter_all_statuses": "Alle Status", + "documents_filter_all_departments": "Alle Abteilungen", + "documents_filter_disabled_hint": "Filter gelten beim Blättern, nicht für Suchergebnisse.", + "documents_sort_disabled_hint": "Suchergebnisse sind nach Relevanz sortiert.", + "documents_sort_updated": "Zuletzt geändert", + "documents_sort_created": "Neueste zuerst", + "documents_access_all": "Alle", + "documents_access_mine": "Meine", + "documents_access_department": "Abteilung", + "documents_access_public": "Öffentlich", + "documents_access_granted": "Freigegeben", + "documents_access_label_author": "Von dir", + "documents_access_label_department": "Abteilung", + "documents_access_label_public": "Öffentlich", + "documents_access_label_granted": "Freigegeben", + "documents_access_hint_author": "Du hast dieses Dokument erstellt.", + "documents_access_hint_department": "Mit deiner Abteilung geteilt.", + "documents_access_hint_public": "Für alle im Unternehmen sichtbar.", + "documents_access_hint_granted": "Deine Abteilung hat Zugriff erhalten.", + "documents_status_draft": "Entwurf", + "documents_status_published": "Veröffentlicht", + "documents_status_archived": "Archiviert", + "documents_badge_builtin": "Mitgeliefert", + "documents_no_department": "Keine Abteilung", + "documents_loading": "Wird geladen …", + "documents_empty": "Keine Dokumente passen zu den aktuellen Filtern.", + "documents_updated_at": "Geändert {date}", + "documents_created_at": "Erstellt {date}", + "history_title": "Verlauf", + "history_empty": "Noch keine Änderungen aufgezeichnet.", + "history_by": "von {actor}", + "history_actor_unknown": "Unbekannt", + "history_view_changes": "Änderungen ansehen", + "history_diff_title": "Änderungen in dieser Version", + "history_restore": "Diese Version wiederherstellen", + "history_action_created": "Erstellt", + "history_action_edited": "Bearbeitet", + "history_action_archived": "Archiviert", + "history_action_visibility_changed": "Sichtbarkeit geändert", + "nav_people": "Kolleg:innen", + "people_title": "Kolleg:innen", + "people_subtitle": "Finde heraus, wer im Team was macht.", + "people_no_department": "Keine Abteilung", + "people_role_admin": "Admin", + "people_not_found": "Diese Person gibt es nicht.", + "people_back": "Zurück zum Verzeichnis", + "profile_edit_action": "Profil bearbeiten", + "profile_edit_title": "Mein Profil", + "profile_edit_subtitle": "Halt fest, was du weißt, damit Kolleg:innen es finden.", + "profile_view_public": "Öffentliches Profil ansehen", + "settings_edit_profile": "Profil bearbeiten", + "document_delete_confirm": "Dieses Dokument wird dauerhaft gelöscht.", + "conversation_delete_confirm": "Dieses Gespräch wird dauerhaft gelöscht.", + "sharing_shared_with": "Geteilt mit", + "sharing_manage": "Teilen verwalten", + "sharing_share": "Mit Abteilungen teilen", + "sharing_dialog_title": "Mit Abteilungen teilen", + "sharing_dialog_hint": "Diese Abteilungen dürfen das Dokument zusätzlich lesen.", + "sharing_none_shareable": "Es gibt keine weiteren Abteilungen.", + "sharing_save": "Speichern", + "sharing_save_failed": "Speichern fehlgeschlagen.", + "sharing_lockout_warning": "Nach dieser Änderung verlierst du selbst den Zugriff auf dieses Dokument.", + "sharing_lockout_confirm": "Trotzdem speichern", + "admin_prompts_title": "System-Prompts", + "admin_prompts_hint": "Diese Anweisungen steuern, wie das Modell antwortet und Texte verbessert. Änderungen greifen sofort, ohne Neustart.", + "admin_prompt_default": "Standard", + "admin_prompt_changed": "Geändert", + "admin_prompt_save": "Speichern", + "admin_prompt_saved": "Gespeichert", + "admin_prompt_reset": "Zurücksetzen", + "admin_prompt_query_system": "Chat-Assistent (System)", + "admin_prompt_query_no_sources": "Chat: keine Treffer", + "admin_prompt_refine_persona": "Textverbesserung: Persona", + "admin_prompt_refine_rules": "Textverbesserung: Regeln", + "admin_prompt_grounding_framing": "Textverbesserung: Wissensbezug", + "admin_prompt_topic_summary": "Themen-Zusammenfassung", + "admin_prompt_title": "Titelvorschlag", + "landing_review_open": "Jetzt prüfen", + "landing_review_pending": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Ein Dokument wartet auf deine Prüfung.", + "countPlural=other": "{count} Dokumente warten auf deine Prüfung." + } + } + ], + "documents_pager_previous": "Zurück", + "documents_pager_next": "Weiter", + "documents_pager_status": "Seite {page} von {pages}, {total} Dokumente", + "document_fallback_title": "Dokument", + "document_not_found_title": "Dokument nicht gefunden", + "document_not_found_body": "Es existiert nicht, oder du hast keinen Zugriff darauf.", + "document_back_to_list": "Zurück zu den Dokumenten", + "document_action_archive": "Archivieren", + "document_action_republish": "Wieder veröffentlichen", + "document_action_edit": "Bearbeiten", + "document_action_delete": "Löschen", + "document_builtin_note": "Teil von Pablan. Diese Seite gehört zum Produkt und wird mit ihm aktualisiert.", + "document_status_line": "Status: {status}", + "document_visibility_line": "Sichtbarkeit: {visibility}", + "document_can_edit": "Du kannst das bearbeiten", + "document_read_only": "Nur lesbar", + "document_visibility_public": "Öffentlich, ganzes Unternehmen", + "document_visibility_department": "Nur Abteilung", + "document_visibility_restricted": "Eingeschränkt, nur mit Freigabe", + "document_save_failed": "Speichern fehlgeschlagen. Bitte versuch es erneut.", + "chat_page_title": "Chat", + "chat_capture_button": "Wissen festhalten", + "chat_status_searching": "Durchsuche die Wissensbasis …", + "chat_status_results": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Eine relevante Stelle gefunden", + "countPlural=other": "{count} relevante Stellen gefunden" + } + } + ], + "chat_status_no_answer": "Dazu ist noch nichts dokumentiert", + "chat_status_queued": "Das Modell ist gerade ausgelastet", + "chat_status_answering": "Erstelle Antwort …", + "chat_empty_query": "Stell eine Frage zur Wissensbasis deines Unternehmens.", + "chat_input_placeholder": "Stell eine Frage …", + "chat_stop": "Erzeugung stoppen", + "chat_send": "Senden", + "chat_no_sources_note": "Ohne die Wissensbasis beantwortet, dazu ist noch nichts dokumentiert.", + "chat_capture_gap": "Dieses Wissen festhalten", + "chat_context_label": "Kontext", + "chat_context_title": "Worauf diese Antwort fußt:", + "chat_context_used": "verwendet", + "chat_context_unused": "zu unsicher", + "panel_open_full_page": "Ganze Seite öffnen", + "panel_close": "Dokumentenpanel schließen", + "admin_page_title": "Administration", + "admin_users_title": "Benutzer", + "admin_users_email": "E-Mail", + "admin_users_name": "Name", + "admin_users_role": "Rolle", + "admin_users_department": "Abteilung", + "admin_users_actions": "Aktionen", + "admin_users_reset_password": "Passwort zurücksetzen", + "admin_users_delete": "Löschen", + "admin_users_no_department": "Keine Abteilung", + "admin_users_password": "Passwort", + "admin_users_create": "Benutzer anlegen", + "admin_users_create_failed": "Benutzer konnte nicht angelegt werden.", + "admin_users_reset_failed": "Zurücksetzen fehlgeschlagen (mindestens 8 Zeichen).", + "admin_users_delete_confirm": "{email} löschen? Die Dokumente bleiben bestehen, ohne Autor.", + "admin_departments_title": "Abteilungen", + "admin_departments_new": "Neue Abteilung", + "admin_departments_create": "Anlegen", + "admin_departments_create_failed": "Abteilung konnte nicht angelegt werden.", + "admin_departments_delete_confirm": "Abteilung \"{name}\" löschen? Mitglieder und Dokumente bleiben ohne sie bestehen.", + "admin_templates_title": "Dokumentationsvorlagen", + "admin_llm_title": "Sprachmodell-Endpunkte", + "admin_llm_test": "Endpunkte testen", + "admin_llm_testing": "Wird getestet …", + "admin_llm_intro": "Diese Werte wurden beim ersten Start aus .env übernommen. Seitdem liegen sie in der Datenbank, spätere Änderungen an .env wirken nicht mehr. Speichern greift sofort, ohne Neustart.", + "admin_llm_base_url": "Basis-URL", + "admin_llm_model": "Modell", + "admin_llm_api_key": "API-Schlüssel", + "admin_llm_api_key_unset": "nicht gesetzt", + "admin_llm_api_key_note": "Ein leeres Schlüsselfeld behält den gespeicherten Schlüssel.", + "admin_llm_source_env": "aus .env", + "admin_llm_source_ui": "hier geändert", + "admin_llm_reset_field": "Auf den .env-Wert zurücksetzen", + "admin_llm_check_models": "Modelle abfragen", + "admin_llm_checking": "Wird geprüft …", + "admin_llm_enter_manually": "Selbst eintragen", + "admin_llm_no_model_list": "Dieser Endpunkt veröffentlicht keine Modellliste. Trag die Modell-ID selbst ein.", + "admin_llm_test_role": "Testen", + "admin_llm_testing_role": "Wird getestet …", + "admin_llm_save": "Speichern", + "admin_llm_save_failed": "Endpunkt konnte nicht gespeichert werden. Bitte versuch es erneut.", + "admin_llm_endpoint_failed": "Endpunkt fehlgeschlagen", + "admin_template_edit": "Bearbeiten", + "admin_template_duplicate": "Duplizieren", + "admin_template_delete": "Löschen", + "admin_template_view": "Ansehen", + "admin_template_delete_confirm": "Vorlage \"{name}\" löschen?", + "admin_template_save": "Vorlage speichern", + "admin_template_saving": "Wird gespeichert …", + "admin_template_save_failed": "Vorlage konnte nicht gespeichert werden.", + "admin_template_add": "Zu dieser Instanz hinzufügen", + "admin_template_adding": "Wird hinzugefügt …", + "admin_template_add_failed": "Vorlage konnte nicht hinzugefügt werden.", + "admin_template_version_hint": "Denk daran, die Version zu erhöhen, wenn sich die Vorlage ändert.", + "admin_template_blueprint_hint": "Eine Vorlage aus dem Katalog. Füg sie hinzu, um sie bearbeiten zu können.", + "admin_template_empty": "Noch keine Dokumentationsvorlagen. Füg unten eine aus dem Katalog hinzu.", + "admin_catalog_title": "Vorlagenkatalog", + "admin_catalog_available": "{count} zum Hinzufügen verfügbar", + "admin_catalog_added": "Hinzugefügt", + "admin_catalog_add": "Hinzufügen", + "admin_template_new": "Neue Vorlage", + "admin_builder_new_heading": "Neue Dokumentationsvorlage", + "admin_builder_show_yaml": "YAML anzeigen", + "admin_builder_show_form": "Formular anzeigen", + "admin_builder_name": "Name", + "admin_builder_version": "Version", + "admin_builder_description": "Kurzbeschreibung", + "admin_builder_description_placeholder": "Wofür ist diese Vorlage gedacht?", + "admin_builder_persona": "Schreibstil fürs Modell", + "admin_builder_persona_placeholder": "Beschreib, wie das Modell den Abschnitt schreiben soll: Ton, Detailgrad, was es vermeiden soll.", + "admin_builder_sections": "Abschnitte", + "admin_builder_sections_hint": "Jede Überschrift wird ein Abschnitt im Dokument. Der Hinweis steuert, was das Modell in diesem Abschnitt herausarbeitet.", + "admin_builder_sections_empty": "Noch keine Abschnitte. Füg den ersten hinzu.", + "admin_builder_section_heading_placeholder": "Überschrift, z. B. Ablauf", + "admin_builder_section_hint_placeholder": "Was gehört in diesen Abschnitt?", + "admin_builder_section_add": "Abschnitt hinzufügen", + "admin_builder_section_up": "Nach oben", + "admin_builder_section_down": "Nach unten", + "admin_builder_section_remove": "Abschnitt entfernen", + "admin_builder_title": "Titelvorschlag", + "admin_builder_title_tokens": "Platzhalter:", + "admin_builder_locale": "Sprache des Inhalts", + "admin_builder_locale_unset": "Ohne Festlegung", + "admin_builder_locale_de": "Deutsch", + "admin_builder_locale_en": "Englisch", + "admin_builder_visibility": "Sichtbarkeit nach Freigabe", + "admin_builder_temperature": "Kreativität", + "admin_builder_temperature_hint": "0 = nüchtern, 1 = frei", + "admin_builder_min_class": "Mindestmodell", + "admin_builder_min_class_placeholder": "optional, z. B. 12b", + "admin_builder_error_name": "Gib der Vorlage einen Namen.", + "admin_builder_error_sections": "Füg mindestens einen Abschnitt mit Überschrift hinzu.", + "common_close": "Schließen", + "admin_llm_reset_field_short": "Auf .env zurücksetzen", + "admin_users_edit": "Bearbeiten", + "admin_users_save": "Speichern", + "admin_users_update_failed": "Änderung konnte nicht gespeichert werden.", + "admin_users_email_placeholder": "vorname@firma.de", + "admin_users_name_placeholder": "Vor- und Nachname", + "admin_users_password_placeholder": "mindestens 8 Zeichen", + "admin_departments_rename": "Umbenennen", + "admin_departments_rename_failed": "Abteilung konnte nicht umbenannt werden.", + "admin_departments_placeholder": "z. B. Instandhaltung", + "admin_llm_base_url_placeholder": "https://api.example.com/v1", + "admin_llm_model_placeholder": "Modell-ID", + "admin_template_name_placeholder": "Vorlagenname", + "admin_users_edit_title": "Benutzer bearbeiten", + "admin_users_search_placeholder": "Nach Name oder E-Mail suchen …", + "admin_pager_status": "Seite {page} von {pages}, {total} Benutzer", + "admin_users_empty": "Keine Benutzer gefunden.", + "admin_department_edit_title": "Abteilung umbenennen", + "admin_users_create_title": "Neuen Benutzer anlegen", + "admin_users_new": "Benutzer anlegen", + "admin_departments_create_title": "Neue Abteilung anlegen", + "admin_departments_new_button": "Abteilung anlegen", + "chat_error_start_conversation": "Das Gespräch konnte nicht gestartet werden. Bitte versuche es erneut.", + "chat_error_connection_lost": "Verbindung unterbrochen. Bitte versuche es erneut.", + "capture_title": "Wissen festhalten", + "capture_subtitle": "Wähle eine Dokumentationsart, dann schreib los. Das Modell schlägt dir beim Schreiben reifere Formulierungen vor.", + "capture_start_failed": "Das Dokument konnte nicht angelegt werden.", + "editor_save": "Speichern", + "editor_accept": "Übernehmen", + "editor_dismiss": "Verwerfen", + "editor_grounding_label": "Grundlage", + "editor_suggestion_title": "Vorschlag", + "admin_catalog_sections": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Ein Abschnitt", + "countPlural=other": "{count} Abschnitte" + } + } + ], + "capture_matches_title": "Passt zu deinem Gespräch", + "capture_templates_title": "Neu dokumentieren", + "capture_success_title": "Dein Wissen ist dokumentiert.", + "capture_success_body": "Du kannst es dir ansehen oder jemandem mit Zugriff zur Prüfung geben.", + "capture_success_view": "Ansehen", + "documents_export": "Exportieren", + "llm_error_unreachable": "Das Sprachmodell ist nicht erreichbar. Prüf, ob der Endpunkt läuft, oder versuch es später noch einmal.", + "llm_error_busy": "Das Sprachmodell ist gerade ausgelastet. Warte einen Moment und versuch es erneut.", + "llm_error_misconfigured": "Der Zugang zum Sprachmodell stimmt nicht. Bitte gib einer Administratorin Bescheid.", + "llm_error_failed": "Das Sprachmodell hat nicht geantwortet. Bitte versuch es erneut.", + "llm_status_slow": "Das Modell braucht länger als sonst. Wahrscheinlich ist der Endpunkt gerade ausgelastet.", + "error_generic": "Etwas ist schiefgelaufen. Bitte versuch es erneut.", + "profile_capture_title": "Halt fest, was du weißt", + "profile_capture_hint": "Ein kurzes Dokument über deine Rolle, deine Spezialgebiete und wofür man dich fragen kann. Die Vorlage stellt die Fragen, du antwortest in deinen Worten.", + "profile_capture_cta": "Dokument über dich anlegen", + "chat_fallback_note": "Ohne Modell wurde klassisch im Volltext gesucht. Du siehst die Fundstellen direkt und kannst sie selbst öffnen.", + "chat_fallback_empty": "Die Volltextsuche hat zu deinen Wörtern nichts gefunden. Formulier es mit anderen Begriffen, oder versuch es erneut, wenn das Modell wieder läuft.", + "admin_users_reset_title": "Passwort zurücksetzen", + "error_email_taken": "Diese E-Mail-Adresse wird bereits verwendet.", + "error_name_taken": "Diesen Namen gibt es schon.", + "error_self_modification": "Das kannst du an deinem eigenen Konto nicht ändern.", + "error_department_in_use": "Diese Abteilung wird noch verwendet. Beim Löschen gehen ihre Freigaben verloren.", + "profile_document_title": "Dein Dokument", + "profile_document_hint": "Das ist dein Dokument über dich. Du kannst es jederzeit weiterschreiben.", + "profile_document_open": "Ansehen", + "profile_document_edit": "Bearbeiten", + "documents_filter_my_reviews": "Zur Prüfung bei mir", + "documents_badge_open_reviews": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Frage offen", + "countPlural=other": "{count} Fragen offen" + } + } + ], + "document_publish": "Veröffentlichen", + "document_draft_title": "Noch ein Entwurf", + "document_draft_hint": "Nur du siehst diesen Text. Veröffentlicht wird er für alle sichtbar, die Zugriff haben, und im Chat gefunden.", + "document_draft_hint_reviewer": "Du wurdest gebeten, diesen Entwurf zu prüfen. Außer dir sieht ihn nur die Person, die ihn schreibt.", + "document_review_ask": "Um Prüfung bitten", + "document_review_asked_by": "{name} fragt:", + "document_review_asked_plain": "{name} bittet um eine Prüfung.", + "document_review_waiting_on": "Wartet auf {name}, gefragt am {date}", + "document_review_confirm": "Stimmt so", + "document_review_fix": "Korrigieren", + "document_review_close": "Frage schließen", + "document_review_answered": "Geprüft von {name} am {date}", + "document_review_failed": "Das hat nicht geklappt. Bitte versuch es erneut.", + "review_ask_hint": "Such jemanden aus, der es beurteilen kann, und schreib dazu, worum es geht. Bis zur Antwort ist das Dokument überall als ungeprüft markiert.", + "review_ask_reviewer": "Wer soll es prüfen?", + "review_ask_question": "Worum geht es? (optional)", + "review_ask_question_placeholder": "z. B. Stimmt das so mit den 14 Urlaubstagen?", + "review_ask_send": "Frage senden", + "review_ask_sent": "{name} wurde gefragt.", + "review_ask_none": "Niemand sonst hat Zugriff auf dieses Dokument.", + "review_ask_failed": "Die Anfrage ist nicht angekommen. Bitte versuch es erneut.", + "editor_save_title": "Änderungen speichern", + "editor_save_hint": "Das hast du geändert, seit du zuletzt gespeichert hast.", + "editor_save_and_publish": "Speichern und veröffentlichen", + "editor_no_changes": "Seit dem letzten Speichern hat sich nichts geändert.", + "editor_unsaved": "Nicht gespeichert", + "capture_success_ask": "Prüfen lassen", + "landing_drafts_title": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Ein Entwurf von dir", + "countPlural=other": "{count} Entwürfe von dir" + } + } + ], + "landing_drafts_hint": "Noch nicht veröffentlicht, niemand sonst findet sie.", + "landing_drafts_publish": "Veröffentlichen", + "landing_drafts_all": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Alle ansehen", + "countPlural=other": "Alle {count} ansehen" + } + } + ], + "chat_source_review_pending": "Zu diesem Dokument ist eine Frage offen. Der Inhalt ist eventuell nicht mehr aktuell.", + "panel_missing": "Das Dokument gibt es nicht mehr, oder du hast keinen Zugriff darauf.", + "panel_loading": "Wird geladen …", + "common_back": "Zurück", + "documents_visibility_public": "öffentlich", + "documents_visibility_department": "Abteilung", + "documents_visibility_restricted": "eingeschränkt", + "history_action_published": "Veröffentlicht", + "history_action_review_requested": "Um Prüfung gebeten", + "history_action_review_resolved": "Geprüft", + "chat_source_sections": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Stelle", + "countPlural=other": "{count} Stellen" + } + } + ], + "visibility_label": "Sichtbar für", + "visibility_save_failed": "Die Sichtbarkeit konnte nicht geändert werden.", + "editor_title_label": "Titel", + "editor_title_suggest": "Titel vorschlagen lassen", + "editor_title_suggest_failed": "Es kam kein Vorschlag zurück. Bitte versuch es erneut.", + "document_review_thanks_title": "Danke, geprüft.", + "document_review_thanks_body": "Der Entwurf gehört wieder der Person, die ihn schreibt. Sobald sie ihn veröffentlicht, findest du ihn über die Suche.", + "documents_access_label_review": "Zur Prüfung", + "documents_access_hint_review": "Du siehst das, weil du um eine Prüfung gebeten wurdest. Mit deiner Antwort endet der Zugriff.", + "common_more": "Mehr", + "document_draft_chip": "Entwurf", + "access_popover_title": "Wer sieht das?", + "access_extra_departments": "Zusätzlich geteilt mit", + "access_no_extra_departments": "Mit keiner weiteren Abteilung geteilt.", + "access_plus_departments": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "+1 Abteilung", + "countPlural=other": "+{count} Abteilungen" + } + } + ], + "history_show_all": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Einen Eintrag anzeigen", + "countPlural=other": "Alle {count} Einträge anzeigen" + } + } + ], + "history_show_less": "Weniger anzeigen", + "documents_filters": "Filter", + "documents_access_filter_label": "Warum sichtbar:", + "documents_export_hint": "Alle lesbaren Dokumente als Markdown-ZIP herunterladen", + "admin_tab_people": "Benutzer und Abteilungen", + "admin_page_subtitle": "Wer arbeitet mit, womit wird geschrieben, und woran hängt das Sprachmodell.", + "people_search_placeholder": "Name oder Abteilung …", + "people_none_found": "Niemand gefunden.", + "profile_my_documents": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Ein Dokument von dir", + "countPlural=other": "{count} Dokumente von dir" + } + } + ], + "chat_empty_hint": "Die Antwort kommt aus euren eigenen Dokumenten und nennt die Stellen, auf die sie sich stützt.", + "chat_sources_label": "Quellen:", + "editor_back": "Zurück", + "admin_prompt_hint_query_system": "Die Grundhaltung des Assistenten im Chat: wie er antwortet und wie er mit den gefundenen Stellen umgeht.", + "admin_prompt_hint_query_no_sources": "Was der Assistent sagt, wenn die Suche nichts Belastbares findet.", + "admin_prompt_hint_refine_persona": "Wer beim Schreiben mitformuliert: Rolle und Tonfall der Textvorschläge im Editor.", + "admin_prompt_hint_refine_rules": "Die Regeln für einen Vorschlag: was er darf, was er nicht erfinden soll.", + "admin_prompt_hint_grounding_framing": "Wie bereits dokumentiertes Wissen in einen Textvorschlag eingebettet wird.", + "admin_prompt_hint_topic_summary": "Fasst ein Gespräch in einem Satz zusammen, um passende Dokumente zu finden.", + "admin_prompt_hint_title": "Schlägt aus dem Inhalt eines Dokuments einen Titel vor.", + "editor_suggestions_paused": "Vorschläge pausiert.", + "editor_suggestions_retry": "Jetzt erneut versuchen", + "editor_saved_at": "Gespeichert um {time}", + "editor_untouched_draft": "Neuer Entwurf. Wenn du nichts schreibst, wird er beim Verlassen verworfen.", + "editor_draft_exists": "Entwurf, nur für dich sichtbar." +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 0000000..8ddc225 --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,501 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "settings_title": "Settings", + "settings_section_appearance": "Appearance", + "settings_section_language": "Language", + "settings_section_security": "Security", + "settings_theme_system": "System", + "settings_theme_light": "Light", + "settings_theme_dark": "Dark", + "settings_locale_automatic": "Automatic", + "settings_locale_hint": "Automatic follows your browser. Your choice is saved to your account and applies on every device.", + "settings_password_change": "Change password", + "settings_password_current": "Current password", + "settings_password_new": "New password", + "settings_password_repeat": "Repeat new password", + "settings_password_other_devices": "Your other devices will be signed out, this one stays.", + "settings_password_changed": "Password changed. Other devices were signed out.", + "settings_password_mismatch": "The new passwords do not match.", + "settings_password_wrong_current": "That is not your current password.", + "settings_password_failed": "Could not change the password. Please try again.", + "settings_administration": "Administration", + "settings_logout": "Log out", + "common_cancel": "Cancel", + "common_delete": "Delete", + "login_page_title": "Sign in", + "login_subtitle": "Sign in with your company account.", + "login_email": "Email", + "login_password": "Password", + "login_submit": "Sign in", + "login_submitting": "Signing in …", + "login_error_credentials": "Email or password is incorrect.", + "login_error_generic": "Login failed. Please try again later.", + "nav_new_conversation": "New conversation", + "nav_documents": "Documents", + "nav_administration": "Administration", + "nav_settings": "Settings", + "nav_sidebar_expand": "Expand sidebar", + "nav_sidebar_collapse": "Collapse sidebar", + "nav_conversation_untitled": "New conversation", + "nav_conversation_delete": "Delete conversation", + "landing_greeting_morning": "Good morning, {name}", + "landing_greeting_afternoon": "Good afternoon, {name}", + "landing_greeting_evening": "Good evening, {name}", + "landing_prompt": "What would you like to capture today?", + "landing_input_placeholder": "Ask a question, or describe what you know…", + "landing_input_note": "Answers cite the documents they come from.", + "landing_ask": "Ask", + "landing_chip_capture": "Capture knowledge", + "landing_chip_find": "Find a document", + "landing_setup_title": "Set up Pablan", + "landing_setup_departments": "Create departments", + "landing_setup_departments_hint": "They decide who sees what.", + "landing_setup_invite": "Invite your colleagues", + "landing_setup_first_capture": "Run the first guided documentation", + "documents_page_title": "Documents", + "documents_search_placeholder": "Search the knowledge base…", + "documents_filter_all_statuses": "All statuses", + "documents_filter_all_departments": "All departments", + "documents_filter_disabled_hint": "Filters apply when browsing, not to search results.", + "documents_sort_disabled_hint": "Search results are ranked by relevance.", + "documents_sort_updated": "Recently changed", + "documents_sort_created": "Newest", + "documents_access_all": "All", + "documents_access_mine": "Mine", + "documents_access_department": "Department", + "documents_access_public": "Public", + "documents_access_granted": "Granted", + "documents_access_label_author": "Yours", + "documents_access_label_department": "Department", + "documents_access_label_public": "Public", + "documents_access_label_granted": "Granted", + "documents_access_hint_author": "You created this document.", + "documents_access_hint_department": "Shared with your department.", + "documents_access_hint_public": "Visible to everyone in the company.", + "documents_access_hint_granted": "Your department was granted access.", + "documents_status_draft": "Draft", + "documents_status_published": "Published", + "documents_status_archived": "Archived", + "documents_badge_builtin": "Built-in", + "documents_no_department": "No department", + "documents_loading": "Loading…", + "documents_empty": "No documents match the current filters.", + "documents_updated_at": "Updated {date}", + "documents_created_at": "Created {date}", + "history_title": "History", + "history_empty": "No changes recorded yet.", + "history_by": "by {actor}", + "history_actor_unknown": "Unknown", + "history_view_changes": "View changes", + "history_diff_title": "What this version changed", + "history_restore": "Restore this version", + "history_action_created": "Created", + "history_action_edited": "Edited", + "history_action_archived": "Archived", + "history_action_visibility_changed": "Visibility changed", + "nav_people": "People", + "people_title": "People", + "people_subtitle": "Find out who does what on the team.", + "people_no_department": "No department", + "people_role_admin": "Admin", + "people_not_found": "This person does not exist.", + "people_back": "Back to the directory", + "profile_edit_action": "Edit profile", + "profile_edit_title": "My profile", + "profile_edit_subtitle": "Write down what you know, so colleagues can find it.", + "profile_view_public": "View public profile", + "settings_edit_profile": "Edit profile", + "document_delete_confirm": "This document will be permanently deleted.", + "conversation_delete_confirm": "This conversation will be permanently deleted.", + "sharing_shared_with": "Shared with", + "sharing_manage": "Manage sharing", + "sharing_share": "Share with departments", + "sharing_dialog_title": "Share with departments", + "sharing_dialog_hint": "These departments may also read the document.", + "sharing_none_shareable": "There are no other departments.", + "sharing_save": "Save", + "sharing_save_failed": "Saving failed.", + "sharing_lockout_warning": "You will lose your own access to this document after this change.", + "sharing_lockout_confirm": "Save anyway", + "admin_prompts_title": "System prompts", + "admin_prompts_hint": "These instructions shape how the model answers and refines text. Changes take effect immediately, without a restart.", + "admin_prompt_default": "Default", + "admin_prompt_changed": "Changed", + "admin_prompt_save": "Save", + "admin_prompt_saved": "Saved", + "admin_prompt_reset": "Reset", + "admin_prompt_query_system": "Chat assistant (system)", + "admin_prompt_query_no_sources": "Chat: no matches", + "admin_prompt_refine_persona": "Refinement: persona", + "admin_prompt_refine_rules": "Refinement: rules", + "admin_prompt_grounding_framing": "Refinement: grounding framing", + "admin_prompt_topic_summary": "Topic summary", + "admin_prompt_title": "Title suggestion", + "landing_review_open": "Review now", + "landing_review_pending": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "One document is waiting for your review.", + "countPlural=other": "{count} documents are waiting for your review." + } + } + ], + "documents_pager_previous": "Previous", + "documents_pager_next": "Next", + "documents_pager_status": "Page {page} of {pages}, {total} documents", + "document_fallback_title": "Document", + "document_not_found_title": "Document not found", + "document_not_found_body": "It may not exist, or you do not have access to it.", + "document_back_to_list": "Back to documents", + "document_action_archive": "Archive", + "document_action_republish": "Republish", + "document_action_edit": "Edit", + "document_action_delete": "Delete", + "document_builtin_note": "Part of Pablan. This page ships with the product and updates with it.", + "document_status_line": "Status: {status}", + "document_visibility_line": "Visibility: {visibility}", + "document_can_edit": "You can edit this", + "document_read_only": "Read only", + "document_visibility_public": "Public, whole company", + "document_visibility_department": "Department only", + "document_visibility_restricted": "Restricted, explicit grants", + "document_save_failed": "Saving failed. Please try again.", + "chat_page_title": "Chat", + "chat_capture_button": "Capture knowledge", + "chat_status_searching": "Searching the knowledge base…", + "chat_status_results": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 relevant passage found", + "countPlural=other": "{count} relevant passages found" + } + } + ], + "chat_status_no_answer": "Nothing documented on this yet", + "chat_status_queued": "The model is busy right now", + "chat_status_answering": "Writing answer…", + "chat_empty_query": "Ask a question about your company's knowledge base.", + "chat_input_placeholder": "Ask a question…", + "chat_stop": "Stop generating", + "chat_send": "Send", + "chat_no_sources_note": "Answered without the knowledge base, nothing documented on this yet.", + "chat_capture_gap": "Capture this knowledge", + "chat_context_label": "Context", + "chat_context_title": "What this answer is based on:", + "chat_context_used": "used", + "chat_context_unused": "too weak", + "panel_open_full_page": "Open full page", + "panel_close": "Close document panel", + "admin_page_title": "Administration", + "admin_users_title": "Users", + "admin_users_email": "Email", + "admin_users_name": "Name", + "admin_users_role": "Role", + "admin_users_department": "Department", + "admin_users_actions": "Actions", + "admin_users_reset_password": "Reset password", + "admin_users_delete": "Delete", + "admin_users_no_department": "No department", + "admin_users_password": "Password", + "admin_users_create": "Create user", + "admin_users_create_failed": "Creating the user failed.", + "admin_users_reset_failed": "Password reset failed (min. 8 characters).", + "admin_users_delete_confirm": "Delete {email}? Their documents survive without an author.", + "admin_departments_title": "Departments", + "admin_departments_new": "New department", + "admin_departments_create": "Create", + "admin_departments_create_failed": "Creating the department failed.", + "admin_departments_delete_confirm": "Delete department \"{name}\"? Members and documents keep existing without it.", + "admin_templates_title": "Capture templates", + "admin_llm_title": "LLM endpoints", + "admin_llm_test": "Test endpoints", + "admin_llm_testing": "Testing…", + "admin_llm_intro": "These values were taken from .env when this instance was first started. From then on they live in the database, so editing .env afterwards changes nothing. Saving applies immediately, no restart.", + "admin_llm_base_url": "Base URL", + "admin_llm_model": "Model", + "admin_llm_api_key": "API key", + "admin_llm_api_key_unset": "not set", + "admin_llm_api_key_note": "An empty API key field keeps the stored key.", + "admin_llm_source_env": "from .env", + "admin_llm_source_ui": "changed here", + "admin_llm_reset_field": "Put this field back to the .env value", + "admin_llm_check_models": "Check models", + "admin_llm_checking": "Checking…", + "admin_llm_enter_manually": "Enter manually", + "admin_llm_no_model_list": "This endpoint does not publish a model list. Type the model id yourself.", + "admin_llm_test_role": "Test", + "admin_llm_testing_role": "Testing…", + "admin_llm_save": "Save", + "admin_llm_save_failed": "Could not save the endpoint. Please try again.", + "admin_llm_endpoint_failed": "endpoint failed", + "admin_template_edit": "Edit", + "admin_template_duplicate": "Duplicate", + "admin_template_delete": "Delete", + "admin_template_view": "View", + "admin_template_delete_confirm": "Delete the template \"{name}\"?", + "admin_template_save": "Save template", + "admin_template_saving": "Saving…", + "admin_template_save_failed": "Could not save the template.", + "admin_template_add": "Add to this instance", + "admin_template_adding": "Adding…", + "admin_template_add_failed": "Could not add the template.", + "admin_template_version_hint": "Remember to raise the version when the template changes.", + "admin_template_blueprint_hint": "A blueprint. Add it to this instance to make it editable.", + "admin_template_empty": "No capture templates yet, add one from the catalog below.", + "admin_catalog_title": "Template catalog", + "admin_catalog_available": "{count} available to add", + "admin_catalog_added": "Added", + "admin_catalog_add": "Add", + "admin_template_new": "New template", + "admin_builder_new_heading": "New documentation template", + "admin_builder_show_yaml": "Show YAML", + "admin_builder_show_form": "Show form", + "admin_builder_name": "Name", + "admin_builder_version": "Version", + "admin_builder_description": "Short description", + "admin_builder_description_placeholder": "What is this template for?", + "admin_builder_persona": "Writing style for the model", + "admin_builder_persona_placeholder": "Describe how the model should write the section: tone, level of detail, what to avoid.", + "admin_builder_sections": "Sections", + "admin_builder_sections_hint": "Each heading becomes a section in the document. The hint steers what the model draws out in that section.", + "admin_builder_sections_empty": "No sections yet. Add the first one.", + "admin_builder_section_heading_placeholder": "Heading, e.g. Procedure", + "admin_builder_section_hint_placeholder": "What belongs in this section?", + "admin_builder_section_add": "Add section", + "admin_builder_section_up": "Move up", + "admin_builder_section_down": "Move down", + "admin_builder_section_remove": "Remove section", + "admin_builder_title": "Suggested title", + "admin_builder_title_tokens": "Placeholders:", + "admin_builder_locale": "Content language", + "admin_builder_locale_unset": "No fixed language", + "admin_builder_locale_de": "German", + "admin_builder_locale_en": "English", + "admin_builder_visibility": "Visibility after approval", + "admin_builder_temperature": "Creativity", + "admin_builder_temperature_hint": "0 = sober, 1 = free", + "admin_builder_min_class": "Minimum model", + "admin_builder_min_class_placeholder": "optional, e.g. 12b", + "admin_builder_error_name": "Give the template a name.", + "admin_builder_error_sections": "Add at least one section with a heading.", + "common_close": "Close", + "admin_llm_reset_field_short": "Reset to .env", + "admin_users_edit": "Edit", + "admin_users_save": "Save", + "admin_users_update_failed": "Could not save the change.", + "admin_users_email_placeholder": "firstname@company.com", + "admin_users_name_placeholder": "First and last name", + "admin_users_password_placeholder": "at least 8 characters", + "admin_departments_rename": "Rename", + "admin_departments_rename_failed": "Could not rename the department.", + "admin_departments_placeholder": "e.g. Maintenance", + "admin_llm_base_url_placeholder": "https://api.example.com/v1", + "admin_llm_model_placeholder": "model id", + "admin_template_name_placeholder": "Template name", + "admin_users_edit_title": "Edit user", + "admin_users_search_placeholder": "Search by name or email…", + "admin_pager_status": "Page {page} of {pages}, {total} users", + "admin_users_empty": "No users found.", + "admin_department_edit_title": "Rename department", + "admin_users_create_title": "Create a new user", + "admin_users_new": "Create user", + "admin_departments_create_title": "Create a new department", + "admin_departments_new_button": "Create department", + "chat_error_start_conversation": "The conversation could not be started. Please try again.", + "chat_error_connection_lost": "Connection lost. Please try again.", + "capture_title": "Capture knowledge", + "capture_subtitle": "Pick a documentation type, then start writing. The model suggests more polished wording as you go.", + "capture_start_failed": "Could not create the document.", + "editor_save": "Save", + "editor_accept": "Accept", + "editor_dismiss": "Dismiss", + "editor_grounding_label": "Grounding", + "editor_suggestion_title": "Suggestion", + "admin_catalog_sections": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 section", + "countPlural=other": "{count} sections" + } + } + ], + "capture_matches_title": "Matches your conversation", + "capture_templates_title": "Document something new", + "capture_success_title": "Your knowledge is documented.", + "capture_success_body": "View it, or hand it to someone with access for review.", + "capture_success_view": "View it", + "documents_export": "Export", + "llm_error_unreachable": "The language model cannot be reached. Check that the endpoint is running, or try again later.", + "llm_error_busy": "The language model is busy right now. Wait a moment and try again.", + "llm_error_misconfigured": "Access to the language model is not set up correctly. Please tell an administrator.", + "llm_error_failed": "The language model did not answer. Please try again.", + "llm_status_slow": "The model is taking longer than usual. The endpoint is probably busy.", + "error_generic": "Something went wrong. Please try again.", + "profile_capture_title": "Write down what you know", + "profile_capture_hint": "A short document about your role, your specialities and what people can ask you about. The template asks the questions, you answer in your own words.", + "profile_capture_cta": "Start a document about you", + "chat_fallback_note": "Without a model this was a plain full-text search. You see the matches directly and can open them yourself.", + "chat_fallback_empty": "The full-text search found nothing for your words. Try other terms, or ask again once the model is back.", + "admin_users_reset_title": "Reset password", + "error_email_taken": "That email address is already in use.", + "error_name_taken": "That name already exists.", + "error_self_modification": "You cannot change that on your own account.", + "error_department_in_use": "This department is still in use. Deleting it drops the access its grants gave.", + "profile_document_title": "Your document", + "profile_document_hint": "This is your document about yourself. You can keep writing it whenever you like.", + "profile_document_open": "View", + "profile_document_edit": "Edit", + "documents_filter_my_reviews": "Waiting for my check", + "documents_badge_open_reviews": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Question open", + "countPlural=other": "{count} questions open" + } + } + ], + "document_publish": "Publish", + "document_draft_title": "Still a draft", + "document_draft_hint": "Only you can see this text. Published, it becomes visible to everyone with access and findable in chat.", + "document_draft_hint_reviewer": "You were asked to check this draft. Apart from you, only the person writing it can see it.", + "document_review_ask": "Ask for a check", + "document_review_asked_by": "{name} asks:", + "document_review_asked_plain": "{name} asked for a check.", + "document_review_waiting_on": "Waiting for {name}, asked on {date}", + "document_review_confirm": "That's correct", + "document_review_fix": "Fix it", + "document_review_close": "Close question", + "document_review_answered": "Checked by {name} on {date}", + "document_review_failed": "That did not work. Please try again.", + "review_ask_hint": "Pick someone who can judge it and say what it is about. Until they answer, the document is marked as unchecked everywhere.", + "review_ask_reviewer": "Who should check it?", + "review_ask_question": "What is it about? (optional)", + "review_ask_question_placeholder": "e.g. Are the 14 holiday days still right?", + "review_ask_send": "Send question", + "review_ask_sent": "{name} has been asked.", + "review_ask_none": "Nobody else has access to this document.", + "review_ask_failed": "The request did not go through. Please try again.", + "editor_save_title": "Save changes", + "editor_save_hint": "This is what you changed since you last saved.", + "editor_save_and_publish": "Save and publish", + "editor_no_changes": "Nothing has changed since the last save.", + "editor_unsaved": "Not saved", + "capture_success_ask": "Have it checked", + "landing_drafts_title": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "One draft of yours", + "countPlural=other": "{count} drafts of yours" + } + } + ], + "landing_drafts_hint": "Not published yet, nobody else can find them.", + "landing_drafts_publish": "Publish", + "landing_drafts_all": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "See all", + "countPlural=other": "See all {count}" + } + } + ], + "chat_source_review_pending": "There is an open question about this document. The content may be out of date.", + "panel_missing": "This document no longer exists, or you do not have access to it.", + "panel_loading": "Loading…", + "common_back": "Back", + "documents_visibility_public": "public", + "documents_visibility_department": "department", + "documents_visibility_restricted": "restricted", + "history_action_published": "Published", + "history_action_review_requested": "Asked for a check", + "history_action_review_resolved": "Checked", + "chat_source_sections": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 section", + "countPlural=other": "{count} sections" + } + } + ], + "visibility_label": "Visible to", + "visibility_save_failed": "The visibility could not be changed.", + "editor_title_label": "Title", + "editor_title_suggest": "Suggest a title", + "editor_title_suggest_failed": "No suggestion came back. Please try again.", + "document_review_thanks_title": "Thanks, checked.", + "document_review_thanks_body": "The draft belongs to the person writing it again. Once they publish it, you will find it through search.", + "documents_access_label_review": "For your check", + "documents_access_hint_review": "You can see this because you were asked to check it. Your answer ends the access.", + "common_more": "More", + "document_draft_chip": "Draft", + "access_popover_title": "Who can see this?", + "access_extra_departments": "Also shared with", + "access_no_extra_departments": "Not shared with any other department.", + "access_plus_departments": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "+1 department", + "countPlural=other": "+{count} departments" + } + } + ], + "history_show_all": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Show one entry", + "countPlural=other": "Show all {count} entries" + } + } + ], + "history_show_less": "Show less", + "documents_filters": "Filters", + "documents_access_filter_label": "Why visible:", + "documents_export_hint": "Download every readable document as a Markdown ZIP", + "admin_tab_people": "Users and departments", + "admin_page_subtitle": "Who works here, what they write with, and what the language model runs on.", + "people_search_placeholder": "Name or department …", + "people_none_found": "Nobody found.", + "profile_my_documents": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "One document you wrote", + "countPlural=other": "{count} documents you wrote" + } + } + ], + "chat_empty_hint": "Answers come out of your own documents and name the passages they lean on.", + "chat_sources_label": "Sources:", + "editor_back": "Back", + "admin_prompt_hint_query_system": "How the assistant answers in chat, and how it treats the passages it found.", + "admin_prompt_hint_query_no_sources": "What the assistant says when the search finds nothing solid.", + "admin_prompt_hint_refine_persona": "Who writes along in the editor: the role and tone of a suggestion.", + "admin_prompt_hint_refine_rules": "The rules for a suggestion: what it may do, and what it must not invent.", + "admin_prompt_hint_grounding_framing": "How already documented knowledge is framed inside a suggestion.", + "admin_prompt_hint_topic_summary": "Sums a conversation up in one sentence, to find matching documents.", + "admin_prompt_hint_title": "Suggests a title from a document's content.", + "editor_suggestions_paused": "Suggestions paused.", + "editor_suggestions_retry": "Try again now", + "editor_saved_at": "Saved at {time}", + "editor_untouched_draft": "New draft. Leave without writing anything and it is discarded.", + "editor_draft_exists": "Draft, visible only to you." +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e9ea434 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5163 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-markdown": "^6.5.1", + "@codemirror/language": "^6.12.4", + "@codemirror/merge": "^6.12.2", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.6", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.25.0", + "bits-ui": "^2.18.1", + "dompurify": "^3.4.12", + "katex": "^0.18.1", + "marked": "^18.0.6", + "marked-katex-extension": "^5.1.10", + "openapi-fetch": "^0.17.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@inlang/paraglide-js": "^2.22.0", + "@playwright/test": "^1.60.0", + "@sveltejs/adapter-node": "^5.5.7", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^24", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.19.0", + "globals": "^17.6.0", + "openapi-typescript": "^7.13.0", + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.8.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz", + "integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inlang/paraglide-js": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.22.0.tgz", + "integrity": "sha512-GSzG7KEKcYAhwuPNJczIPB+DzyndYxr4lsXAkkB7xh00jTrt80NF2KfgjEAkxuJvWcnscqXf7y7d1Q0SWtSu7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "^2.10.0", + "commander": "11.1.0", + "consola": "3.4.0", + "json5": "2.2.3", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0" + }, + "bin": { + "paraglide-js": "bin/run.js" + }, + "peerDependencies": { + "typescript": ">=5.6", + "vite": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", + "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "comment-json": "^4.2.3" + } + }, + "node_modules/@inlang/sdk": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.10.2.tgz", + "integrity": "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lix-js/sdk": "0.4.10", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.28.12", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^14.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lix-js/sdk": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz", + "integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@lix-js/server-protocol-schema": "0.1.1", + "dedent": "1.5.1", + "human-id": "^4.1.1", + "js-sha256": "^0.11.0", + "kysely": "^0.28.12", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@lix-js/server-protocol-schema": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", + "integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@lucide/svelte": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", + "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.31.30", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz", + "integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.48.0-build4", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz", + "integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "sqlite-wasm": "bin/index.js" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.0.tgz", + "integrity": "sha512-5pBnJwdNzxbrxp1TLK1NPMFF0Cx57iZUDKInznKcfifYR9m9poWfZI2Tfhw6BZIjYor5dXvcibt4EQgar3k6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", + "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.20.0.tgz", + "integrity": "sha512-AElKLVt7Hjy4d7ljwhrhw9hux60DCxCNkmK8cY/aAXvjs8tpR7PvU4DlyI/SA1PaJww1gh0wPGo2pbyURuEwxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-id": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", + "integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.1.tgz", + "integrity": "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-katex-extension": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.10.tgz", + "integrity": "sha512-TuqrzguLeXXm6iBaf16leL3+dVmMj8KrBdunMVVzxMS/bwcjtQ0YG0sNytl1j7uUo8yClsXJqBbVjH1yOPurwQ==", + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16 <0.18", + "marked": ">=4 <19" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", + "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sqlite-wasm-kysely": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz", + "integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==", + "dev": true, + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.48.0-build2" + }, + "peerDependencies": { + "kysely": "*" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.56.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.6.tgz", + "integrity": "sha512-p4HDLDogGHKRKCrgckQHNs5PEfXkju6JI5jTywueaKJI5hAdjPohEhRtQ0M1SWC/+TA73SPln+r7srr+7e4nZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", + "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.34.1" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..66099c4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,60 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "npm run messages && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "npm run messages && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "npm run messages && npm run lint:files", + "lint:files": "prettier --check . && eslint .", + "format": "prettier --write .", + "test:e2e": "playwright install chromium && playwright test", + "test": "npm run test:e2e", + "messages": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide --strategy custom-userPreference cookie preferredLanguage baseLocale" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@inlang/paraglide-js": "^2.22.0", + "@playwright/test": "^1.60.0", + "@sveltejs/adapter-node": "^5.5.7", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^24", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.19.0", + "globals": "^17.6.0", + "openapi-typescript": "^7.13.0", + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.8.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16" + }, + "dependencies": { + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-markdown": "^6.5.1", + "@codemirror/language": "^6.12.4", + "@codemirror/merge": "^6.12.2", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.6", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.25.0", + "bits-ui": "^2.18.1", + "dompurify": "^3.4.12", + "katex": "^0.18.1", + "marked": "^18.0.6", + "marked-katex-extension": "^5.1.10", + "openapi-fetch": "^0.17.0" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..de55209 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +// E2E runs against the dev stack: postgres + backend must be up and seeded +// (make dev / make seed); the vite dev server is reused or started here. +export default defineConfig({ + testDir: 'e2e', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + // One worker: every spec drives the same seeded user against one local + // LLM, so parallel specs delete each other's in-flight conversations + // (and queue behind the same model anyway). + workers: 1, + use: { + baseURL: 'http://localhost:5173', + // The specs assert English copy, so the language the interface picks + // has to be pinned rather than inherited from whatever the machine + // running the suite happens to send. Accept-Language is the strategy + // that applies to a visitor with no account preference and no cookie. + // A seeded user who HAS picked a language keeps it (that is the product + // rule), so anything that runs as such a user selects by testid. + locale: 'en-US', + extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' } + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'npm run dev', + port: 5173, + reuseExistingServer: true + } +}); diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 0000000..f875488 --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,12 @@ +/** @type {import("prettier").Config} */ +const config = { + useTabs: true, + singleQuote: true, + trailingComma: 'none', + printWidth: 100, + plugins: ['prettier-plugin-svelte', 'prettier-plugin-tailwindcss'], + overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }], + tailwindStylesheet: './src/app.css' +}; + +export default config; diff --git a/frontend/project.inlang/settings.json b/frontend/project.inlang/settings.json new file mode 100644 index 0000000..c627cbd --- /dev/null +++ b/frontend/project.inlang/settings.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "de", + "locales": ["de", "en"], + "modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/frontend/scripts/check-messages.py b/frontend/scripts/check-messages.py new file mode 100644 index 0000000..b7b2c7d --- /dev/null +++ b/frontend/scripts/check-messages.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Gate on the two message rules that cannot be caught at runtime. + +1. **Every locale carries every key.** Paraglide falls back to the base + locale for a missing message, which means an untranslated string ships + silently as German inside an English interface. A missing key is a build + failure here instead. + +2. **No em or en dashes in UI copy.** They are hard to type, inconsistent + across the app when hand-written, and in German they collide with the + Gedankenstrich convention. Commas, colons or a second sentence do the + job. Prose in docs/ and comments is unaffected: this only reads the + message files. + +Run by `make lint`, so both rules hold for every route the migration +touches rather than only where someone remembered. +""" + +import json +import sys +from pathlib import Path + +MESSAGES = Path(__file__).resolve().parents[1] / "messages" +BASE_LOCALE = "de" +DASHES = {"—": "em dash", "–": "en dash"} + + +def load(path: Path) -> dict[str, object]: + data = json.loads(path.read_text(encoding="utf-8")) + return {key: value for key, value in data.items() if not key.startswith("$")} + + +def strings_in(value: object) -> list[str]: + """Every translatable string inside a message. + + A message is either a plain string or a list of variants, each with a + `match` object mapping a selector to a string (see docs/i18n.md, + pluralization). Declarations and selectors are machinery, not copy, so + only the match values are checked for dashes. + """ + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [ + text + for variant in value + if isinstance(variant, dict) + for text in variant.get("match", {}).values() + if isinstance(text, str) + ] + return [] + + +def main() -> int: + files = sorted(MESSAGES.glob("*.json")) + if not files: + print(f"check-messages: no message files in {MESSAGES}", file=sys.stderr) + return 1 + + catalogs = {path.stem: load(path) for path in files} + if BASE_LOCALE not in catalogs: + print(f"check-messages: missing base locale {BASE_LOCALE}.json", file=sys.stderr) + return 1 + + problems: list[str] = [] + base_keys = set(catalogs[BASE_LOCALE]) + + for locale, catalog in sorted(catalogs.items()): + if locale == BASE_LOCALE: + continue + for key in sorted(base_keys - set(catalog)): + problems.append( + f"{locale}.json: missing message '{key}' " + f"(present in {BASE_LOCALE}.json): a missing translation " + f"would silently ship as {BASE_LOCALE}" + ) + for key in sorted(set(catalog) - base_keys): + problems.append( + f"{locale}.json: message '{key}' has no counterpart in " + f"{BASE_LOCALE}.json: the source language defines the set" + ) + + for locale, catalog in sorted(catalogs.items()): + for key, value in sorted(catalog.items()): + for char, name in DASHES.items(): + if any(char in text for text in strings_in(value)): + problems.append( + f"{locale}.json: message '{key}' contains an {name} " + f"({char}): use a comma, a colon, or two sentences" + ) + + if problems: + print("check-messages: FAILED", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + return 1 + + total = len(base_keys) + locales = ", ".join(sorted(catalogs)) + print(f"check-messages: {total} messages complete in {locales}, no dashes") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/scripts/contrast-check.py b/frontend/scripts/contrast-check.py new file mode 100644 index 0000000..31b743a --- /dev/null +++ b/frontend/scripts/contrast-check.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""WCAG AA gate for the design tokens — runs as part of `make lint`. + +Parses the light-dark() token definitions straight out of +frontend/src/app.css, so palette edits are validated without keeping a +copy of the values in sync here. Text pairings must reach 4.5:1, the +focus ring 3:1, in BOTH modes. Exits non-zero on any violation. +""" + +import math +import re +import sys +from pathlib import Path + +APP_CSS = Path(__file__).resolve().parents[1] / "src" / "app.css" + +# Short names used in the pair lists → --pb-* token names. +ALIASES = { + "raised": "surface-raised", + "sunken": "surface-sunken", +} + +# (foreground, background) — needs >= 4.5 +TEXT_PAIRS = [ + ("ink", "surface"), + ("ink", "raised"), + ("ink", "sunken"), + ("ink-muted", "surface"), + ("ink-muted", "raised"), + ("ink-muted", "sunken"), + ("primary-fg", "primary"), + ("primary-fg", "primary-hover"), + ("secondary-fg", "secondary"), + ("secondary-fg", "secondary-hover"), + ("accent-fg", "accent"), + ("accent-fg", "accent-hover"), + ("secondary", "surface"), + ("secondary", "sunken"), + # primary is a button surface (warm near-black), never body text, so it is + # only checked as a background — see the primary-fg pairings above. + ("success", "surface"), + ("warning", "surface"), + ("danger", "surface"), + ("success", "success-muted"), + ("warning", "warning-muted"), + ("danger", "danger-muted"), + ("success-fg", "success"), + ("warning-fg", "warning"), + ("danger-fg", "danger"), + ("danger-fg", "danger-hover"), +] + +# Focus indicator vs adjacent surface — needs >= 3.0 +RING_PAIRS = [("secondary", "surface"), ("secondary", "raised")] + + +COLOR = r"(#[0-9a-fA-F]{6}|oklch\([^)]*\))" + + +def parse_palettes() -> tuple[dict[str, str], dict[str, str]]: + css = APP_CSS.read_text() + light: dict[str, str] = {} + dark: dict[str, str] = {} + pattern = rf"--pb-([a-z-]+):\s*light-dark\(\s*{COLOR}\s*,\s*{COLOR}\s*\)" + for name, light_value, dark_value in re.findall(pattern, css): + light[name] = light_value + dark[name] = dark_value + return light, dark + + +def _hex_luminance(hex_color: str) -> float: + hex_color = hex_color.lstrip("#") + r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (0, 2, 4)) + + def linear(channel: float) -> float: + if channel <= 0.04045: + return channel / 12.92 + return ((channel + 0.055) / 1.055) ** 2.4 + + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + + +def _oklch_luminance(value: str) -> float: + """oklch(L C H) → relative luminance. + + Goes OKLab → LMS → linear sRGB, which is already the space WCAG's + luminance formula wants, so no gamma round-trip is needed. Out-of-gamut + channels are clamped, the same way a browser renders them. + """ + body = value[value.index("(") + 1 : value.rindex(")")] + parts = body.replace("/", " ").split() + lightness = float(parts[0].rstrip("%")) / (100 if "%" in parts[0] else 1) + chroma = float(parts[1]) + hue = math.radians(float(parts[2])) + + a = chroma * math.cos(hue) + b = chroma * math.sin(hue) + + l_ = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3 + m_ = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3 + s_ = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3 + + red = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_ + green = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_ + blue = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_ + + red, green, blue = (min(1.0, max(0.0, channel)) for channel in (red, green, blue)) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def luminance(color: str) -> float: + if color.startswith("oklch"): + return _oklch_luminance(color) + return _hex_luminance(color) + + +def ratio(a: str, b: str) -> float: + la, lb = luminance(a), luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def main() -> int: + light, dark = parse_palettes() + if not light: + print(f"contrast-check: no light-dark() tokens found in {APP_CSS}") + return 1 + + failures = 0 + checked = 0 + for mode, palette in (("light", light), ("dark", dark)): + for pairs, minimum, kind in ( + (TEXT_PAIRS, 4.5, "text"), + (RING_PAIRS, 3.0, "ring"), + ): + for fg, bg in pairs: + fg_hex = palette[ALIASES.get(fg, fg)] + bg_hex = palette[ALIASES.get(bg, bg)] + r = ratio(fg_hex, bg_hex) + checked += 1 + if r < minimum: + failures += 1 + print(f"FAIL [{mode}] {fg} on {bg}: {r:.2f} < {minimum} ({kind})") + + if failures: + print(f"contrast-check: {failures} of {checked} pairings violate WCAG AA") + return 1 + print(f"contrast-check: {checked} token pairings pass WCAG AA") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..df63102 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,193 @@ +@import 'tailwindcss'; + +/* + * Design tokens — the ONLY place colors are defined (see CLAUDE.md). + * Every value carries light and dark via light-dark(); the active mode + * follows the OS preference and can be forced with data-theme on <html>. + * All pairings are WCAG-AA-checked by contrast-check.py in make lint. + */ +:root { + /* Dark is the product's default look — near-black with a warm cast, so the + * yellow/orange/red ramp sits on it without glaring; light stays fully + * supported and is selectable via data-theme. */ + color-scheme: dark; + + /* Pablan brand palette — a warm ramp: yellow (accent), orange (secondary), + * red (danger, and the end of the brand gradient). Dark values are authored + * in oklch, light mode keeps darkened variants of the same hues so text + * pairings still reach AA on white — a light-mode yellow reads as amber by + * necessity, not by accident. */ + /* primary is the QUIET button surface — neutral, not a loud fill. The + * brand's saturation is spent only on links (secondary) and CTAs + * (accent); chrome stays a warm black and white. */ + --pb-primary: light-dark(#1f1c18, oklch(24% 0.008 70)); + --pb-primary-hover: light-dark(#2f2a24, oklch(30% 0.008 70)); + --pb-primary-fg: light-dark(#ffffff, oklch(96% 0.005 90)); + /* orange: links, interactive text, the focus ring */ + --pb-secondary: light-dark(#b04a00, oklch(74% 0.165 55)); + --pb-secondary-hover: light-dark(#8f3b00, oklch(80% 0.14 55)); + --pb-secondary-fg: light-dark(#ffffff, oklch(15% 0.05 55)); + /* yellow: highlights, active states, CTAs — never body text or large surfaces */ + --pb-accent: light-dark(#b8770a, oklch(89% 0.185 95)); + --pb-accent-hover: light-dark(#c9840f, oklch(93% 0.16 95)); + --pb-accent-fg: light-dark(#1a1200, oklch(16% 0.06 95)); + + /* surfaces — near-neutral with a faint warm cast, so the yellow/orange + * accents are the only real colour on screen */ + --pb-surface: light-dark(#fafaf9, oklch(7% 0.006 70)); + --pb-surface-raised: light-dark(#ffffff, oklch(14% 0.006 70)); + /* the recessed/interactive tint: darker on white, lifted on black */ + --pb-surface-sunken: light-dark(#f2f0ed, oklch(19% 0.008 70)); + + /* text */ + --pb-ink: light-dark(#1a1815, oklch(95% 0.005 90)); + --pb-ink-muted: light-dark(#63605a, oklch(70% 0.01 80)); + + /* borders */ + --pb-border: light-dark(#e5e1db, oklch(22% 0.008 70)); + --pb-border-strong: light-dark(#c5bfb6, oklch(32% 0.01 70)); + + /* states — success is the one deliberately cool colour: the brand ramp is + * entirely warm, so green is the only thing left that reads as "good" at a + * glance. Warning borrows the family amber, danger IS the brand red. */ + --pb-success: light-dark(#16793e, oklch(78% 0.16 150)); + --pb-success-fg: light-dark(#ffffff, oklch(14% 0.05 150)); + --pb-success-muted: light-dark(#e3f6ea, oklch(24% 0.07 150)); + --pb-warning: light-dark(#8a6400, oklch(78% 0.14 75)); + --pb-warning-fg: light-dark(#ffffff, oklch(14% 0.05 75)); + --pb-warning-muted: light-dark(#faf0d3, oklch(24% 0.07 75)); + --pb-danger: light-dark(#c0261b, oklch(68% 0.2 27)); + --pb-danger-hover: light-dark(#a11f15, oklch(75% 0.17 27)); + --pb-danger-fg: light-dark(#ffffff, oklch(14% 0.06 27)); + --pb-danger-muted: light-dark(#fbe9e7, oklch(24% 0.09 27)); +} + +:root[data-theme='light'] { + color-scheme: light; +} +:root[data-theme='dark'] { + color-scheme: dark; +} + +/* Drop Tailwind's default palette: only semantic tokens compile to utilities, + * so a stray `bg-red-500` fails the build instead of shipping. */ +@theme { + --color-*: initial; +} + +@theme inline { + --color-primary: var(--pb-primary); + --color-primary-hover: var(--pb-primary-hover); + --color-primary-fg: var(--pb-primary-fg); + --color-secondary: var(--pb-secondary); + --color-secondary-hover: var(--pb-secondary-hover); + --color-secondary-fg: var(--pb-secondary-fg); + --color-accent: var(--pb-accent); + --color-accent-hover: var(--pb-accent-hover); + --color-accent-fg: var(--pb-accent-fg); + --color-surface: var(--pb-surface); + --color-surface-raised: var(--pb-surface-raised); + --color-surface-sunken: var(--pb-surface-sunken); + --color-ink: var(--pb-ink); + --color-ink-muted: var(--pb-ink-muted); + --color-border: var(--pb-border); + --color-border-strong: var(--pb-border-strong); + --color-success: var(--pb-success); + --color-success-fg: var(--pb-success-fg); + --color-success-muted: var(--pb-success-muted); + --color-warning: var(--pb-warning); + --color-warning-fg: var(--pb-warning-fg); + --color-warning-muted: var(--pb-warning-muted); + --color-danger: var(--pb-danger); + --color-danger-hover: var(--pb-danger-hover); + --color-danger-fg: var(--pb-danger-fg); + --color-danger-muted: var(--pb-danger-muted); + --color-ring: var(--pb-secondary); + /* Dropping the default palette removed these keywords too. */ + --color-transparent: transparent; + --color-current: currentColor; +} + +body { + background-color: var(--pb-surface); + color: var(--pb-ink); +} + +/* + * Brand treatments — the warm sweep of the wordmark and the glow behind hero + * elements. Defined once here so components never name colors (CLAUDE.md + * design tokens). + */ +.brand-gradient-text { + /* The brand ramp: yellow → orange → red. In light mode the tokens are + * already the darkened variants, so the wordmark stays legible on white. */ + background-image: linear-gradient( + 135deg, + var(--pb-accent) 0%, + var(--pb-secondary) 55%, + var(--pb-danger) 100% + ); + background-clip: text; + color: transparent; +} + +/* Soft radial brand light behind a hero. The element needs `relative`. */ +.brand-glow { + position: relative; +} +.brand-glow::before { + content: ''; + position: absolute; + inset: -30% -15% 30% -15%; + background: + radial-gradient( + 45% 55% at 30% 40%, + color-mix(in oklab, var(--pb-secondary) 22%, transparent), + transparent 70% + ), + radial-gradient( + 45% 55% at 70% 45%, + color-mix(in oklab, var(--pb-accent) 18%, transparent), + transparent 70% + ); + filter: blur(60px); + pointer-events: none; + z-index: 0; +} +.brand-glow > * { + position: relative; + z-index: 1; +} + +/* CTA lights — a warm halo under the primary call to action. */ +.glow-accent { + box-shadow: 0 0 40px color-mix(in oklab, var(--pb-accent) 35%, transparent); +} +.glow-accent:hover { + box-shadow: 0 0 48px color-mix(in oklab, var(--pb-accent) 50%, transparent); +} +.glow-secondary { + box-shadow: 0 0 40px color-mix(in oklab, var(--pb-secondary) 35%, transparent); +} + +/* + * Sidebar collapse. + * + * Driven by data-sidebar on <html>, set by the pre-paint boot script in + * app.html — NOT by {#if collapsed} in the component. Svelte only knows the + * stored value once it hydrates, so a conditional render showed the expanded + * sidebar for the first frames of every reload. CSS applies to the + * server-rendered markup immediately. + */ +.sidebar { + width: 15rem; +} +:root[data-sidebar='collapsed'] .sidebar { + width: 3.5rem; +} +:root[data-sidebar='collapsed'] .sidebar-label { + display: none; +} +:root[data-sidebar='collapsed'] .sidebar-row { + justify-content: center; +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..0a35061 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,17 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +import type { components } from '$lib/api/schema'; + +declare global { + namespace App { + // interface Error {} + interface Locals { + user: components['schemas']['UserOut'] | null; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..335e535 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,26 @@ +<!doctype html> +<html lang="%lang%" dir="%dir%"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + <script> + // Apply stored per-device UI state before first paint. Inline and + // synchronous on purpose: any later — including hydration — and the + // page visibly flashes the other state. Everything switched from + // here is styled by CSS off these attributes, never by {#if}, so + // the server-rendered markup is already correct on frame one. + try { + var t = localStorage.getItem('pablan.theme'); + if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t); + if (localStorage.getItem('pablan.sidebar.collapsed') === 'true') + document.documentElement.setAttribute('data-sidebar', 'collapsed'); + } catch (e) { + // Private mode without storage: the defaults apply. + } + </script> + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..aeb0bcf --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,42 @@ +import { sequence } from '@sveltejs/kit/hooks'; +import type { Handle } from '@sveltejs/kit'; +import { apiFetch } from '$lib/server/api'; +import { rememberRequestLocale } from '$lib/i18n/strategy.server'; +import { paraglideMiddleware } from '$lib/paraglide/server'; +import { getTextDirection } from '$lib/paraglide/runtime'; + +// The only auth logic in the frontend: forward the session cookie to the +// backend and expose the result as locals.user. +const auth: Handle = async ({ event, resolve }) => { + event.locals.user = null; + if (event.cookies.get('pablan_session')) { + try { + const response = await apiFetch(event.fetch, event.cookies, '/api/auth/me'); + if (response.ok) { + event.locals.user = await response.json(); + } + } catch { + // Backend unreachable: treat as logged out instead of failing the page. + event.locals.user = null; + } + } + // Hand the account's language to the custom locale strategy, which only + // sees the request. Runs before the i18n handle, hence the order below. + rememberRequestLocale(event.request, event.locals.user?.locale ?? null); + return resolve(event); +}; + +// Resolves the locale through the configured strategy chain and stamps the +// result into the document, so the very first server-rendered byte already +// carries the right language. +const i18n: Handle = ({ event, resolve }) => + paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { + event.request = localizedRequest; + return resolve(event, { + transformPageChunk: ({ html }) => + html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale)) + }); + }); + +// auth first: it is what tells the locale strategy which user is asking. +export const handle: Handle = sequence(auth, i18n); diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts new file mode 100644 index 0000000..1f01a55 --- /dev/null +++ b/frontend/src/hooks.ts @@ -0,0 +1,11 @@ +import type { Reroute } from '@sveltejs/kit'; +import { deLocalizeUrl } from '$lib/paraglide/runtime'; + +/** Strip any locale prefix before the router matches a route. + * + * Pablan does not use the `url` strategy, so today this is a passthrough. + * It stays because it is the hook that has to exist the moment localized + * paths are ever turned on, and discovering that later means debugging + * every route at once. + */ +export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname; diff --git a/frontend/src/lib/admin/DepartmentManager.svelte b/frontend/src/lib/admin/DepartmentManager.svelte new file mode 100644 index 0000000..1ed3330 --- /dev/null +++ b/frontend/src/lib/admin/DepartmentManager.svelte @@ -0,0 +1,203 @@ +<script lang="ts"> + import Pencil from '@lucide/svelte/icons/pencil'; + import Trash2 from '@lucide/svelte/icons/trash-2'; + import Building2 from '@lucide/svelte/icons/building-2'; + import Plus from '@lucide/svelte/icons/plus'; + import { api } from '$lib/api/client'; + import { apiErrorCode, errorMessage } from '$lib/api/errors'; + import type { components } from '$lib/api/schema'; + import IconAction from '$lib/components/IconAction.svelte'; + import Button from '$lib/components/Button.svelte'; + import Card from '$lib/components/Card.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import FormField from '$lib/components/FormField.svelte'; + import Input from '$lib/components/Input.svelte'; + import { m } from '$lib/paraglide/messages'; + + type Department = components['schemas']['DepartmentOut']; + + // The page owns the list because the user manager reads it too; every + // change here reports back so both stay on the same set. + let { + departments, + onChanged + }: { departments: Department[]; onChanged: () => Promise<void> | void } = $props(); + + let error = $state<string | null>(null); + let creating = $state(false); + let editing = $state<Department | null>(null); + let draft = $state(''); + let confirmRequest = $state<{ title: string; message: string; run: () => void } | null>(null); + + function startCreate() { + error = null; + draft = ''; + creating = true; + } + + function startEdit(department: Department) { + error = null; + draft = department.name; + editing = department; + } + + async function create(event: SubmitEvent) { + event.preventDefault(); + error = null; + const { data, error: apiError } = await api.POST('/api/admin/departments', { + body: { name: draft } + }); + if (!data) { + error = errorMessage(apiErrorCode(apiError), m.admin_departments_create_failed()); + return; + } + creating = false; + await onChanged(); + } + + async function save(department: Department) { + error = null; + const { error: apiError } = await api.PATCH('/api/admin/departments/{department_id}', { + params: { path: { department_id: department.id } }, + body: { name: draft } + }); + if (apiError) { + error = errorMessage(apiErrorCode(apiError), m.admin_departments_rename_failed()); + return; + } + editing = null; + await onChanged(); + } + + function remove(department: Department) { + confirmRequest = { + title: m.common_delete(), + message: m.admin_departments_delete_confirm({ name: department.name }), + run: async () => { + // The modal is the admin's acknowledgement, so pass confirm — the + // backend otherwise refuses to silently drop a department still in + // use (its shared-access grants CASCADE away). + await api.DELETE('/api/admin/departments/{department_id}', { + params: { path: { department_id: department.id }, query: { confirm: true } } + }); + await onChanged(); + } + }; + } +</script> + +<Card> + <div class="mb-3 flex flex-wrap items-center justify-between gap-2"> + <h2 class="flex items-center gap-2 text-lg font-semibold"> + <Building2 size={18} class="text-ink-muted" /> + {m.admin_departments_title()} + </h2> + <Button size="sm" onclick={startCreate} data-testid="new-department"> + <Plus size={15} /> + {m.admin_departments_new_button()} + </Button> + </div> + + {#if error && !creating && !editing} + <p role="alert" class="mb-2 text-sm text-danger">{error}</p> + {/if} + + <ul class="flex flex-col gap-1" data-testid="department-list"> + {#each departments as department (department.id)} + <li + class="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-surface-sunken" + > + <span class="text-sm">{department.name}</span> + <span class="inline-flex items-center gap-1 whitespace-nowrap"> + <IconAction + label={m.admin_departments_rename()} + icon={Pencil} + size="sm" + onclick={() => startEdit(department)} + testid="rename-department" + /> + <IconAction + label={m.admin_users_delete()} + icon={Trash2} + size="sm" + variant="danger" + onclick={() => remove(department)} + /> + </span> + </li> + {/each} + </ul> +</Card> + +<Dialog + open={creating} + onOpenChange={(open) => { + if (!open) creating = false; + }} + title={m.admin_departments_create_title()} + data-testid="create-department-dialog" +> + <form class="flex flex-col gap-3" onsubmit={create}> + <FormField label={m.admin_departments_new()} for="new-department-name"> + <Input + id="new-department-name" + required + placeholder={m.admin_departments_placeholder()} + bind:value={draft} + /> + </FormField> + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + <div class="flex gap-2"> + <Button type="submit" size="sm" data-testid="create-department"> + {m.admin_departments_create()} + </Button> + <Button variant="ghost" size="sm" onclick={() => (creating = false)}> + {m.common_cancel()} + </Button> + </div> + </form> +</Dialog> + +<Dialog + open={editing !== null} + onOpenChange={(open) => { + if (!open) editing = null; + }} + title={m.admin_department_edit_title()} + data-testid="department-dialog" +> + {#if editing} + {@const department = editing} + <div class="flex flex-col gap-3"> + <FormField label={m.admin_departments_title()} for="edit-department-name"> + <Input + id="edit-department-name" + placeholder={m.admin_departments_placeholder()} + bind:value={draft} + /> + </FormField> + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + <div class="flex gap-2"> + <Button size="sm" onclick={() => save(department)} data-testid="save-department"> + {m.admin_users_save()} + </Button> + <Button variant="ghost" size="sm" onclick={() => (editing = null)}> + {m.common_cancel()} + </Button> + </div> + </div> + {/if} +</Dialog> + +<ConfirmDialog + open={confirmRequest !== null} + title={confirmRequest?.title ?? ''} + message={confirmRequest?.message ?? ''} + onConfirm={() => confirmRequest?.run()} + onClose={() => (confirmRequest = null)} +/> diff --git a/frontend/src/lib/admin/EndpointCheck.svelte b/frontend/src/lib/admin/EndpointCheck.svelte new file mode 100644 index 0000000..7a9fb23 --- /dev/null +++ b/frontend/src/lib/admin/EndpointCheck.svelte @@ -0,0 +1,59 @@ +<script lang="ts"> + import { api } from '$lib/api/client'; + import { errorMessage } from '$lib/api/errors'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import { m } from '$lib/paraglide/messages'; + + // First-line support: ping all three model roles and show what came back. + // A failure gets both halves, because they answer different questions: + // what it means for the product (the phrased reason), and what to fix + // (the sanitized technical detail). + type Status = components['schemas']['LLMTestResponse']; + + let status = $state<Status | null>(null); + let busy = $state(false); + + async function test() { + busy = true; + const { data } = await api.POST('/api/admin/llm/test'); + status = data ?? null; + busy = false; + } +</script> + +<div class="mt-4 border-t border-border pt-4"> + <Button size="sm" onclick={test} disabled={busy} data-testid="llm-test"> + {busy ? m.admin_llm_testing() : m.admin_llm_test()} + </Button> + {#if status} + <ul class="mt-3 flex flex-col gap-1.5 text-sm" data-testid="llm-results"> + {#each status.roles as role (role.role)} + <li class="flex flex-wrap items-center gap-2"> + <span + class="rounded-md px-2 py-0.5 text-xs font-medium {role.ok + ? 'bg-success-muted text-success' + : 'bg-danger-muted text-danger'}" + > + {role.ok ? 'ok' : 'error'} + </span> + <span class="font-medium">{role.role}</span> + <span class="text-ink-muted"> + {role.model} · {role.base_url} + {#if role.latency_ms !== null} + · {role.latency_ms} ms + {/if} + </span> + {#if !role.ok} + <span class="w-full text-xs text-ink-muted"> + {errorMessage(role.code)} + {#if role.error} + <code class="ml-1">{role.error}</code> + {/if} + </span> + {/if} + </li> + {/each} + </ul> + {/if} +</div> diff --git a/frontend/src/lib/admin/LlmSettings.svelte b/frontend/src/lib/admin/LlmSettings.svelte new file mode 100644 index 0000000..8f0f53b --- /dev/null +++ b/frontend/src/lib/admin/LlmSettings.svelte @@ -0,0 +1,292 @@ +<script lang="ts"> + import RotateCcw from '@lucide/svelte/icons/rotate-ccw'; + import Search from '@lucide/svelte/icons/search'; + import { api } from '$lib/api/client'; + import { errorMessage } from '$lib/api/errors'; + import type { components } from '$lib/api/schema'; + import Badge from '$lib/components/Badge.svelte'; + import Button from '$lib/components/Button.svelte'; + import FormField from '$lib/components/FormField.svelte'; + import Input from '$lib/components/Input.svelte'; + import Select from '$lib/components/Select.svelte'; + import Tooltip from '$lib/components/Tooltip.svelte'; + import { m } from '$lib/paraglide/messages'; + + type Setting = components['schemas']['LLMSettingOut']; + type Role = Setting['role']; + type Status = components['schemas']['LLMRoleStatus']; + + type Draft = { base_url: string; model: string; api_key: string }; + type Discovery = { models: string[]; supported: boolean; loading: boolean }; + + let settings = $state<Setting[]>([]); + // Per-role form state, seeded with the stored values so the fields show + // what is actually configured rather than an empty box over a placeholder. + let draft = $state<Record<string, Draft>>({}); + let tested = $state<Record<string, Status | undefined>>({}); + let discovered = $state<Record<string, Discovery | undefined>>({}); + let busy = $state<string | null>(null); + let error = $state<string | null>(null); + + async function refresh() { + const { data } = await api.GET('/api/admin/llm/settings'); + settings = data ?? []; + for (const setting of settings) { + draft[setting.role] = { + base_url: setting.base_url, + model: setting.model, + // Never round-trips: the key is write-only, so the field stays + // empty and an empty field means "keep the stored one". + api_key: '' + }; + } + } + + $effect(() => void refresh()); + + function candidate(role: Role) { + const form = draft[role]; + return { + role, + base_url: form.base_url || undefined, + model: form.model || undefined, + api_key: form.api_key || undefined + }; + } + + /** Validate before storing: a wrong endpoint should fail here, not on + * the next user question. */ + async function test(role: Role) { + busy = role; + error = null; + const { data } = await api.POST('/api/admin/llm/test', { body: candidate(role) }); + tested[role] = data?.roles?.[0]; + busy = null; + } + + /** Ask the endpoint what it serves. Runs server-side, so the key never + * leaves the backend. */ + async function discover(role: Role) { + const form = draft[role]; + discovered[role] = { models: [], supported: true, loading: true }; + const { data } = await api.POST('/api/admin/llm/models/{role}', { + params: { path: { role } }, + body: { base_url: form.base_url || undefined, api_key: form.api_key || undefined } + }); + discovered[role] = { + models: data?.models ?? [], + supported: data?.supported ?? false, + loading: false + }; + } + + async function save(role: Role) { + busy = role; + error = null; + const form = draft[role]; + const stored = settings.find((setting) => setting.role === role); + // Only send what actually changed. Submitting every field would mark + // the untouched ones as hand-edited, so fixing a URL would quietly + // claim the model no longer comes from .env. + const { error: failed } = await api.PUT('/api/admin/llm/settings/{role}', { + params: { path: { role } }, + body: { + base_url: form.base_url === stored?.base_url ? undefined : form.base_url, + model: form.model === stored?.model ? undefined : form.model, + // Empty means "keep the stored key", not "delete it". + api_key: form.api_key || undefined + } + }); + busy = null; + if (failed) { + error = m.admin_llm_save_failed(); + return; + } + tested[role] = undefined; + await refresh(); + } + + /** Put one field back to what .env says. Per field, because an admin who + * fixed the model has no reason to lose their URL. */ + async function reset(role: Role, field: 'base_url' | 'model' | 'api_key') { + busy = role; + await api.PUT('/api/admin/llm/settings/{role}', { + params: { path: { role } }, + body: { [`reset_${field}`]: true } + }); + busy = null; + tested[role] = undefined; + await refresh(); + } + + function sourceLabel(fromEnv: boolean): string { + return fromEnv ? m.admin_llm_source_env() : m.admin_llm_source_ui(); + } +</script> + +{#snippet source(setting: Setting, field: 'base_url' | 'model' | 'api_key', fromEnv: boolean)} + <span class="flex items-center gap-1 text-xs font-normal text-ink-muted"> + {sourceLabel(fromEnv)} + {#if !fromEnv} + <!-- Long sentence as the tooltip, short name as the accessible + label: a screen reader announcing a whole sentence for a small + icon button is noise. --> + <Tooltip + text={m.admin_llm_reset_field()} + label={m.admin_llm_reset_field_short()} + onclick={() => reset(setting.role, field)} + > + <span class="text-ink-muted transition-colors hover:text-ink"> + <RotateCcw size={12} /> + </span> + </Tooltip> + {/if} + </span> +{/snippet} + +<div class="flex flex-col gap-4" data-testid="llm-settings"> + <p class="text-xs text-ink-muted"> + {m.admin_llm_intro()} + </p> + + {#each settings as setting (setting.role)} + <div class="rounded-xl border border-border p-4"> + <div class="mb-3 flex flex-wrap items-center gap-2"> + <span class="font-medium">{setting.role}</span> + {#if tested[setting.role]} + <Badge variant={tested[setting.role]?.ok ? 'success' : 'danger'}> + {tested[setting.role]?.ok + ? `ok · ${tested[setting.role]?.latency_ms} ms` + : m.admin_llm_endpoint_failed()} + </Badge> + {#if !tested[setting.role]?.ok} + <!-- Why it failed, in words, plus the sanitized technical detail + the admin needs to fix it. --> + <span class="text-xs text-ink-muted"> + {errorMessage(tested[setting.role]?.code)} + {#if tested[setting.role]?.error} + <code class="ml-1">{tested[setting.role]?.error}</code> + {/if} + </span> + {/if} + {/if} + </div> + + <div class="grid gap-3 md:grid-cols-2"> + <div class="min-w-0"> + <FormField label={m.admin_llm_base_url()} for="{setting.role}-url"> + {#snippet hint()}{@render source( + setting, + 'base_url', + setting.base_url_from_env + )}{/snippet} + <Input + id="{setting.role}-url" + placeholder={m.admin_llm_base_url_placeholder()} + bind:value={draft[setting.role].base_url} + /> + </FormField> + </div> + <div class="min-w-0"> + <FormField label={m.admin_llm_model()} for="{setting.role}-model"> + {#snippet hint()}{@render source(setting, 'model', setting.model_from_env)}{/snippet} + <div class="flex items-center gap-1"> + {#if discovered[setting.role]?.models?.length} + <!-- A dropdown once we know what the endpoint serves; the + free-text field stays reachable via "Enter manually". --> + <Select + id="{setting.role}-model" + class="min-w-0 flex-1" + bind:value={draft[setting.role].model} + data-testid="model-select-{setting.role}" + > + {#each discovered[setting.role]?.models ?? [] as name (name)} + <option value={name}>{name}</option> + {/each} + </Select> + <Button + variant="ghost" + size="sm" + class="shrink-0 whitespace-nowrap" + onclick={() => (discovered[setting.role] = undefined)} + > + {m.admin_llm_enter_manually()} + </Button> + {:else} + <div class="min-w-0 flex-1"> + <Input + id="{setting.role}-model" + placeholder={m.admin_llm_model_placeholder()} + bind:value={draft[setting.role].model} + /> + </div> + <Button + variant="ghost" + size="sm" + class="shrink-0 whitespace-nowrap" + disabled={discovered[setting.role]?.loading} + onclick={() => discover(setting.role)} + data-testid="discover-{setting.role}" + > + <Search size={14} /> + {discovered[setting.role]?.loading + ? m.admin_llm_checking() + : m.admin_llm_check_models()} + </Button> + {/if} + </div> + </FormField> + {#if discovered[setting.role] && !discovered[setting.role]?.loading && !discovered[setting.role]?.supported} + <p class="mt-1 text-xs text-ink-muted" data-testid="no-model-list-{setting.role}"> + {m.admin_llm_no_model_list()} + </p> + {/if} + </div> + <div class="min-w-0 md:col-span-2"> + <FormField label={m.admin_llm_api_key()} for="{setting.role}-key"> + {#snippet hint()}{@render source( + setting, + 'api_key', + setting.api_key_from_env + )}{/snippet} + <Input + id="{setting.role}-key" + type="password" + placeholder={setting.api_key_set ? '••••••••' : m.admin_llm_api_key_unset()} + bind:value={draft[setting.role].api_key} + /> + </FormField> + </div> + </div> + + {#if tested[setting.role]?.error} + <p class="mt-2 text-xs text-danger">{tested[setting.role]?.error}</p> + {/if} + + <div class="mt-3 flex flex-wrap gap-2"> + <Button + variant="ghost" + size="sm" + disabled={busy !== null} + onclick={() => test(setting.role)} + data-testid="test-{setting.role}" + > + {busy === setting.role ? m.admin_llm_testing_role() : m.admin_llm_test_role()} + </Button> + <Button + size="sm" + disabled={busy !== null || !tested[setting.role]?.ok} + onclick={() => save(setting.role)} + data-testid="save-{setting.role}" + > + {m.admin_llm_save()} + </Button> + </div> + <p class="mt-2 text-xs text-ink-muted">{m.admin_llm_api_key_note()}</p> + </div> + {/each} + + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} +</div> diff --git a/frontend/src/lib/admin/PromptSettings.svelte b/frontend/src/lib/admin/PromptSettings.svelte new file mode 100644 index 0000000..d385030 --- /dev/null +++ b/frontend/src/lib/admin/PromptSettings.svelte @@ -0,0 +1,155 @@ +<script lang="ts"> + import Check from '@lucide/svelte/icons/check'; + import RotateCcw from '@lucide/svelte/icons/rotate-ccw'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import { m } from '$lib/paraglide/messages'; + + // Seven prompts in a column of identical grey boxes is a wall nobody reads. + // Each one is a card instead: what it is for, whether it still says what + // shipped, and — only when it has been touched — the buttons to keep or + // undo the change. The text itself is the widest thing on the page, + // monospaced and free to grow, because that is what is being edited. + type PromptSettingOut = components['schemas']['PromptSettingOut']; + + let prompts = $state<PromptSettingOut[]>([]); + // The editable copy per prompt, plus per-prompt UI flags. + let draft = $state<Record<string, string>>({}); + let busy = $state<Record<string, boolean>>({}); + let saved = $state<Record<string, boolean>>({}); + + // Labels live in i18n keyed by prompt id — the backend never sends UI copy. + const labels = $derived<Record<string, string>>({ + query_system: m.admin_prompt_query_system(), + query_no_sources: m.admin_prompt_query_no_sources(), + refine_persona: m.admin_prompt_refine_persona(), + refine_rules: m.admin_prompt_refine_rules(), + grounding_framing: m.admin_prompt_grounding_framing(), + topic_summary: m.admin_prompt_topic_summary(), + title: m.admin_prompt_title() + }); + + // What each prompt actually does, in one line — the label alone ("Chat: + // keine Treffer") does not tell an admin when it is used. + const hints = $derived<Record<string, string>>({ + query_system: m.admin_prompt_hint_query_system(), + query_no_sources: m.admin_prompt_hint_query_no_sources(), + refine_persona: m.admin_prompt_hint_refine_persona(), + refine_rules: m.admin_prompt_hint_refine_rules(), + grounding_framing: m.admin_prompt_hint_grounding_framing(), + topic_summary: m.admin_prompt_hint_topic_summary(), + title: m.admin_prompt_hint_title() + }); + + function seed(list: PromptSettingOut[]) { + prompts = list; + draft = Object.fromEntries(list.map((prompt) => [prompt.key, prompt.content])); + } + + async function refresh() { + const { data } = await api.GET('/api/admin/prompts'); + if (data) seed(data); + } + + $effect(() => { + void refresh(); + }); + + function apply(updated: PromptSettingOut) { + prompts = prompts.map((prompt) => (prompt.key === updated.key ? updated : prompt)); + } + + async function save(key: string) { + busy[key] = true; + saved[key] = false; + const { data } = await api.PUT('/api/admin/prompts/{key}', { + params: { path: { key } }, + body: { content: draft[key] } + }); + busy[key] = false; + if (data) { + apply(data); + saved[key] = true; + } + } + + async function reset(key: string) { + busy[key] = true; + saved[key] = false; + const { data } = await api.PUT('/api/admin/prompts/{key}', { + params: { path: { key } }, + body: { reset: true } + }); + busy[key] = false; + if (data) { + apply(data); + draft[key] = data.content; + } + } +</script> + +<p class="mb-4 max-w-prose text-sm text-ink-muted">{m.admin_prompts_hint()}</p> + +<div class="flex flex-col gap-3"> + {#each prompts as prompt (prompt.key)} + {@const dirty = draft[prompt.key] !== prompt.content} + <div + class="rounded-xl border bg-surface-raised p-4 transition-colors {dirty + ? 'border-accent/60' + : 'border-border'}" + data-testid="prompt-{prompt.key}" + > + <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1"> + <div class="min-w-0"> + <p class="font-medium">{labels[prompt.key] ?? prompt.key}</p> + <p class="mt-0.5 text-xs text-ink-muted">{hints[prompt.key] ?? ''}</p> + </div> + <!-- Only a prompt that no longer matches what shipped needs saying. --> + {#if !prompt.is_default} + <span class="shrink-0 text-xs text-secondary">{m.admin_prompt_changed()}</span> + {/if} + </div> + + <textarea + bind:value={draft[prompt.key]} + rows="5" + spellcheck="false" + class="mt-3 w-full resize-y rounded-lg border border-border bg-surface p-3 font-mono text-xs leading-relaxed text-ink transition-colors focus:border-border-strong focus:outline-none" + ></textarea> + + <div class="mt-2 flex flex-wrap items-center gap-2"> + {#if dirty} + <Button size="sm" onclick={() => save(prompt.key)} disabled={busy[prompt.key]}> + {m.admin_prompt_save()} + </Button> + <Button + variant="ghost" + size="sm" + onclick={() => (draft[prompt.key] = prompt.content)} + disabled={busy[prompt.key]} + > + {m.common_cancel()} + </Button> + {:else if saved[prompt.key]} + <span class="flex items-center gap-1 text-xs text-success"> + <Check size={13} /> + {m.admin_prompt_saved()} + </span> + {/if} + {#if !prompt.is_default} + <Button + variant="ghost" + size="sm" + class="ml-auto" + onclick={() => reset(prompt.key)} + disabled={busy[prompt.key]} + > + <RotateCcw size={14} /> + {m.admin_prompt_reset()} + </Button> + {/if} + </div> + </div> + {/each} +</div> diff --git a/frontend/src/lib/admin/TemplateBuilder.svelte b/frontend/src/lib/admin/TemplateBuilder.svelte new file mode 100644 index 0000000..f078bd7 --- /dev/null +++ b/frontend/src/lib/admin/TemplateBuilder.svelte @@ -0,0 +1,235 @@ +<script lang="ts"> + import Code from '@lucide/svelte/icons/code'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import TemplateSections from '$lib/admin/TemplateSections.svelte'; + import FormField from '$lib/components/FormField.svelte'; + import Input from '$lib/components/Input.svelte'; + import Select from '$lib/components/Select.svelte'; + import { m } from '$lib/paraglide/messages'; + import { untrack } from 'svelte'; + + type Detail = components['schemas']['TemplateDetail']; + type Config = components['schemas']['AuthoringTemplate']; + + type Props = { + /** The template being edited, or null to build a new one. */ + template: Detail | null; + /** Saved successfully — the caller refreshes and returns to the list. */ + onSaved: (detail: Detail) => void; + onCancel: () => void; + /** Present only for an existing template: switch to the raw YAML editor. + * A new template has no serialized YAML yet, so it stays form-only. */ + onShowYaml?: () => void; + }; + + let { template, onSaved, onCancel, onShowYaml }: Props = $props(); + + // The stored config IS an AuthoringTemplate; the detail types it loosely + // (JSONB), so we read it back through the generated shape. Read once to + // seed the form — the parent keys this component per target, so a fresh + // instance mounts whenever the edited template changes. + const cfg = untrack(() => template?.config) as Config | undefined; + + let name = $state(cfg?.name ?? ''); + let version = $state(cfg?.version ?? '1.0'); + let description = $state(cfg?.description ?? ''); + let persona = $state(cfg?.persona ?? ''); + let titleTemplate = $state(cfg?.title_template ?? '{{user.name}} ({{date}})'); + // '' stands in for null: "no fixed language". + let locale = $state<'de' | 'en' | ''>(cfg?.locale ?? ''); + let visibility = $state<Config['metadata']['visibility']>( + cfg?.metadata?.visibility ?? 'department' + ); + let temperature = $state(cfg?.model?.temperature ?? 0.4); + let minClass = $state(cfg?.model?.min_class_hint ?? ''); + let sections = $state((cfg?.sections ?? []).map((s) => ({ heading: s.heading, hint: s.hint }))); + + let busy = $state(false); + let error = $state<string | null>(null); + let nameError = $state<string | null>(null); + let sectionError = $state<string | null>(null); + + // The literal title placeholders, held as data so Svelte does not read the + // double braces as an expression. + const titleTokens = ['{{user.name}}', '{{date}}']; + + function slugify(value: string): string { + const slug = value + .toLowerCase() + .replaceAll('ä', 'ae') + .replaceAll('ö', 'oe') + .replaceAll('ü', 'ue') + .replaceAll('ß', 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug || 'vorlage'; + } + + // The skeleton is the document the editor opens with — one level-2 heading + // per section, in order. Deriving it here keeps the headings and the + // section hints from ever drifting apart. + function buildSkeleton(): string { + const headings = sections + .map((s) => s.heading.trim()) + .filter(Boolean) + .map((h) => `## ${h}`); + return headings.length ? headings.join('\n\n') + '\n' : ''; + } + + async function save() { + nameError = sectionError = error = null; + if (!name.trim()) { + nameError = m.admin_builder_error_name(); + return; + } + const kept = sections + .map((s) => ({ heading: s.heading.trim(), hint: s.hint.trim() })) + .filter((s) => s.heading); + if (kept.length === 0) { + sectionError = m.admin_builder_error_sections(); + return; + } + + const config: Config = { + id: cfg?.id ?? slugify(name), + name: name.trim(), + version: version.trim() || '1.0', + kind: 'authoring', + locale: locale === '' ? null : locale, + description: description.trim(), + model: { temperature: Number(temperature), min_class_hint: minClass.trim() || null }, + persona: persona.trim(), + skeleton: buildSkeleton(), + sections: kept, + title_template: titleTemplate.trim(), + metadata: { visibility } + }; + + busy = true; + const { data, error: failed } = await api.POST('/api/templates/build', { + body: { template_id: template?.id ?? null, config } + }); + busy = false; + if (failed || !data) { + error = (failed as { detail?: string })?.detail ?? m.admin_template_save_failed(); + return; + } + onSaved(data); + } +</script> + +<div class="flex flex-col gap-5" data-testid="template-builder"> + <div class="flex flex-wrap items-center justify-between gap-2"> + <span class="min-w-0 font-medium"> + {template ? template.name : m.admin_builder_new_heading()} + </span> + {#if onShowYaml} + <Button variant="ghost" size="sm" onclick={onShowYaml} data-testid="builder-show-yaml"> + <Code size={14} /> + {m.admin_builder_show_yaml()} + </Button> + {/if} + </div> + + <!-- Basics --> + <div class="grid gap-4 sm:grid-cols-2"> + <FormField label={m.admin_builder_name()} for="tpl-name" error={nameError}> + <Input + id="tpl-name" + bind:value={name} + placeholder={m.admin_template_name_placeholder()} + data-testid="builder-name" + /> + </FormField> + <FormField label={m.admin_builder_version()} for="tpl-version"> + {#snippet hint()} + <span class="text-xs text-ink-muted">{m.admin_template_version_hint()}</span> + {/snippet} + <Input id="tpl-version" bind:value={version} class="w-24" /> + </FormField> + </div> + + <FormField label={m.admin_builder_description()} for="tpl-desc"> + <Input + id="tpl-desc" + bind:value={description} + placeholder={m.admin_builder_description_placeholder()} + /> + </FormField> + + <FormField label={m.admin_builder_persona()} for="tpl-persona"> + <textarea + id="tpl-persona" + class="min-h-24 w-full resize-y rounded-xl border border-border bg-surface-raised px-3 py-2 text-sm transition-colors focus:border-border-strong focus:outline-none" + bind:value={persona} + placeholder={m.admin_builder_persona_placeholder()} + data-testid="builder-persona"></textarea> + </FormField> + + <TemplateSections bind:sections error={sectionError} /> + + <!-- Title, language, visibility, model. --> + <FormField label={m.admin_builder_title()} for="tpl-title"> + <Input id="tpl-title" bind:value={titleTemplate} data-testid="builder-title" /> + </FormField> + <p class="-mt-3 flex flex-wrap items-center gap-1.5 text-xs text-ink-muted"> + {m.admin_builder_title_tokens()} + {#each titleTokens as token (token)} + <code class="rounded bg-surface-sunken px-1 py-0.5">{token}</code> + {/each} + </p> + + <div class="grid gap-4 sm:grid-cols-2"> + <FormField label={m.admin_builder_locale()} for="tpl-locale"> + <Select id="tpl-locale" bind:value={locale}> + <option value="">{m.admin_builder_locale_unset()}</option> + <option value="de">{m.admin_builder_locale_de()}</option> + <option value="en">{m.admin_builder_locale_en()}</option> + </Select> + </FormField> + <FormField label={m.admin_builder_visibility()} for="tpl-visibility"> + <Select id="tpl-visibility" bind:value={visibility}> + <option value="public">{m.document_visibility_public()}</option> + <option value="department">{m.document_visibility_department()}</option> + <option value="restricted">{m.document_visibility_restricted()}</option> + </Select> + </FormField> + </div> + + <div class="grid gap-4 sm:grid-cols-2"> + <FormField label={m.admin_builder_temperature()} for="tpl-temp"> + {#snippet hint()} + <span class="text-xs text-ink-muted">{m.admin_builder_temperature_hint()}</span> + {/snippet} + <Input + id="tpl-temp" + type="number" + min="0" + max="1" + step="0.1" + bind:value={temperature} + class="w-24" + /> + </FormField> + <FormField label={m.admin_builder_min_class()} for="tpl-minclass"> + <Input + id="tpl-minclass" + bind:value={minClass} + placeholder={m.admin_builder_min_class_placeholder()} + /> + </FormField> + </div> + + {#if error} + <p role="alert" class="text-sm text-danger" data-testid="builder-error">{error}</p> + {/if} + + <div class="flex gap-2"> + <Button size="sm" disabled={busy} onclick={save} data-testid="builder-save"> + {busy ? m.admin_template_saving() : m.admin_template_save()} + </Button> + <Button variant="ghost" size="sm" onclick={onCancel}>{m.common_cancel()}</Button> + </div> +</div> diff --git a/frontend/src/lib/admin/TemplateCatalog.svelte b/frontend/src/lib/admin/TemplateCatalog.svelte new file mode 100644 index 0000000..def4674 --- /dev/null +++ b/frontend/src/lib/admin/TemplateCatalog.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import Eye from '@lucide/svelte/icons/eye'; + import Plus from '@lucide/svelte/icons/plus'; + import type { components } from '$lib/api/schema'; + import Badge from '$lib/components/Badge.svelte'; + import Button from '$lib/components/Button.svelte'; + import IconAction from '$lib/components/IconAction.svelte'; + import { m } from '$lib/paraglide/messages'; + + // The blueprints that ship with the product, inert until an admin adds one. + // Collapsed by default so the list above it stays the answer to "what can my + // colleagues use right now?". + type CatalogSummary = components['schemas']['CatalogSummary']; + + let { + catalog, + busy, + onPreview, + onAdd + }: { + catalog: CatalogSummary[]; + busy: boolean; + onPreview: (entry: CatalogSummary) => void; + onAdd: (catalogId: string) => void; + } = $props(); + + let open = $state(false); + const available = $derived(catalog.filter((entry) => !entry.added)); +</script> + +<div class="rounded-xl border border-border" data-testid="template-catalog"> + <button + class="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left" + onclick={() => (open = !open)} + data-testid="toggle-catalog" + > + <span class="text-sm font-medium">{m.admin_catalog_title()}</span> + <span class="text-xs whitespace-nowrap text-ink-muted"> + {m.admin_catalog_available({ count: available.length })} + </span> + </button> + {#if open} + <ul class="flex flex-col gap-2 border-t border-border p-3"> + {#each catalog as entry (entry.id)} + <li class="flex items-center gap-3 rounded-lg px-2 py-2"> + <div class="min-w-0 flex-1"> + <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1"> + <span class="text-sm font-medium break-words">{entry.name}</span> + <span class="text-xs whitespace-nowrap text-ink-muted"> + {m.admin_catalog_sections({ count: entry.sections })} + </span> + {#if entry.added} + <Badge variant="neutral">{m.admin_catalog_added()}</Badge> + {/if} + </div> + <p class="mt-0.5 text-xs text-ink-muted">{entry.description}</p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <IconAction + icon={Eye} + label={m.admin_template_view()} + size="sm" + onclick={() => onPreview(entry)} + /> + {#if !entry.added} + <Button size="sm" variant="ghost" disabled={busy} onclick={() => onAdd(entry.id)}> + <Plus size={14} /> + {m.admin_catalog_add()} + </Button> + {/if} + </div> + </li> + {/each} + </ul> + {/if} +</div> diff --git a/frontend/src/lib/admin/TemplateManager.svelte b/frontend/src/lib/admin/TemplateManager.svelte new file mode 100644 index 0000000..f2f35d2 --- /dev/null +++ b/frontend/src/lib/admin/TemplateManager.svelte @@ -0,0 +1,243 @@ +<script lang="ts"> + import Copy from '@lucide/svelte/icons/copy'; + import Pencil from '@lucide/svelte/icons/pencil'; + import Plus from '@lucide/svelte/icons/plus'; + import Trash2 from '@lucide/svelte/icons/trash-2'; + import { api } from '$lib/api/client'; + import { apiErrorCode, apiErrorDetail, errorMessage } from '$lib/api/errors'; + import type { components } from '$lib/api/schema'; + import TemplateBuilder from '$lib/admin/TemplateBuilder.svelte'; + import TemplateCatalog from '$lib/admin/TemplateCatalog.svelte'; + import TemplateYaml from '$lib/admin/TemplateYaml.svelte'; + import Button from '$lib/components/Button.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import IconAction from '$lib/components/IconAction.svelte'; + import { m } from '$lib/paraglide/messages'; + + // What this instance offers, and what it could offer. One surface with + // three faces: the list, the form builder that is the primary way to write + // a template, and raw YAML as the advanced escape hatch. + type Summary = components['schemas']['TemplateSummary']; + type Detail = components['schemas']['TemplateDetail']; + type CatalogSummary = components['schemas']['CatalogSummary']; + + let templates = $state<Summary[]>([]); + let catalog = $state<CatalogSummary[]>([]); + let editing = $state<Detail | null>(null); + /** Building a brand-new template (no row yet). */ + let creating = $state(false); + /** Raw YAML instead of the form; only for a template that has a row. */ + let yamlMode = $state(false); + /** A blueprint being previewed. Read-only: it has no row to save to. */ + let previewing = $state<CatalogSummary | null>(null); + let source = $state(''); + let busy = $state(false); + let error = $state<string | null>(null); + let confirmRequest = $state<{ message: string; run: () => void } | null>(null); + + const showBuilder = $derived(creating || (editing !== null && !yamlMode)); + const showYaml = $derived(previewing !== null || (editing !== null && yamlMode)); + + function reset() { + editing = null; + creating = false; + previewing = null; + yamlMode = false; + error = null; + } + + async function refresh() { + const [own, shipped] = await Promise.all([ + api.GET('/api/templates'), + api.GET('/api/templates/catalog') + ]); + templates = own.data ?? []; + catalog = shipped.data ?? []; + } + + $effect(() => void refresh()); + + function startCreate() { + reset(); + creating = true; + } + + async function edit(id: string) { + reset(); + const { data } = await api.GET('/api/templates/{template_id}', { + params: { path: { template_id: id } } + }); + if (!data) return; + editing = data; + source = data.yaml; + } + + async function preview(entry: CatalogSummary) { + reset(); + const { data } = await api.GET('/api/templates/catalog/{catalog_id}', { + params: { path: { catalog_id: entry.id } } + }); + if (!data) return; + previewing = entry; + source = data.yaml; + } + + /** A schema violation is the ONE failure whose English detail helps: it + * names the offending field, and an admin editing YAML is the reader. */ + function failureMessage(failed: unknown, fallback: string): string { + const code = apiErrorCode(failed); + if (code === 'invalid_template') return apiErrorDetail(failed) ?? fallback; + return errorMessage(code, fallback); + } + + async function save() { + if (!editing) return; + busy = true; + error = null; + const { data, error: failed } = await api.PUT('/api/templates/{template_id}', { + params: { path: { template_id: editing.id } }, + body: { yaml: source } + }); + busy = false; + if (failed || !data) { + error = failureMessage(failed, m.admin_template_save_failed()); + return; + } + reset(); + await refresh(); + } + + async function add(catalogId: string) { + busy = true; + error = null; + const { data, error: failed } = await api.POST('/api/templates/catalog/{catalog_id}', { + params: { path: { catalog_id: catalogId } } + }); + busy = false; + if (failed || !data) { + error = failureMessage(failed, m.admin_template_add_failed()); + return; + } + previewing = null; + await refresh(); + // Straight into the editor: adding is almost always the first half of + // "add and adapt to how we actually do it here". + await edit(data.id); + } + + async function duplicate(id: string) { + busy = true; + const { data } = await api.POST('/api/templates/{template_id}/duplicate', { + params: { path: { template_id: id } } + }); + busy = false; + await refresh(); + if (data) await edit(data.id); + } + + function remove(id: string, name: string) { + confirmRequest = { + message: m.admin_template_delete_confirm({ name }), + run: async () => { + await api.DELETE('/api/templates/{template_id}', { + params: { path: { template_id: id } } + }); + await refresh(); + } + }; + } +</script> + +<ConfirmDialog + open={confirmRequest !== null} + title={m.common_delete()} + message={confirmRequest?.message} + onConfirm={() => confirmRequest?.run()} + onClose={() => (confirmRequest = null)} +/> + +<div data-testid="template-list"> + {#if showBuilder} + <!-- Key on the target so the form seeds fresh when the edited template + (or new-vs-edit) changes, rather than keeping the first values. --> + {#key editing?.id ?? 'new'} + <TemplateBuilder + template={editing} + onSaved={async () => { + reset(); + await refresh(); + }} + onCancel={reset} + onShowYaml={editing ? () => (yamlMode = true) : undefined} + /> + {/key} + {:else if showYaml} + {@const blueprint = previewing} + <TemplateYaml + title={editing?.name ?? blueprint?.name ?? ''} + bind:yaml={source} + readonly={blueprint !== null} + {busy} + {error} + onCommit={() => (blueprint ? add(blueprint.id) : save())} + onCancel={reset} + onShowForm={editing ? () => (yamlMode = false) : undefined} + /> + {:else} + <div class="flex flex-col gap-4"> + <div class="flex justify-end"> + <Button size="sm" onclick={startCreate} data-testid="new-template"> + <Plus size={14} /> + {m.admin_template_new()} + </Button> + </div> + <ul class="flex flex-col gap-2"> + {#each templates as template (template.id)} + <li class="flex items-center gap-3 rounded-xl border border-border px-4 py-3"> + <div class="min-w-0 flex-1"> + <!-- The badges must never break mid-word, so the name gets the + flexible column and everything else stays nowrap. --> + <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1"> + <span class="font-medium break-words">{template.name}</span> + <span class="text-xs whitespace-nowrap text-ink-muted">v{template.version}</span> + </div> + {#if template.description} + <p class="mt-0.5 text-xs text-ink-muted">{template.description}</p> + {/if} + </div> + <div class="flex shrink-0 items-center gap-1"> + <IconAction + icon={Pencil} + label={m.admin_template_edit()} + size="sm" + onclick={() => edit(template.id)} + /> + <IconAction + icon={Copy} + label={m.admin_template_duplicate()} + size="sm" + onclick={() => duplicate(template.id)} + /> + <IconAction + icon={Trash2} + label={m.admin_template_delete()} + size="sm" + variant="danger" + onclick={() => remove(template.id, template.name)} + /> + </div> + </li> + {/each} + {#if templates.length === 0} + <li + class="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-ink-muted" + > + {m.admin_template_empty()} + </li> + {/if} + </ul> + + <TemplateCatalog {catalog} {busy} onPreview={preview} onAdd={add} /> + </div> + {/if} +</div> diff --git a/frontend/src/lib/admin/TemplateSections.svelte b/frontend/src/lib/admin/TemplateSections.svelte new file mode 100644 index 0000000..5c70228 --- /dev/null +++ b/frontend/src/lib/admin/TemplateSections.svelte @@ -0,0 +1,114 @@ +<script lang="ts"> + import ChevronDown from '@lucide/svelte/icons/chevron-down'; + import ChevronUp from '@lucide/svelte/icons/chevron-up'; + import Plus from '@lucide/svelte/icons/plus'; + import X from '@lucide/svelte/icons/x'; + import Button from '$lib/components/Button.svelte'; + import Input from '$lib/components/Input.svelte'; + import { m } from '$lib/paraglide/messages'; + + // The skeleton a document starts from: a heading becomes a document heading, + // its hint steers the refinement model under that heading. Order matters, so + // it is editable — with buttons rather than drag: dragging is not + // keyboard-accessible and does not work on touch. + export type Section = { heading: string; hint: string }; + + let { sections = $bindable(), error }: { sections: Section[]; error: string | null } = $props(); + + function move(index: number, delta: number) { + const target = index + delta; + if (target < 0 || target >= sections.length) return; + [sections[index], sections[target]] = [sections[target], sections[index]]; + } +</script> + +{#snippet iconButton( + label: string, + onclick: () => void, + disabled: boolean, + danger: boolean, + children: import('svelte').Snippet +)} + <button + type="button" + class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-ink-muted transition-colors disabled:opacity-30 {danger + ? 'hover:bg-danger-muted hover:text-danger' + : 'hover:bg-surface-sunken hover:text-ink'}" + {onclick} + {disabled} + aria-label={label} + > + {@render children()} + </button> +{/snippet} + +<div class="flex flex-col gap-2"> + <span class="text-sm font-medium text-ink">{m.admin_builder_sections()}</span> + <p class="text-xs text-ink-muted">{m.admin_builder_sections_hint()}</p> + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + + <div class="flex flex-col gap-2" data-testid="builder-sections"> + {#each sections as section, index (section)} + <div class="flex flex-col gap-2 rounded-xl border border-border p-3"> + <div class="flex items-center gap-2"> + <Input + bind:value={section.heading} + placeholder={m.admin_builder_section_heading_placeholder()} + data-testid="builder-section-heading" + /> + <div class="flex shrink-0 items-center gap-0.5"> + {#snippet up()}<ChevronUp size={15} />{/snippet} + {#snippet down()}<ChevronDown size={15} />{/snippet} + {#snippet remove()}<X size={15} />{/snippet} + {@render iconButton( + m.admin_builder_section_up(), + () => move(index, -1), + index === 0, + false, + up + )} + {@render iconButton( + m.admin_builder_section_down(), + () => move(index, 1), + index === sections.length - 1, + false, + down + )} + {@render iconButton( + m.admin_builder_section_remove(), + () => sections.splice(index, 1), + false, + true, + remove + )} + </div> + </div> + <textarea + class="min-h-16 w-full resize-y rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm transition-colors focus:border-border-strong focus:outline-none" + bind:value={section.hint} + placeholder={m.admin_builder_section_hint_placeholder()}></textarea> + </div> + {/each} + {#if sections.length === 0} + <p + class="rounded-xl border border-dashed border-border px-4 py-5 text-center text-sm text-ink-muted" + > + {m.admin_builder_sections_empty()} + </p> + {/if} + </div> + + <div> + <Button + variant="ghost" + size="sm" + onclick={() => sections.push({ heading: '', hint: '' })} + data-testid="builder-add-section" + > + <Plus size={14} /> + {m.admin_builder_section_add()} + </Button> + </div> +</div> diff --git a/frontend/src/lib/admin/TemplateYaml.svelte b/frontend/src/lib/admin/TemplateYaml.svelte new file mode 100644 index 0000000..309349d --- /dev/null +++ b/frontend/src/lib/admin/TemplateYaml.svelte @@ -0,0 +1,63 @@ +<script lang="ts"> + import Button from '$lib/components/Button.svelte'; + import { m } from '$lib/paraglide/messages'; + + // The advanced escape hatch: a template as raw YAML. Read-only when the + // source is a shipped blueprint, because a blueprint has no row to save to + // until it is added. + let { + title, + yaml = $bindable(), + readonly = false, + busy, + error, + onCommit, + onCancel, + onShowForm + }: { + title: string; + yaml: string; + readonly?: boolean; + busy: boolean; + error: string | null; + /** Save the edited YAML, or add the previewed blueprint. */ + onCommit: () => void; + onCancel: () => void; + /** Back to the form builder; absent while previewing a blueprint. */ + onShowForm?: () => void; + } = $props(); +</script> + +<div class="flex flex-col gap-3"> + <div class="flex flex-wrap items-center justify-between gap-2"> + <span class="min-w-0 font-medium">{title}</span> + {#if onShowForm} + <Button variant="ghost" size="sm" onclick={onShowForm} data-testid="show-form"> + {m.admin_builder_show_form()} + </Button> + {:else} + <span class="text-xs text-ink-muted">{m.admin_template_blueprint_hint()}</span> + {/if} + </div> + <textarea + class="h-96 w-full resize-none rounded-xl border border-border bg-surface px-3 py-2 font-mono text-xs transition-colors focus:border-border-strong focus:outline-none" + bind:value={yaml} + {readonly} + spellcheck="false" + data-testid="template-editor"></textarea> + {#if error} + <p role="alert" class="text-sm text-danger" data-testid="template-error">{error}</p> + {/if} + <div class="flex gap-2"> + {#if readonly} + <Button size="sm" disabled={busy} onclick={onCommit} data-testid="add-template"> + {busy ? m.admin_template_adding() : m.admin_template_add()} + </Button> + {:else} + <Button size="sm" disabled={busy} onclick={onCommit} data-testid="save-template"> + {busy ? m.admin_template_saving() : m.admin_template_save()} + </Button> + {/if} + <Button variant="ghost" size="sm" onclick={onCancel}>{m.common_cancel()}</Button> + </div> +</div> diff --git a/frontend/src/lib/admin/UserManager.svelte b/frontend/src/lib/admin/UserManager.svelte new file mode 100644 index 0000000..0d01998 --- /dev/null +++ b/frontend/src/lib/admin/UserManager.svelte @@ -0,0 +1,412 @@ +<script lang="ts"> + import KeyRound from '@lucide/svelte/icons/key-round'; + import Pencil from '@lucide/svelte/icons/pencil'; + import Trash2 from '@lucide/svelte/icons/trash-2'; + import UserPlus from '@lucide/svelte/icons/user-plus'; + import { api } from '$lib/api/client'; + import { apiErrorCode, errorMessage } from '$lib/api/errors'; + import type { components } from '$lib/api/schema'; + import IconAction from '$lib/components/IconAction.svelte'; + import Button from '$lib/components/Button.svelte'; + import Card from '$lib/components/Card.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import FormField from '$lib/components/FormField.svelte'; + import Input from '$lib/components/Input.svelte'; + import Select from '$lib/components/Select.svelte'; + import { m } from '$lib/paraglide/messages'; + + type AdminUser = components['schemas']['AdminUserOut']; + type Department = components['schemas']['DepartmentOut']; + + // Departments come from the page: this list only reads them (for the + // select and the name column), the department manager owns them. + let { departments }: { departments: Department[] } = $props(); + + const departmentNames = $derived(new Map(departments.map((d) => [d.id, d.name]))); + + let users = $state<AdminUser[]>([]); + let total = $state(0); + let perPage = $state(25); + let page = $state(1); + let search = $state(''); + let error = $state<string | null>(null); + + const pages = $derived(Math.max(1, Math.ceil(total / perPage))); + + async function load() { + const { data } = await api.GET('/api/admin/users', { + params: { query: { search: search.trim() || undefined, page } } + }); + users = data?.items ?? []; + total = data?.total ?? 0; + perPage = data?.per_page ?? perPage; + } + + $effect(() => { + void load(); + }); + + /** Any change to what is listed starts over at page one. */ + function reload() { + page = 1; + void load(); + } + + let searchDebounce: ReturnType<typeof setTimeout> | undefined; + function onSearch() { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(reload, 250); + } + + function goToPage(next: number) { + page = Math.min(Math.max(1, next), pages); + void load(); + } + + // One row at a time: an admin correcting an address is doing one thing, and + // a table full of open inputs invites half-finished edits nobody remembers + // making. Creating and editing share the same six fields, so they share a + // draft rather than growing two shapes for the same person. + const emptyDraft = { email: '', name: '', role: 'member', department: '', password: '' }; + let draft = $state({ ...emptyDraft }); + let editing = $state<AdminUser | null>(null); + let creating = $state(false); + let resetting = $state<AdminUser | null>(null); + let newPassword = $state(''); + let confirmRequest = $state<{ title: string; message: string; run: () => void } | null>(null); + + function startCreate() { + error = null; + draft = { ...emptyDraft }; + creating = true; + } + + function startEdit(user: AdminUser) { + error = null; + draft = { + email: user.email, + name: user.name, + role: user.role, + department: user.department_id ?? '', + password: '' + }; + editing = user; + } + + async function create(event: SubmitEvent) { + event.preventDefault(); + error = null; + const { data, error: apiError } = await api.POST('/api/admin/users', { + body: { + email: draft.email, + name: draft.name, + role: draft.role as 'member' | 'admin', + department_id: draft.department || null, + password: draft.password + } + }); + if (!data) { + error = errorMessage(apiErrorCode(apiError), m.admin_users_create_failed()); + return; + } + creating = false; + reload(); + } + + async function save(user: AdminUser) { + error = null; + const { error: apiError } = await api.PATCH('/api/admin/users/{user_id}', { + params: { path: { user_id: user.id } }, + body: { + email: draft.email, + name: draft.name, + role: draft.role as 'member' | 'admin', + // The two ways to say "no department" are different requests: + // omitting it changes nothing, clearing it is explicit. + department_id: draft.department || undefined, + clear_department: draft.department ? undefined : true + } + }); + if (apiError) { + error = errorMessage(apiErrorCode(apiError), m.admin_users_update_failed()); + return; + } + editing = null; + await load(); + } + + function startReset(user: AdminUser) { + error = null; + newPassword = ''; + resetting = user; + } + + async function resetPassword(event: SubmitEvent) { + event.preventDefault(); + if (!resetting) return; + error = null; + const { data, error: apiError } = await api.PATCH('/api/admin/users/{user_id}', { + params: { path: { user_id: resetting.id } }, + body: { password: newPassword } + }); + if (!data) { + error = errorMessage(apiErrorCode(apiError), m.admin_users_reset_failed()); + return; + } + resetting = null; + } + + function remove(user: AdminUser) { + confirmRequest = { + title: m.common_delete(), + message: m.admin_users_delete_confirm({ email: user.email }), + run: async () => { + await api.DELETE('/api/admin/users/{user_id}', { + params: { path: { user_id: user.id } } + }); + await load(); + } + }; + } +</script> + +<Card> + <div class="mb-3 flex flex-wrap items-center justify-between gap-2"> + <h2 class="text-lg font-semibold">{m.admin_users_title()}</h2> + <div class="flex flex-wrap items-center gap-2"> + <div class="w-72"> + <Input + placeholder={m.admin_users_search_placeholder()} + bind:value={search} + oninput={onSearch} + data-testid="user-search" + /> + </div> + <Button size="sm" onclick={startCreate} data-testid="new-user"> + <UserPlus size={15} /> + {m.admin_users_new()} + </Button> + </div> + </div> + + {#if error && !creating && !editing && !resetting} + <p role="alert" class="mb-2 text-sm text-danger">{error}</p> + {/if} + + <!-- Cells carry their own padding: without it the columns run into each + other and "florian@pablan.dev" reads as one word with "Florian". --> + <div class="overflow-x-auto"> + <table class="w-full min-w-xl text-sm"> + <thead> + <tr class="border-b border-border text-left text-xs text-ink-muted"> + <th class="py-1.5 pr-4 font-medium">{m.admin_users_email()}</th> + <th class="py-1.5 pr-4 font-medium">{m.admin_users_name()}</th> + <th class="py-1.5 pr-4 font-medium">{m.admin_users_role()}</th> + <th class="py-1.5 pr-4 font-medium">{m.admin_users_department()}</th> + <th class="py-1.5"><span class="sr-only">{m.admin_users_actions()}</span></th> + </tr> + </thead> + <tbody data-testid="user-table"> + {#each users as user (user.id)} + <tr class="border-b border-border"> + <td class="py-2 pr-4">{user.email}</td> + <td class="py-2 pr-4">{user.name}</td> + <td class="py-2 pr-4">{user.role}</td> + <td class="py-2 pr-4">{departmentNames.get(user.department_id ?? '') ?? '—'}</td> + <td class="py-2 text-right whitespace-nowrap"> + <span class="inline-flex items-center gap-1"> + <IconAction + label={m.admin_users_edit()} + icon={Pencil} + size="sm" + onclick={() => startEdit(user)} + testid="edit-user" + /> + <IconAction + label={m.admin_users_reset_password()} + icon={KeyRound} + size="sm" + onclick={() => startReset(user)} + /> + <IconAction + label={m.admin_users_delete()} + icon={Trash2} + size="sm" + variant="danger" + onclick={() => remove(user)} + /> + </span> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + + {#if users.length === 0} + <p class="py-4 text-sm text-ink-muted">{m.admin_users_empty()}</p> + {/if} + + {#if pages > 1} + <div class="mt-3 flex items-center justify-center gap-3" data-testid="user-pager"> + <Button variant="ghost" size="sm" disabled={page <= 1} onclick={() => goToPage(page - 1)}> + {m.documents_pager_previous()} + </Button> + <span class="text-xs text-ink-muted"> + {m.admin_pager_status({ page, pages, total })} + </span> + <Button variant="ghost" size="sm" disabled={page >= pages} onclick={() => goToPage(page + 1)}> + {m.documents_pager_next()} + </Button> + </div> + {/if} +</Card> + +<!-- Editing lives in a modal rather than an expanded row: the table's columns + are sized for reading, an edit needs room, and a dialog makes "I am + changing this person" unmistakable. --> +<Dialog + open={editing !== null} + onOpenChange={(open) => { + if (!open) editing = null; + }} + title={m.admin_users_edit_title()} + data-testid="user-dialog" +> + {#if editing} + {@const user = editing} + <div class="flex flex-col gap-3"> + {@render personFields('edit')} + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + <div class="flex gap-2"> + <Button size="sm" onclick={() => save(user)} data-testid="save-user"> + {m.admin_users_save()} + </Button> + <Button variant="ghost" size="sm" onclick={() => (editing = null)}> + {m.common_cancel()} + </Button> + </div> + </div> + {/if} +</Dialog> + +<Dialog + open={creating} + onOpenChange={(open) => { + if (!open) creating = false; + }} + title={m.admin_users_create_title()} + data-testid="create-user-dialog" +> + <form class="flex flex-col gap-3" onsubmit={create}> + {@render personFields('new')} + <FormField label={m.admin_users_password()} for="new-password"> + <Input + id="new-password" + type="password" + required + minlength={8} + placeholder={m.admin_users_password_placeholder()} + bind:value={draft.password} + /> + </FormField> + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + <div class="flex gap-2"> + <Button type="submit" size="sm" data-testid="create-user"> + {m.admin_users_create()} + </Button> + <Button variant="ghost" size="sm" onclick={() => (creating = false)}> + {m.common_cancel()} + </Button> + </div> + </form> +</Dialog> + +<!-- A password is set, never shown: the same modal shape as every other admin + action, instead of the browser's unstyled prompt(). --> +<Dialog + open={resetting !== null} + onOpenChange={(open) => { + if (!open) resetting = null; + }} + title={m.admin_users_reset_title()} + description={resetting?.email} + data-testid="reset-password-dialog" +> + <form class="flex flex-col gap-3" onsubmit={resetPassword}> + <FormField label={m.admin_users_password()} for="reset-password"> + <Input + id="reset-password" + type="password" + required + minlength={8} + placeholder={m.admin_users_password_placeholder()} + bind:value={newPassword} + /> + </FormField> + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + <div class="flex gap-2"> + <Button type="submit" size="sm" data-testid="save-password"> + {m.admin_users_save()} + </Button> + <Button variant="ghost" size="sm" onclick={() => (resetting = null)}> + {m.common_cancel()} + </Button> + </div> + </form> +</Dialog> + +<ConfirmDialog + open={confirmRequest !== null} + title={confirmRequest?.title ?? ''} + message={confirmRequest?.message ?? ''} + onConfirm={() => confirmRequest?.run()} + onClose={() => (confirmRequest = null)} +/> + +{#snippet personFields(prefix: string)} + <FormField label={m.admin_users_email()} for="{prefix}-email"> + <Input + id="{prefix}-email" + type="email" + required + placeholder={m.admin_users_email_placeholder()} + bind:value={draft.email} + /> + </FormField> + <FormField label={m.admin_users_name()} for="{prefix}-name"> + <Input + id="{prefix}-name" + required + placeholder={m.admin_users_name_placeholder()} + bind:value={draft.name} + /> + </FormField> + <div class="flex flex-wrap gap-3"> + <div class="min-w-36 flex-1"> + <FormField label={m.admin_users_role()} for="{prefix}-role"> + <Select id="{prefix}-role" bind:value={draft.role}> + <option value="member">member</option> + <option value="admin">admin</option> + </Select> + </FormField> + </div> + <div class="min-w-48 flex-1"> + <FormField label={m.admin_users_department()} for="{prefix}-department"> + <Select id="{prefix}-department" bind:value={draft.department}> + <option value="">{m.admin_users_no_department()}</option> + {#each departments as department (department.id)} + <option value={department.id}>{department.name}</option> + {/each} + </Select> + </FormField> + </div> + </div> +{/snippet} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..de4f90a --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,11 @@ +import createClient from 'openapi-fetch'; + +import type { paths } from './schema'; + +export function createApi(fetchFn: typeof globalThis.fetch = globalThis.fetch) { + return createClient<paths>({ fetch: fetchFn }); +} + +export type ApiClient = ReturnType<typeof createApi>; + +export const api = createApi(); diff --git a/frontend/src/lib/api/errors.ts b/frontend/src/lib/api/errors.ts new file mode 100644 index 0000000..604c0a8 --- /dev/null +++ b/frontend/src/lib/api/errors.ts @@ -0,0 +1,63 @@ +// Backend error codes phrased for the user. +// +// The API answers with `{detail, code}` and SSE error frames carry a bare +// `code` — the backend never renders UI language (CLAUDE.md). `detail` is for +// developers; everything a user reads is written here. + +import { m } from '$lib/paraglide/messages'; + +/** The `code` from an API error body, if it carries one. */ +export function apiErrorCode(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null && 'code' in error) { + const code = (error as { code: unknown }).code; + if (typeof code === 'string') return code; + } + return undefined; +} + +/** + * The `detail` from an API error body. + * + * Developer-facing English, so it is NOT for the user — with one exception the + * caller must justify: a validation failure whose detail names the offending + * field is the only thing that helps, and no code could carry it. + */ +export function apiErrorDetail(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null && 'detail' in error) { + const detail = (error as { detail: unknown }).detail; + if (typeof detail === 'string') return detail; + } + return undefined; +} + +/** + * A sentence for `code`, falling back to the caller's own wording. + * + * `fallback` is the action-specific message ("the user could not be created"), + * used when the code is one we have no better sentence for. Without one, a + * generic sentence is still better than a blank. + */ +export function errorMessage(code: string | null | undefined, fallback?: string): string { + // Written out rather than looked up by a built key: Paraglide is a + // compiler and can only check and tree-shake literal keys (docs/i18n.md). + switch (code) { + case 'llm_unreachable': + return m.llm_error_unreachable(); + case 'llm_busy': + return m.llm_error_busy(); + case 'llm_misconfigured': + return m.llm_error_misconfigured(); + case 'llm_failed': + return m.llm_error_failed(); + case 'email_taken': + return m.error_email_taken(); + case 'name_taken': + return m.error_name_taken(); + case 'self_modification': + return m.error_self_modification(); + case 'department_in_use': + return m.error_department_in_use(); + default: + return fallback ?? m.error_generic(); + } +} diff --git a/frontend/src/lib/api/refine.ts b/frontend/src/lib/api/refine.ts new file mode 100644 index 0000000..de72f4c --- /dev/null +++ b/frontend/src/lib/api/refine.ts @@ -0,0 +1,55 @@ +// SSE consumer for section refinement: POST the document + cursor line, get +// back which line range the suggestion replaces, then the refined section +// streamed token by token. Same fetch + ReadableStream pattern as the chat +// turn stream (EventSource cannot POST); the AbortSignal cancels the moment +// the user resumes typing. + +import { parseFrame } from '$lib/api/stream'; + +export type GroundingReference = { title: string; heading_path: string }; + +export type RefineEvent = + // The exact 1-based inclusive line range the accepted suggestion overwrites. + | { type: 'section'; start_line: number; end_line: number } + // What the suggestion is grounding on (the author's own readable material), + // for the "?" inspector; only sent when the section matched something. + | { type: 'grounding'; references: GroundingReference[] } + | { type: 'token'; text: string } + | { type: 'error'; code: string } + | { type: 'done' }; + +export async function* streamRefine( + documentId: string, + contentMd: string, + cursorLine: number, + signal: AbortSignal +): AsyncGenerator<RefineEvent> { + const response = await fetch(`/api/documents/${documentId}/refine`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content_md: contentMd, cursor_line: cursorLine }), + signal + }); + if (!response.ok || !response.body) { + throw new Error(`refine request failed (${response.status})`); + } + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + let frameEnd; + while ((frameEnd = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); + const event = parseFrame<RefineEvent>(frame); + if (event) yield event; + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts new file mode 100644 index 0000000..67e4b4a --- /dev/null +++ b/frontend/src/lib/api/schema.d.ts @@ -0,0 +1,3581 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login */ + post: operations["login_api_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Logout */ + post: operations["logout_api_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Me */ + get: operations["me_api_auth_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/account/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Change Password + * @description 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. + */ + post: operations["change_password_api_account_password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/account/locale": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set Locale + * @description 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. + */ + put: operations["set_locale_api_account_locale_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/account/document": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Personal Document + * @description 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. + */ + get: operations["personal_document_api_account_document_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/llm/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Llm Test + * @description First-line support tool: pings all three roles, or one candidate + * configuration without persisting anything. + */ + post: operations["llm_test_api_admin_llm_test_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/llm/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Llm Settings */ + get: operations["llm_settings_api_admin_llm_settings_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/llm/settings/{role}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Llm Setting + * @description 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. + */ + put: operations["update_llm_setting_api_admin_llm_settings__role__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/llm/models/{role}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Llm Models + * @description List what the endpoint serves, so the model field can be a dropdown. + */ + post: operations["llm_models_api_admin_llm_models__role__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/prompts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Prompt Settings + * @description Every editable system prompt with its effective text and whether it is + * still the shipped default. + */ + get: operations["prompt_settings_api_admin_prompts_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/prompts/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Prompt Setting + * @description Override a system prompt (applied without a restart) or reset it to the + * shipped default. Resetting deletes the override row. + */ + put: operations["update_prompt_setting_api_admin_prompts__key__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Users + * @description 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. + */ + get: operations["list_users_api_admin_users_get"]; + put?: never; + /** Create User */ + post: operations["create_user_api_admin_users_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete User + * @description Offboarding: sessions and conversations cascade, documents survive + * with author set to NULL. + */ + delete: operations["delete_user_api_admin_users__user_id__delete"]; + options?: never; + head?: never; + /** Update User */ + patch: operations["update_user_api_admin_users__user_id__patch"]; + trace?: never; + }; + "/api/admin/departments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Department */ + post: operations["create_department_api_admin_departments_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/departments/{department_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Department + * @description 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). + */ + delete: operations["delete_department_api_admin_departments__department_id__delete"]; + options?: never; + head?: never; + /** Rename Department */ + patch: operations["rename_department_api_admin_departments__department_id__patch"]; + trace?: never; + }; + "/api/admin/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Metrics Snapshot + * @description 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. + */ + get: operations["metrics_snapshot_api_admin_metrics_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/conversations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Conversations + * @description The sidebar list, newest activity first. The title is the first message, + * fetched as a correlated subquery so one statement answers the whole list. + */ + get: operations["list_conversations_api_conversations_get"]; + put?: never; + /** Create Conversation */ + post: operations["create_conversation_api_conversations_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/conversations/{conversation_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Conversation */ + get: operations["get_conversation_api_conversations__conversation_id__get"]; + put?: never; + post?: never; + /** + * Delete Conversation + * @description GDPR: users delete their own conversations; messages cascade. + */ + delete: operations["delete_conversation_api_conversations__conversation_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/conversations/{conversation_id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send Message */ + post: operations["send_message_api_conversations__conversation_id__messages_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/departments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Departments + * @description Department names for filters and pickers — not secret, any user. + */ + get: operations["list_departments_api_departments_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Documents + * @description 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. + */ + get: operations["list_documents_api_documents_get"]; + put?: never; + /** + * Create Document + * @description 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. + */ + post: operations["create_document_api_documents_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Documents + * @description 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. + */ + get: operations["search_documents_api_documents_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export Documents + * @description 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. + */ + get: operations["export_documents_api_documents_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Document Stats + * @description Is this a fresh install or a filled one? Read by the landing page's + * first-run guide. + */ + get: operations["document_stats_api_documents_stats_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Document */ + get: operations["get_document_api_documents__document_id__get"]; + put?: never; + post?: never; + /** Delete Document */ + delete: operations["delete_document_api_documents__document_id__delete"]; + options?: never; + head?: never; + /** Update Document */ + patch: operations["update_document_api_documents__document_id__patch"]; + trace?: never; + }; + "/api/documents/{document_id}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Document History + * @description 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. + */ + get: operations["document_history_api_documents__document_id__history_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/versions/{event_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Document Version + * @description 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. + */ + get: operations["document_version_api_documents__document_id__versions__event_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Publish Document + * @description 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. + */ + post: operations["publish_document_api_documents__document_id__publish_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/reviewers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Reviewers + * @description 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. + */ + get: operations["list_reviewers_api_documents__document_id__reviewers_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request Review + * @description 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. + */ + post: operations["request_review_api_documents__document_id__reviews_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/reviews/{review_id}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resolve Review + * @description 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. + */ + post: operations["resolve_review_api_documents__document_id__reviews__review_id__resolve_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/departments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set Shared Departments + * @description Replace the full set of ADDITIONAL departments this document is shared + * with. Author or admin only. + */ + put: operations["set_shared_departments_api_documents__document_id__departments_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/suggest-title": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Suggest Title + * @description Suggest a concise title from the document's content (review step for a + * new document). Owner-scoped; content in, title out, nothing logged. + */ + post: operations["suggest_title_api_documents__document_id__suggest_title_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/suggest-similar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Suggest Similar + * @description 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. + */ + post: operations["suggest_similar_api_documents_suggest_similar_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{document_id}/refine": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refine Section + * @description 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`). + */ + post: operations["refine_section_api_documents__document_id__refine_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/people": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List People + * @description Every colleague, ordered by name. Visible to any authenticated user. + */ + get: operations["list_people_api_people_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/people/{person_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Person + * @description One colleague's profile. + */ + get: operations["get_person_api_people__person_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/templates/catalog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Catalog + * @description The blueprints shipped with Pablan, each marked with whether this + * instance has already added it. + */ + get: operations["list_catalog_api_templates_catalog_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/templates/catalog/{catalog_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Catalog Blueprint + * @description Read a blueprint before adding it — the whole point of "view" is that + * an admin can see its structure before committing to it. + */ + get: operations["get_catalog_blueprint_api_templates_catalog__catalog_id__get"]; + put?: never; + /** + * Add From Catalog + * @description Copy a blueprint into this instance. The result is an ordinary + * template row: editable, and never touched by the catalog again. + */ + post: operations["add_from_catalog_api_templates_catalog__catalog_id__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/templates/build": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Build Template + * @description Save a template from the structured form builder. Creates a new row + * (template_id null) or updates one in place. + */ + post: operations["build_template_api_templates_build_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/templates/{template_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Template */ + get: operations["get_template_api_templates__template_id__get"]; + /** + * Update Template + * @description Replace a template's YAML. Validated against the schema on save. + */ + put: operations["update_template_api_templates__template_id__put"]; + post?: never; + /** + * Delete Template + * @description 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. + */ + delete: operations["delete_template_api_templates__template_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/templates/{template_id}/duplicate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Duplicate Template + * @description Fork a template — for trying a variant without losing the original. + * The copy gets a fresh config id so the two never collide. + */ + post: operations["duplicate_template_api_templates__template_id__duplicate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Templates + * @description 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. + */ + get: operations["list_templates_api_templates_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health_api_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record<string, never>; +export interface components { + schemas: { + /** + * AccessReason + * @description Why a document is visible to the requesting user. + * + * API-only (never stored): computed per request so the UI can explain + * access instead of leaving visibility rules implicit. + * @enum {string} + */ + AccessReason: "author" | "public" | "department" | "granted" | "review"; + /** AdminDepartmentOut */ + AdminDepartmentOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + }; + /** AdminUserOut */ + AdminUserOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Email */ + email: string; + /** Name */ + name: string; + role: components["schemas"]["UserRole"]; + /** Department Id */ + department_id: string | null; + }; + /** AdminUserPage */ + AdminUserPage: { + /** Items */ + items: components["schemas"]["AdminUserOut"][]; + /** Total */ + total: number; + /** Per Page */ + per_page: number; + }; + /** AuthoringTemplate */ + AuthoringTemplate: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Version */ + version: string; + /** + * Kind + * @default authoring + * @constant + */ + kind: "authoring"; + /** Locale */ + locale?: ("de" | "en") | null; + /** + * Description + * @default + */ + description: string; + /** + * @default { + * "temperature": 0.4 + * } + */ + model: components["schemas"]["TemplateModelHints"]; + /** Persona */ + persona: string; + /** Skeleton */ + skeleton: string; + /** + * Sections + * @default [] + */ + sections: components["schemas"]["SectionHint"][]; + /** Title Template */ + title_template: string; + /** + * @default { + * "visibility": "department" + * } + */ + metadata: components["schemas"]["TemplateMetadata"]; + }; + /** CatalogDetail */ + CatalogDetail: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Description */ + description: string; + /** Sections */ + sections: number; + /** Added */ + added: boolean; + /** Yaml */ + yaml: string; + }; + /** + * CatalogSummary + * @description A blueprint on disk. `id` is the config id, NOT a row id — a catalog + * entry has no row until someone adds it. + */ + CatalogSummary: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Description */ + description: string; + /** Sections */ + sections: number; + /** Added */ + added: boolean; + }; + /** ConversationCreate */ + ConversationCreate: { + mode: components["schemas"]["ConversationMode"]; + }; + /** ConversationDetail */ + ConversationDetail: { + /** + * Id + * Format: uuid + */ + id: string; + /** Title */ + title?: string | null; + /** + * Messages + * @default [] + */ + messages: components["schemas"]["MessageOut"][]; + }; + /** + * ConversationMode + * @enum {string} + */ + ConversationMode: "query" | "insight"; + /** ConversationSummary */ + ConversationSummary: { + /** + * Id + * Format: uuid + */ + id: string; + /** Title */ + title?: string | null; + }; + /** DepartmentCreate */ + DepartmentCreate: { + /** Name */ + name: string; + }; + /** DepartmentOut */ + DepartmentOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + }; + /** DepartmentRef */ + DepartmentRef: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + }; + /** + * DocumentCreate + * @description 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. + */ + DocumentCreate: { + /** Template Id */ + template_id?: string | null; + /** Title */ + title?: string | null; + visibility?: components["schemas"]["DocumentVisibility"] | null; + /** Conversation Id */ + conversation_id?: string | null; + }; + /** + * DocumentDepartments + * @description The full set of ADDITIONAL departments the document is shared with (on + * top of the owning department) — replaces the existing grants. + */ + DocumentDepartments: { + /** Department Ids */ + department_ids: string[]; + /** Confirm Lockout */ + confirm_lockout?: boolean | null; + }; + /** DocumentDetail */ + DocumentDetail: { + /** + * Id + * Format: uuid + */ + id: string; + /** Title */ + title: string; + status: components["schemas"]["DocumentStatus"]; + visibility: components["schemas"]["DocumentVisibility"]; + /** Department Id */ + department_id: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + access_reason: components["schemas"]["AccessReason"]; + /** Can Edit */ + can_edit: boolean; + /** Open Reviews */ + open_reviews: number; + /** Is Builtin */ + is_builtin: boolean; + /** Content Md */ + content_md: string; + /** + * Reviews + * @default [] + */ + reviews: components["schemas"]["ReviewOut"][]; + /** + * Shared Departments + * @default [] + */ + shared_departments: components["schemas"]["DepartmentRef"][]; + }; + /** + * DocumentEventAction + * @description A recorded step in a document's audit history. + * + * Content-bearing actions (created / edited) snapshot the Markdown source of + * truth; the rest record only who did what and when. + * @enum {string} + */ + DocumentEventAction: "created" | "edited" | "published" | "archived" | "visibility_changed" | "review_requested" | "review_resolved"; + /** + * DocumentEventOut + * @description One entry in a document's history timeline — metadata only. + */ + DocumentEventOut: { + /** + * Id + * Format: uuid + */ + id: string; + action: components["schemas"]["DocumentEventAction"]; + /** Actor Id */ + actor_id: string | null; + /** Actor Name */ + actor_name: string | null; + visibility: components["schemas"]["DocumentVisibility"] | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Has Snapshot */ + has_snapshot: boolean; + }; + /** DocumentPage */ + DocumentPage: { + /** Items */ + items: components["schemas"]["DocumentSummary"][]; + /** Total */ + total: number; + /** Per Page */ + per_page: number; + }; + /** + * DocumentSearchHit + * @description A search result: the document plus the section that matched. + * + * Empty `heading_path` means the match was on the title, not a section. + */ + DocumentSearchHit: { + /** + * Id + * Format: uuid + */ + id: string; + /** Title */ + title: string; + status: components["schemas"]["DocumentStatus"]; + visibility: components["schemas"]["DocumentVisibility"]; + /** Department Id */ + department_id: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + access_reason: components["schemas"]["AccessReason"]; + /** Can Edit */ + can_edit: boolean; + /** Open Reviews */ + open_reviews: number; + /** Is Builtin */ + is_builtin: boolean; + /** + * Heading Path + * @default + */ + heading_path: string; + }; + /** + * DocumentSort + * @description 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. + * @enum {string} + */ + DocumentSort: "updated" | "created"; + /** + * DocumentStats + * @description 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. + */ + DocumentStats: { + /** Documents Total */ + documents_total: number; + /** Departments Total */ + departments_total: number; + }; + /** + * DocumentStatus + * @description Where a document stands. + * + * Three states, because publishing is the author's own decision: a draft is + * private, a published document is visible and indexed, an archived one is + * neither. Uncertainty about CONTENT is not a status — it is an open review + * request (`ReviewRequest`), which can sit on a published document too. + * @enum {string} + */ + DocumentStatus: "draft" | "published" | "archived"; + /** DocumentSummary */ + DocumentSummary: { + /** + * Id + * Format: uuid + */ + id: string; + /** Title */ + title: string; + status: components["schemas"]["DocumentStatus"]; + visibility: components["schemas"]["DocumentVisibility"]; + /** Department Id */ + department_id: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + access_reason: components["schemas"]["AccessReason"]; + /** Can Edit */ + can_edit: boolean; + /** Open Reviews */ + open_reviews: number; + /** Is Builtin */ + is_builtin: boolean; + }; + /** DocumentUpdate */ + DocumentUpdate: { + /** Title */ + title?: string | null; + /** Content Md */ + content_md?: string | null; + visibility?: components["schemas"]["DocumentVisibility"] | null; + status?: components["schemas"]["DocumentStatus"] | null; + /** Confirm Lockout */ + confirm_lockout?: boolean | null; + /** Conversation Id */ + conversation_id?: string | null; + }; + /** + * DocumentVersion + * @description 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. + */ + DocumentVersion: { + /** + * Id + * Format: uuid + */ + id: string; + action: components["schemas"]["DocumentEventAction"]; + /** Actor Id */ + actor_id: string | null; + /** Actor Name */ + actor_name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Title */ + title: string | null; + /** Content Md */ + content_md: string | null; + /** Previous Content Md */ + previous_content_md: string | null; + visibility: components["schemas"]["DocumentVisibility"] | null; + }; + /** + * DocumentVisibility + * @enum {string} + */ + DocumentVisibility: "public" | "department" | "restricted"; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * LLMModelsRequest + * @description Optional candidate endpoint, so an admin can list the models of a + * URL they have typed but not saved. + */ + LLMModelsRequest: { + /** Base Url */ + base_url?: string | null; + /** Api Key */ + api_key?: string | null; + }; + /** LLMModelsResponse */ + LLMModelsResponse: { + /** Models */ + models: string[]; + /** Supported */ + supported: boolean; + /** Error */ + error?: string | null; + }; + /** LLMRoleStatus */ + LLMRoleStatus: { + /** + * Role + * @enum {string} + */ + role: "chat" | "utility" | "embedding"; + /** Ok */ + ok: boolean; + /** Base Url */ + base_url: string; + /** Model */ + model: string; + /** Latency Ms */ + latency_ms: number | null; + /** Code */ + code: string | null; + /** Error */ + error: string | null; + }; + /** + * LLMSettingOut + * @description 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. + */ + LLMSettingOut: { + /** + * Role + * @enum {string} + */ + role: "chat" | "utility" | "embedding"; + /** Base Url */ + base_url: string; + /** Model */ + model: string; + /** Base Url From Env */ + base_url_from_env: boolean; + /** Model From Env */ + model_from_env: boolean; + /** Api Key Set */ + api_key_set: boolean; + /** Api Key From Env */ + api_key_from_env: boolean; + }; + /** LLMSettingUpdate */ + LLMSettingUpdate: { + /** Base Url */ + base_url?: string | null; + /** Model */ + model?: string | null; + /** Api Key */ + api_key?: string | null; + /** Reset Base Url */ + reset_base_url?: boolean | null; + /** Reset Model */ + reset_model?: boolean | null; + /** Reset Api Key */ + reset_api_key?: boolean | null; + }; + /** + * LLMTestRequest + * @description Optional candidate config: test an endpoint BEFORE saving it. + */ + LLMTestRequest: { + /** Role */ + role?: ("chat" | "utility" | "embedding") | null; + /** Base Url */ + base_url?: string | null; + /** Model */ + model?: string | null; + /** Api Key */ + api_key?: string | null; + }; + /** LLMTestResponse */ + LLMTestResponse: { + /** Roles */ + roles: components["schemas"]["LLMRoleStatus"][]; + }; + /** + * LocalePreference + * @description The languages the interface ships in — the frontend bundles must + * cover exactly these. + */ + LocalePreference: { + /** Locale */ + locale?: ("de" | "en") | null; + }; + /** LoginRequest */ + LoginRequest: { + /** Email */ + email: string; + /** Password */ + password: string; + }; + /** MessageOut */ + MessageOut: { + /** + * Id + * Format: uuid + */ + id: string; + role: components["schemas"]["MessageRole"]; + /** Content */ + content: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Sources + * @default [] + */ + sources: components["schemas"]["MessageSource"][]; + /** Fallback */ + fallback?: string | null; + }; + /** + * MessageRole + * @enum {string} + */ + MessageRole: "user" | "assistant" | "system"; + /** MessageSource */ + MessageSource: { + /** + * Document Id + * Format: uuid + */ + document_id: string; + /** Title */ + title: string; + /** Heading Path */ + heading_path: string; + /** + * Excerpt + * @default + */ + excerpt: string; + /** + * Used + * @default true + */ + used: boolean; + /** + * Review Pending + * @default false + */ + review_pending: boolean; + }; + /** PasswordChange */ + PasswordChange: { + /** Current Password */ + current_password: string; + /** New Password */ + new_password: string; + }; + /** + * PersonOut + * @description A colleague as the directory shows them — never email or credentials. + */ + PersonOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + role: components["schemas"]["UserRole"]; + /** Department */ + department: string | null; + }; + /** + * PersonalDocument + * @description 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. + */ + PersonalDocument: { + /** Document Id */ + document_id?: string | null; + /** Title */ + title?: string | null; + status?: components["schemas"]["DocumentStatus"] | null; + /** Template Id */ + template_id?: string | null; + }; + /** PromptSettingOut */ + PromptSettingOut: { + /** Key */ + key: string; + /** Content */ + content: string; + /** Is Default */ + is_default: boolean; + }; + /** PromptSettingUpdate */ + PromptSettingUpdate: { + /** Content */ + content?: string | null; + /** Reset */ + reset?: boolean | null; + }; + /** RefineRequest */ + RefineRequest: { + /** Content Md */ + content_md: string; + /** Cursor Line */ + cursor_line: number; + }; + /** + * ReviewOut + * @description One request to check this document. Open while `resolved_at` is null. + */ + ReviewOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Question */ + question: string | null; + /** Requester Name */ + requester_name: string | null; + /** Reviewer Id */ + reviewer_id: string | null; + /** Reviewer Name */ + reviewer_name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Resolved At */ + resolved_at: string | null; + /** Resolved By Name */ + resolved_by_name: string | null; + /** Is Mine */ + is_mine: boolean; + }; + /** + * ReviewRequestBody + * @description Ask someone to check this document, optionally about something specific + * ("do the holiday numbers still hold?"). + */ + ReviewRequestBody: { + /** + * Reviewer Id + * Format: uuid + */ + reviewer_id: string; + /** Question */ + question?: string | null; + }; + /** + * ReviewerCandidate + * @description A user the author may ask to check a document — id + name only. + */ + ReviewerCandidate: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + }; + /** + * SectionHint + * @description 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. + */ + SectionHint: { + /** Heading */ + heading: string; + /** Hint */ + hint: string; + }; + /** SendMessage */ + SendMessage: { + /** Content */ + content: string; + }; + /** SimilarDocumentOut */ + SimilarDocumentOut: { + /** + * Document Id + * Format: uuid + */ + document_id: string; + /** Title */ + title: string; + }; + /** SuggestSimilarRequest */ + SuggestSimilarRequest: { + /** + * Conversation Id + * Format: uuid + */ + conversation_id: string; + }; + /** + * TemplateBuildRequest + * @description 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. + */ + TemplateBuildRequest: { + /** Template Id */ + template_id?: string | null; + config: components["schemas"]["AuthoringTemplate"]; + }; + /** TemplateDetail */ + TemplateDetail: { + /** + * Id + * Format: uuid + */ + id: string; + /** Config Id */ + config_id: string; + /** Name */ + name: string; + /** Version */ + version: string; + /** + * Description + * @default + */ + description: string; + /** Config */ + config: { + [key: string]: unknown; + }; + /** Yaml */ + yaml: string; + }; + /** TemplateImportRequest */ + TemplateImportRequest: { + /** Yaml */ + yaml: string; + }; + /** TemplateMetadata */ + TemplateMetadata: { + /** + * Visibility + * @default department + * @enum {string} + */ + visibility: "public" | "department" | "restricted"; + }; + /** TemplateModelHints */ + TemplateModelHints: { + /** + * Temperature + * @default 0.4 + */ + temperature: number; + /** Min Class Hint */ + min_class_hint?: string | null; + }; + /** TemplateSummary */ + TemplateSummary: { + /** + * Id + * Format: uuid + */ + id: string; + /** Config Id */ + config_id: string; + /** Name */ + name: string; + /** Version */ + version: string; + /** + * Description + * @default + */ + description: string; + }; + /** TitleSuggestion */ + TitleSuggestion: { + /** Title */ + title: string; + }; + /** UserCreate */ + UserCreate: { + /** Email */ + email: string; + /** Name */ + name: string; + /** @default member */ + role: components["schemas"]["UserRole"]; + /** Department Id */ + department_id?: string | null; + /** Password */ + password: string; + }; + /** UserOut */ + UserOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Email */ + email: string; + /** Name */ + name: string; + role: components["schemas"]["UserRole"]; + /** Department Id */ + department_id: string | null; + /** Locale */ + locale?: ("de" | "en") | null; + }; + /** + * UserRole + * @enum {string} + */ + UserRole: "member" | "admin"; + /** UserUpdate */ + UserUpdate: { + /** Name */ + name?: string | null; + /** Email */ + email?: string | null; + role?: components["schemas"]["UserRole"] | null; + /** Department Id */ + department_id?: string | null; + /** Clear Department */ + clear_department?: boolean | null; + /** Password */ + password?: string | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record<string, never>; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record<string, never>; +export interface operations { + login_api_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logout_api_auth_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + me_api_auth_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"]; + }; + }; + }; + }; + change_password_api_account_password_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChange"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_locale_api_account_locale_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalePreference"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + personal_document_api_account_document_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonalDocument"]; + }; + }; + }; + }; + llm_test_api_admin_llm_test_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LLMTestRequest"] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LLMTestResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + llm_settings_api_admin_llm_settings_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LLMSettingOut"][]; + }; + }; + }; + }; + update_llm_setting_api_admin_llm_settings__role__put: { + parameters: { + query?: never; + header?: never; + path: { + role: "chat" | "utility" | "embedding"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LLMSettingUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LLMSettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + llm_models_api_admin_llm_models__role__post: { + parameters: { + query?: never; + header?: never; + path: { + role: "chat" | "utility" | "embedding"; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LLMModelsRequest"] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LLMModelsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + prompt_settings_api_admin_prompts_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PromptSettingOut"][]; + }; + }; + }; + }; + update_prompt_setting_api_admin_prompts__key__put: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PromptSettingUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PromptSettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_users_api_admin_users_get: { + parameters: { + query?: { + search?: string | null; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserPage"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_user_api_admin_users_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_user_api_admin_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_user_api_admin_users__user_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_department_api_admin_departments_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DepartmentCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminDepartmentOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_department_api_admin_departments__department_id__delete: { + parameters: { + query?: { + confirm?: boolean; + }; + header?: never; + path: { + department_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + rename_department_api_admin_departments__department_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + department_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DepartmentCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminDepartmentOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + metrics_snapshot_api_admin_metrics_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + list_conversations_api_conversations_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationSummary"][]; + }; + }; + }; + }; + create_conversation_api_conversations_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConversationCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_conversation_api_conversations__conversation_id__get: { + parameters: { + query?: never; + header?: never; + path: { + conversation_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_conversation_api_conversations__conversation_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + conversation_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + send_message_api_conversations__conversation_id__messages_post: { + parameters: { + query?: never; + header?: never; + path: { + conversation_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendMessage"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_departments_api_departments_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DepartmentOut"][]; + }; + }; + }; + }; + list_documents_api_documents_get: { + parameters: { + query?: { + department?: string | null; + status?: components["schemas"]["DocumentStatus"] | null; + assigned_to_me?: boolean; + search?: string | null; + sort?: components["schemas"]["DocumentSort"]; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentPage"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_document_api_documents_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DocumentCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_documents_api_documents_search_get: { + parameters: { + query: { + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentSearchHit"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + export_documents_api_documents_export_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + document_stats_api_documents_stats_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentStats"]; + }; + }; + }; + }; + get_document_api_documents__document_id__get: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_document_api_documents__document_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_document_api_documents__document_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DocumentUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + document_history_api_documents__document_id__history_get: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentEventOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + document_version_api_documents__document_id__versions__event_id__get: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentVersion"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + publish_document_api_documents__document_id__publish_post: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_reviewers_api_documents__document_id__reviewers_get: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewerCandidate"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + request_review_api_documents__document_id__reviews_post: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReviewRequestBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + resolve_review_api_documents__document_id__reviews__review_id__resolve_post: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + review_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_shared_departments_api_documents__document_id__departments_put: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DocumentDepartments"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + suggest_title_api_documents__document_id__suggest_title_post: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TitleSuggestion"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + suggest_similar_api_documents_suggest_similar_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SuggestSimilarRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SimilarDocumentOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + refine_section_api_documents__document_id__refine_post: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefineRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_people_api_people_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonOut"][]; + }; + }; + }; + }; + get_person_api_people__person_id__get: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_catalog_api_templates_catalog_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogSummary"][]; + }; + }; + }; + }; + get_catalog_blueprint_api_templates_catalog__catalog_id__get: { + parameters: { + query?: never; + header?: never; + path: { + catalog_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + add_from_catalog_api_templates_catalog__catalog_id__post: { + parameters: { + query?: never; + header?: never; + path: { + catalog_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + build_template_api_templates_build_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_template_api_templates__template_id__get: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_template_api_templates__template_id__put: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateImportRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_template_api_templates__template_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + duplicate_template_api_templates__template_id__duplicate_post: { + parameters: { + query?: never; + header?: never; + path: { + template_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_templates_api_templates_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateSummary"][]; + }; + }; + }; + }; + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; +} diff --git a/frontend/src/lib/api/stream.ts b/frontend/src/lib/api/stream.ts new file mode 100644 index 0000000..78f9202 --- /dev/null +++ b/frontend/src/lib/api/stream.ts @@ -0,0 +1,92 @@ +// SSE consumer for conversation turns: fetch + ReadableStream (NOT +// EventSource — it cannot POST), with an AbortSignal wired to the stop +// button. + +export type SourceChunk = { + document_id: string; + title: string; + heading_path: string; + excerpt: string; + // Whether the passage actually grounded the answer, or was only retrieved + // and dropped as too weak (a no-answer turn). Absent on old messages, which + // were all used — treat undefined as true. + used?: boolean; + // The cited document has an unanswered request to check it: readable, but + // not settled. Marked wherever the source appears. + review_pending?: boolean; +}; + +export type StreamEvent = + | { type: 'token'; text: string } + | { type: 'sources'; chunks: SourceChunk[] } + // Query progress, metadata only: phase is + // searching | results | no_answer | answering; count is documents found. + | { type: 'state'; phase: string; count: number | null } + | { type: 'error'; code: string } + // No model was reachable: `sources` is a plain full-text result list for + // the user to open, and no answer follows. `code` says why. + | { type: 'fallback'; code: string } + | { type: 'done'; message_id: string | null }; + +export async function* streamMessage( + conversationId: string, + content: string, + signal: AbortSignal +): AsyncGenerator<StreamEvent> { + yield* streamTurn( + `/api/conversations/${conversationId}/messages`, + JSON.stringify({ content }), + signal + ); +} + +async function* streamTurn( + url: string, + body: string | null, + signal: AbortSignal +): AsyncGenerator<StreamEvent> { + const response = await fetch(url, { + method: 'POST', + headers: body ? { 'content-type': 'application/json' } : undefined, + body, + signal + }); + if (!response.ok || !response.body) { + throw new Error(`stream request failed (${response.status})`); + } + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + let frameEnd; + while ((frameEnd = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); + const event = parseFrame<StreamEvent>(frame); + if (event) yield event; + } + } + } finally { + reader.releaseLock(); + } +} + +/** Parse one `event:`/`data:` SSE frame into a typed object, or null. Shared + * shape used by the chat turn stream and the section-refinement stream. */ +export function parseFrame<T>(frame: string): T | null { + let eventName = ''; + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('event: ')) { + eventName = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + data += line.slice(6); + } + } + if (!eventName || !data) return null; + return { type: eventName, ...JSON.parse(data) } as T; +} diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..eb0b57c --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="400" height="400" role="img" aria-label="Pablan network P"> + <path d="M30,84 L30,18 L64,18 L64,48 L30,48" stroke="#d97a00" stroke-width="3" fill="none"></path> + <circle cx="30" cy="84" r="8" fill="#c0261b"></circle> + <circle cx="30" cy="48" r="6" fill="#d75a00"></circle> + <circle cx="64" cy="48" r="6" fill="#d97a00"></circle> + <circle cx="64" cy="18" r="6" fill="#d99400"></circle> + <circle cx="30" cy="18" r="7" fill="#e0a800"></circle> +</svg> diff --git a/frontend/src/lib/chat/AssistantTurn.svelte b/frontend/src/lib/chat/AssistantTurn.svelte new file mode 100644 index 0000000..d38093e --- /dev/null +++ b/frontend/src/lib/chat/AssistantTurn.svelte @@ -0,0 +1,106 @@ +<script lang="ts"> + import ClipboardList from '@lucide/svelte/icons/clipboard-list'; + import Info from '@lucide/svelte/icons/info'; + import type { SourceChunk } from '$lib/api/stream'; + import type { ChatMessage } from '$lib/chat/state.svelte'; + import ContextInspector from '$lib/chat/ContextInspector.svelte'; + import FallbackResults from '$lib/chat/FallbackResults.svelte'; + import SourceBadge from '$lib/chat/SourceBadge.svelte'; + import { groupSources } from '$lib/chat/sources'; + import Markdown from '$lib/components/Markdown.svelte'; + import { m } from '$lib/paraglide/messages'; + + // One reply, in whichever state it is in: still streaming, answered and + // cited, answered without cover, or not answered at all because no model + // could be reached. Each of those tells the reader something different + // about how much to trust what they see. + let { + message, + statusLine, + slow, + captureHref, + onOpenSource + }: { + message: ChatMessage; + /** Retrieval progress, only for the turn currently streaming. */ + statusLine: string | null; + slow: boolean; + captureHref: string; + onOpenSource: (source: SourceChunk) => void; + } = $props(); + + const cited = $derived(message.sources.filter((source) => source.used !== false)); +</script> + +<div class="flex max-w-prose flex-col gap-2 self-start"> + <div class="rounded-xl bg-surface px-3 py-2 text-sm" data-testid="assistant-message"> + {#if message.streaming && statusLine} + <p class="mb-1 animate-pulse text-xs text-ink-muted" data-testid="retrieval-status"> + {statusLine} + </p> + {/if} + {#if message.streaming && slow} + <p class="mb-1 text-xs text-warning" data-testid="slow-status">{m.llm_status_slow()}</p> + {/if} + + <!-- Render the sanitized Markdown LIVE as it streams, so the reply is + unveiled already-formatted rather than snapping from plain text to + Markdown when it settles. The blinking cursor marks that more is + still coming. --> + <Markdown content={message.content} /> + {#if message.streaming} + <span class="ml-0.5 animate-pulse text-secondary">▍</span> + {/if} + + {#if message.fallback} + <FallbackResults code={message.fallback} sources={message.sources} onOpen={onOpenSource} /> + {:else} + {#if cited.length > 0} + <!-- One badge per document, not per chunk: two matching sections of + one document are one source. Only passages that grounded the + answer become badges; the "?" inspector shows the full set. --> + <div class="mt-2 flex flex-wrap items-center gap-1" data-testid="sources"> + <span class="mr-0.5 text-xs text-ink-muted">{m.chat_sources_label()}</span> + {#each groupSources(cited) as source (source.document_id)} + <SourceBadge {source} onOpen={onOpenSource} /> + {/each} + </div> + {/if} + {#if !message.streaming && message.sources.length > 0} + <!-- What the model was working from: every retrieved passage, marking + which grounded the answer (this explains a no-answer too). --> + <div class="mt-1.5"> + <ContextInspector sources={message.sources} /> + </div> + {/if} + {/if} + + {#if message.noAnswer && !message.streaming} + <!-- The answer stands, but it is not backed by the knowledge base — + say so where it applies. --> + <p + class="mt-2 flex items-center gap-1.5 border-t border-border pt-2 text-xs text-ink-muted" + data-testid="no-sources-note" + > + <Info size={12} class="shrink-0" /> + {m.chat_no_sources_note()} + </p> + {/if} + </div> + + {#if message.noAnswer && !message.streaming} + <!-- A gap in the knowledge base: offer to write it down. --> + <!-- captureHref IS resolved; the rule cannot see through the query string + the conversation id is passed as. --> + <!-- eslint-disable svelte/no-navigation-without-resolve --> + <a + href={captureHref} + class="flex w-fit cursor-pointer items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs text-ink-muted transition-colors hover:border-accent hover:text-ink" + data-testid="capture-gap" + > + <ClipboardList size={13} /> + {m.chat_capture_gap()} + </a> + <!-- eslint-enable svelte/no-navigation-without-resolve --> + {/if} +</div> diff --git a/frontend/src/lib/chat/ChatView.svelte b/frontend/src/lib/chat/ChatView.svelte new file mode 100644 index 0000000..b710188 --- /dev/null +++ b/frontend/src/lib/chat/ChatView.svelte @@ -0,0 +1,206 @@ +<script lang="ts"> + import ClipboardList from '@lucide/svelte/icons/clipboard-list'; + import { afterNavigate, goto } from '$app/navigation'; + import { resolve } from '$app/paths'; + import type { SourceChunk } from '$lib/api/stream'; + import AssistantTurn from '$lib/chat/AssistantTurn.svelte'; + import Composer from '$lib/chat/Composer.svelte'; + import DocumentPanel from '$lib/chat/DocumentPanel.svelte'; + import { chatState as chat } from '$lib/chat/state.svelte'; + import { m } from '$lib/paraglide/messages'; + import { untrack } from 'svelte'; + + type Props = { + /** The conversation this view shows, or null for a fresh composer. */ + conversationId: string | null; + /** Asked once, on mount — the landing page hand-off. */ + initialQuestion?: string; + }; + + let { conversationId, initialQuestion = '' }: Props = $props(); + + let draft = $state(''); + let scroller = $state<HTMLElement | null>(null); + let openSource = $state<SourceChunk | null>(null); + let inputEl = $state<HTMLTextAreaElement | null>(null); + + // Keep the composer focused across the /chat -> /chat/[id] navigation that + // the first message triggers: that swaps the page component, so the old + // textarea is destroyed and `keepFocus` cannot help. Re-focus once the new + // view has mounted, so the next message can be typed straight away. + afterNavigate(() => inputEl?.focus()); + + const splitOpen = $derived(openSource !== null); + + // Capturing from a chat carries the conversation, so the picker can suggest + // matching documents and the draft keeps the chat as background context. + const captureHref = $derived( + resolve('/documents/new') + (chat.activeId ? `?conversation=${chat.activeId}` : '') + ); + + // The transient status line above a streaming reply. The backend sends a + // phase and counts; the sentence is written here, because the backend never + // renders UI-language strings. + const statusLine = $derived.by(() => { + const progress = chat.retrieval; + if (!progress) return null; + if (progress.phase === 'searching') return m.chat_status_searching(); + if (progress.phase === 'results') { + return m.chat_status_results({ count: progress.count ?? 0 }); + } + if (progress.phase === 'no_answer') return m.chat_status_no_answer(); + // Every slot the endpoint has is taken by someone else's turn. + if (progress.phase === 'queued') return m.chat_status_queued(); + if (progress.phase === 'answering') return m.chat_status_answering(); + return null; + }); + + // Entering chat refreshes the shared list the sidebar renders — it may have + // gone stale since the app shell mounted. + $effect(() => { + void chat.loadConversations(); + }); + + // The route owns which conversation is shown: /chat/[id] for an existing + // one, /chat for a fresh composer. Reacting to the param rather than to + // clicks is what makes back/forward and a pasted link behave, and it is the + // single place a switch can abort the previous stream. + $effect(() => { + const id = conversationId; + untrack(() => { + if (id === chat.activeId) return; + // Switching away mid-answer: the old stream must not keep writing into + // a view that now belongs to another conversation. + chat.stop(); + openSource = null; + if (id) { + void chat.open(id); + } else { + chat.startNew(); + } + }); + }); + + // Give a conversation its own address as soon as it has one, without + // interrupting the answer already streaming into it. + $effect(() => { + const active = chat.activeId; + if (!active || active === conversationId) return; + void goto(resolve(`/chat/${active}`), { + replaceState: true, + noScroll: true, + keepFocus: true + }); + }); + + // The landing hand-off, consumed exactly once. + let handedOff = false; + $effect(() => { + const question = initialQuestion; + untrack(() => { + if (question && !handedOff) { + handedOff = true; + // Not awaited: streaming takes seconds, and navigation must stay + // responsive while the answer arrives. + void chat.send(question); + } + }); + }); + + // Follow the stream: scroll down whenever the last message grows. + $effect(() => { + const last = chat.messages.at(-1); + void last?.content; + if (scroller) { + scroller.scrollTop = scroller.scrollHeight; + } + }); + + async function send() { + const content = draft.trim(); + if (!content || chat.streaming) return; + draft = ''; + await chat.send(content); + } +</script> + +<!-- A split view uses the whole window; a single column stays readable. + Below lg there is no room for two columns, so the panels stack. --> +<div + class="mx-auto flex min-h-0 w-full flex-1 flex-col gap-4 lg:flex-row {splitOpen + ? 'max-w-none' + : 'max-w-5xl'}" +> + <section + class="relative flex min-h-0 min-w-0 flex-1 flex-col rounded-2xl border border-border bg-surface-raised" + class:hidden={openSource !== null} + class:lg:flex={openSource !== null} + > + <div class="flex items-center justify-end gap-2 border-b border-border px-4 py-2"> + <!-- captureHref IS resolved; the rule cannot see through the query + string the conversation id is passed as. --> + <!-- eslint-disable svelte/no-navigation-without-resolve --> + <a + href={captureHref} + class="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-ink-muted transition-colors hover:text-ink" + data-testid="chat-capture-link" + > + <ClipboardList size={16} /> + {m.chat_capture_button()} + </a> + <!-- eslint-enable svelte/no-navigation-without-resolve --> + </div> + + <div bind:this={scroller} class="flex-1 overflow-y-auto p-4"> + {#if chat.messages.length === 0} + <!-- The one promise worth making before the first question: the + answer comes out of your own documents, and says which. --> + <div class="mx-auto mt-10 flex max-w-sm flex-col items-center gap-1.5 text-center"> + <p class="text-sm font-medium">{m.chat_empty_query()}</p> + <p class="text-xs text-ink-muted">{m.chat_empty_hint()}</p> + </div> + {/if} + <div class="flex flex-col gap-3"> + {#each chat.messages as message, index (index)} + {#if message.role === 'user'} + <div + class="max-w-prose self-end rounded-xl bg-primary px-3 py-2 text-sm text-primary-fg" + > + {message.content} + </div> + {:else} + <AssistantTurn + {message} + {statusLine} + {captureHref} + slow={chat.slow} + onOpenSource={(source) => (openSource = source)} + /> + {/if} + {/each} + </div> + </div> + + {#if chat.error} + <p role="alert" class="border-t border-border px-4 py-2 text-sm text-danger"> + {chat.error} + </p> + {/if} + + <Composer + bind:draft + bind:input={inputEl} + streaming={chat.streaming} + onSubmit={send} + onStop={() => chat.stop()} + /> + </section> + + {#if openSource} + <DocumentPanel + documentId={openSource.document_id} + headingPath={openSource.heading_path} + onClose={() => (openSource = null)} + /> + {/if} +</div> diff --git a/frontend/src/lib/chat/Composer.svelte b/frontend/src/lib/chat/Composer.svelte new file mode 100644 index 0000000..cf5aac5 --- /dev/null +++ b/frontend/src/lib/chat/Composer.svelte @@ -0,0 +1,75 @@ +<script lang="ts"> + import ArrowUp from '@lucide/svelte/icons/arrow-up'; + import Square from '@lucide/svelte/icons/square'; + import Button from '$lib/components/Button.svelte'; + import { m } from '$lib/paraglide/messages'; + + // Ask, or stop. The same button position does both, so the answer can be + // interrupted where it was started. + let { + draft = $bindable(), + streaming, + input = $bindable(), + onSubmit, + onStop + }: { + draft: string; + streaming: boolean; + input: HTMLTextAreaElement | null; + onSubmit: () => void; + onStop: () => void; + } = $props(); + + function submit(event: SubmitEvent) { + event.preventDefault(); + onSubmit(); + } + + function onKeydown(event: KeyboardEvent) { + // Enter sends, Shift+Enter breaks the line: a question is usually one + // line, and a chat that needs a mouse to send is slower than talking. + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + (event.currentTarget as HTMLElement).closest('form')?.requestSubmit(); + } + } +</script> + +<div class="p-3"> + <form + class="rounded-2xl border border-border bg-surface-sunken p-2.5 transition-colors focus-within:border-border-strong" + onsubmit={submit} + > + <textarea + bind:this={input} + class="max-h-40 min-h-10 w-full resize-none bg-transparent px-1.5 py-1 text-sm placeholder:text-ink-muted focus:outline-none" + placeholder={m.chat_input_placeholder()} + rows="1" + bind:value={draft} + onkeydown={onKeydown} + data-testid="chat-input"></textarea> + <div class="flex justify-end px-0.5"> + {#if streaming} + <Button + size="icon" + type="button" + aria-label={m.chat_stop()} + onclick={onStop} + data-testid="stop-button" + > + <Square size={16} /> + </Button> + {:else} + <Button + type="submit" + size="icon" + aria-label={m.chat_send()} + disabled={!draft.trim()} + data-testid="send-button" + > + <ArrowUp size={18} /> + </Button> + {/if} + </div> + </form> +</div> diff --git a/frontend/src/lib/chat/ContextInspector.svelte b/frontend/src/lib/chat/ContextInspector.svelte new file mode 100644 index 0000000..5d8475f --- /dev/null +++ b/frontend/src/lib/chat/ContextInspector.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import AlertTriangle from '@lucide/svelte/icons/triangle-alert'; + import HelpCircle from '@lucide/svelte/icons/circle-help'; + import type { SourceChunk } from '$lib/api/stream'; + import Popover from '$lib/components/Popover.svelte'; + import { m } from '$lib/paraglide/messages'; + + // Every passage retrieval surfaced for this turn — the ones that grounded + // the answer (`used`) and the ones that were too weak (a no-answer turn). + let { sources }: { sources: SourceChunk[] } = $props(); +</script> + +{#if sources.length > 0} + <Popover triggerLabel={m.chat_context_label()} contentClass="max-w-md"> + {#snippet trigger()} + <span + class="flex items-center gap-1 px-1.5 py-0.5 text-xs text-ink-muted transition-colors hover:text-ink" + data-testid="context-inspector" + > + <HelpCircle size={13} /> + {m.chat_context_label()} + </span> + {/snippet} + <p class="mb-2 text-xs font-medium text-ink-muted">{m.chat_context_title()}</p> + <ul class="flex max-h-72 flex-col gap-2 overflow-y-auto"> + {#each sources as source, index (source.document_id + source.heading_path + index)} + <li class="border-t border-border pt-2 first:border-t-0 first:pt-0"> + <div class="flex items-start justify-between gap-2"> + <span class="flex min-w-0 items-center gap-1 truncate font-medium"> + {#if source.review_pending} + <AlertTriangle size={12} class="shrink-0 text-warning" /> + {/if} + {source.heading_path || source.title} + </span> + <span + class="shrink-0 text-xs {source.used === false ? 'text-ink-muted' : 'text-success'}" + > + {source.used === false ? m.chat_context_unused() : m.chat_context_used()} + </span> + </div> + {#if source.excerpt} + <p class="mt-0.5 line-clamp-3 text-xs text-ink-muted">{source.excerpt}</p> + {/if} + </li> + {/each} + </ul> + </Popover> +{/if} diff --git a/frontend/src/lib/chat/DocumentPanel.svelte b/frontend/src/lib/chat/DocumentPanel.svelte new file mode 100644 index 0000000..15f5740 --- /dev/null +++ b/frontend/src/lib/chat/DocumentPanel.svelte @@ -0,0 +1,118 @@ +<script lang="ts"> + import AlertTriangle from '@lucide/svelte/icons/triangle-alert'; + import X from '@lucide/svelte/icons/x'; + import ExternalLink from '@lucide/svelte/icons/external-link'; + import { resolve } from '$app/paths'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import { m } from '$lib/paraglide/messages'; + import Badge from '$lib/components/Badge.svelte'; + import Markdown from '$lib/components/Markdown.svelte'; + import { bodyWithoutTitle, statusLabel, visibilityLabel } from '$lib/documents/presentation'; + + type DocumentDetail = components['schemas']['DocumentDetail']; + + type Props = { + documentId: string; + /** Cited section, e.g. "Wartung › Intervalle" — scrolled to if found. */ + headingPath?: string; + onClose: () => void; + }; + + let { documentId, headingPath = '', onClose }: Props = $props(); + + let document = $state<DocumentDetail | null>(null); + let missing = $state(false); + let body = $state<HTMLElement | null>(null); + + // Must match rag/chunking.py HEADING_PATH_SEPARATOR. + const HEADING_SEPARATOR = ' › '; + + $effect(() => { + const id = documentId; + document = null; + missing = false; + void (async () => { + const { data, response } = await api.GET('/api/documents/{document_id}', { + params: { path: { document_id: id } } + }); + if (!data) { + missing = response.status === 404; + return; + } + document = data; + })(); + }); + + // Once the Markdown is in the DOM, jump to the cited section. Best + // effort: an unmatched heading just leaves the panel at the top. + $effect(() => { + void document?.content_md; + const target = headingPath.split(HEADING_SEPARATOR).at(-1)?.trim(); + if (!body || !target) return; + queueMicrotask(() => { + const heading = [...(body?.querySelectorAll('h1, h2, h3, h4') ?? [])].find( + (element) => element.textContent?.trim() === target + ); + heading?.scrollIntoView({ block: 'start' }); + }); + }); +</script> + +<aside + class="flex min-h-0 w-full min-w-0 flex-col rounded-2xl border border-border bg-surface-raised lg:w-[45%] lg:shrink-0" + data-testid="document-panel" +> + <div class="flex items-start justify-between gap-2 border-b border-border px-4 py-2"> + <div class="min-w-0"> + <p class="truncate text-sm font-medium"> + {document?.title ?? m.document_fallback_title()} + </p> + {#if headingPath} + <p class="truncate text-xs text-ink-muted">{headingPath}</p> + {/if} + </div> + <div class="flex shrink-0 items-center gap-1"> + {#if document} + <a + href={resolve(`/documents/${document.id}`)} + class="rounded-md p-1 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink" + aria-label={m.panel_open_full_page()} + > + <ExternalLink size={16} /> + </a> + {/if} + <button + class="cursor-pointer rounded-md p-1 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink" + onclick={onClose} + aria-label={m.panel_close()} + > + <X size={16} /> + </button> + </div> + </div> + <div bind:this={body} class="min-h-0 flex-1 overflow-y-auto p-4"> + {#if missing} + <p class="text-sm text-ink-muted">{m.panel_missing()}</p> + {:else if document} + <div class="mb-3 flex flex-wrap items-center gap-1"> + <Badge>{statusLabel(document.status)}</Badge> + <Badge>{visibilityLabel(document.visibility)}</Badge> + </div> + {#if document.open_reviews > 0} + <!-- The same warning the chat badge carries, restated where the text + is actually read. --> + <p + class="mb-3 flex items-start gap-1.5 rounded-lg bg-warning-muted px-3 py-2 text-xs text-warning" + data-testid="panel-review-pending" + > + <AlertTriangle size={13} class="mt-0.5 shrink-0" /> + {m.chat_source_review_pending()} + </p> + {/if} + <Markdown content={bodyWithoutTitle(document.content_md, document.title)} /> + {:else} + <p class="text-sm text-ink-muted">{m.panel_loading()}</p> + {/if} + </div> +</aside> diff --git a/frontend/src/lib/chat/FallbackResults.svelte b/frontend/src/lib/chat/FallbackResults.svelte new file mode 100644 index 0000000..8463c3a --- /dev/null +++ b/frontend/src/lib/chat/FallbackResults.svelte @@ -0,0 +1,60 @@ +<script lang="ts"> + import AlertTriangle from '@lucide/svelte/icons/triangle-alert'; + import FileText from '@lucide/svelte/icons/file-text'; + import Info from '@lucide/svelte/icons/info'; + import { errorMessage } from '$lib/api/errors'; + import type { SourceChunk } from '$lib/api/stream'; + import { groupSources } from '$lib/chat/sources'; + import { m } from '$lib/paraglide/messages'; + + // No model answered this turn: what retrieval found IS the reply, as a list + // the reader opens themselves. Said plainly, because a search without a + // model finds only what is written literally. + let { + code, + sources, + onOpen + }: { code: string; sources: SourceChunk[]; onOpen: (source: SourceChunk) => void } = $props(); +</script> + +<p class="flex items-start gap-1.5 text-xs text-warning" data-testid="fallback-note"> + <Info size={12} class="mt-0.5 shrink-0" /> + <span>{errorMessage(code)} {m.chat_fallback_note()}</span> +</p> + +{#if sources.length === 0} + <p class="mt-2 text-sm text-ink-muted">{m.chat_fallback_empty()}</p> +{:else} + <ul class="mt-2 flex flex-col gap-1" data-testid="fallback-results"> + {#each groupSources(sources) as source (source.document_id)} + <li> + <button + type="button" + class="w-full cursor-pointer rounded-lg border border-border px-3 py-2 text-left transition-colors hover:border-border-strong" + onclick={() => onOpen(source.chunks[0])} + > + <span class="flex items-center gap-1.5 text-sm font-medium"> + {#if source.reviewPending} + <AlertTriangle size={13} class="shrink-0 text-warning" /> + {:else} + <FileText size={13} class="shrink-0 text-accent" /> + {/if} + {source.title} + </span> + {#if source.reviewPending} + <span class="mt-0.5 block text-xs text-warning"> + {m.chat_source_review_pending()} + </span> + {/if} + {#if source.chunks[0].heading_path} + <span class="mt-0.5 block text-xs text-ink-muted"> + {source.chunks[0].heading_path} + </span> + {/if} + <!-- Plain text: an excerpt is document content, unrendered. --> + <span class="mt-1 block text-xs text-ink-muted">{source.chunks[0].excerpt}</span> + </button> + </li> + {/each} + </ul> +{/if} diff --git a/frontend/src/lib/chat/SourceBadge.svelte b/frontend/src/lib/chat/SourceBadge.svelte new file mode 100644 index 0000000..79bbbab --- /dev/null +++ b/frontend/src/lib/chat/SourceBadge.svelte @@ -0,0 +1,55 @@ +<script lang="ts"> + import AlertTriangle from '@lucide/svelte/icons/triangle-alert'; + import FileText from '@lucide/svelte/icons/file-text'; + import type { SourceChunk } from '$lib/api/stream'; + import type { GroupedSource } from '$lib/chat/sources'; + import Tooltip from '$lib/components/Tooltip.svelte'; + import { m } from '$lib/paraglide/messages'; + + type Props = { + source: GroupedSource; + onOpen: (source: SourceChunk) => void; + }; + + let { source, onOpen }: Props = $props(); + + const matches = $derived(source.chunks.length); +</script> + +<!-- Opening jumps to the best-matching section, which is the first chunk: + retrieval returns them in relevance order. --> +<Tooltip side="top" onclick={() => onOpen(source.chunks[0])} data-testid="source-badge"> + {#snippet content()} + {#if source.reviewPending} + <span class="mb-2 block font-medium text-warning">{m.chat_source_review_pending()}</span> + {/if} + <!-- Plain text only: excerpts are document content and stay unrendered. --> + {#each source.chunks as chunk, index (chunk.heading_path + index)} + <span class="mt-2 block first:mt-0"> + {#if chunk.heading_path} + <span class="block font-medium text-ink-muted">{chunk.heading_path}</span> + {/if} + <span class="block">{chunk.excerpt || 'No preview available.'}</span> + </span> + {/each} + {/snippet} + <!-- A cited document with an open question is marked here, where the answer + is read: the text may be out of date and the reader has to know. --> + <span + class="inline-flex items-center gap-1 rounded-full border bg-surface-sunken px-2 py-0.5 text-xs transition-colors hover:text-ink {source.reviewPending + ? 'border-warning/60 text-warning' + : 'border-border text-ink-muted hover:border-border-strong'}" + > + {#if source.reviewPending} + <AlertTriangle size={12} data-testid="source-review-pending" /> + {:else} + <FileText size={12} /> + {/if} + {source.title} + {#if matches > 1} + <span class="text-ink-muted opacity-70" data-testid="source-match-count"> + {m.chat_source_sections({ count: matches })} + </span> + {/if} + </span> +</Tooltip> diff --git a/frontend/src/lib/chat/conversations.svelte.ts b/frontend/src/lib/chat/conversations.svelte.ts new file mode 100644 index 0000000..d004dd6 --- /dev/null +++ b/frontend/src/lib/chat/conversations.svelte.ts @@ -0,0 +1,29 @@ +// Recent conversations are shown in the sidebar (every page) and driven by +// the chat page, so the list lives in one module-scope store rather than in +// ChatState. A layout `load` would refetch on every navigation and could not +// be refreshed right after a turn finishes. + +import { api } from '$lib/api/client'; +import type { components } from '$lib/api/schema'; + +export type ConversationSummary = components['schemas']['ConversationSummary']; + +class ConversationStore { + items = $state<ConversationSummary[]>([]); + loaded = $state(false); + + async load(): Promise<void> { + const { data } = await api.GET('/api/conversations'); + this.items = data ?? []; + this.loaded = true; + } + + async remove(id: string): Promise<void> { + await api.DELETE('/api/conversations/{conversation_id}', { + params: { path: { conversation_id: id } } + }); + await this.load(); + } +} + +export const conversationStore = new ConversationStore(); diff --git a/frontend/src/lib/chat/sources.ts b/frontend/src/lib/chat/sources.ts new file mode 100644 index 0000000..82642a6 --- /dev/null +++ b/frontend/src/lib/chat/sources.ts @@ -0,0 +1,38 @@ +import type { SourceChunk } from '$lib/api/stream'; + +/** One cited document, with every chunk of it that matched. + * + * Retrieval works on chunks, so a document whose introduction and whose + * appendix both match arrives as two sources. Showing that as two identical + * badges reads as two documents. Deduplication is presentation only: the + * SSE payload and the persisted `messages.meta` stay chunk-level, because + * the individual heading paths are what the popover lists. + */ +export type GroupedSource = { + document_id: string; + title: string; + /** In arrival order, which is relevance order. */ + chunks: SourceChunk[]; + /** The document has an unanswered request to check it — a property of the + * document, so any chunk carrying it marks the whole source. */ + reviewPending: boolean; +}; + +export function groupSources(sources: SourceChunk[]): GroupedSource[] { + const byDocument = new Map<string, GroupedSource>(); + for (const source of sources) { + const existing = byDocument.get(source.document_id); + if (existing) { + existing.chunks.push(source); + existing.reviewPending ||= source.review_pending === true; + continue; + } + byDocument.set(source.document_id, { + document_id: source.document_id, + title: source.title, + chunks: [source], + reviewPending: source.review_pending === true + }); + } + return [...byDocument.values()]; +} diff --git a/frontend/src/lib/chat/state.svelte.ts b/frontend/src/lib/chat/state.svelte.ts new file mode 100644 index 0000000..423ea47 --- /dev/null +++ b/frontend/src/lib/chat/state.svelte.ts @@ -0,0 +1,219 @@ +// Chat state as a Svelte 5 runes class (no legacy stores). +// +// Query (RAG Q&A) only — capture is no longer a conversation; it writes a +// Document directly (see $lib/documents/WritingEditor.svelte). + +import { api } from '$lib/api/client'; +import { errorMessage } from '$lib/api/errors'; +import { streamMessage, type SourceChunk, type StreamEvent } from '$lib/api/stream'; +import { conversationStore } from '$lib/chat/conversations.svelte'; +import { m } from '$lib/paraglide/messages'; + +export type { ConversationSummary } from '$lib/chat/conversations.svelte'; + +export type ChatMessage = { + role: 'user' | 'assistant'; + content: string; + sources: SourceChunk[]; + streaming: boolean; + /** The streamed tokens, kept separate so the live view can fade each one + * in (see StreamingText); `content` stays the source for the final render. */ + tokens: string[]; + /** Retrieval found nothing solid — offer to capture the knowledge. */ + noAnswer: boolean; + /** The question that hit the gap (kept for a future retrieval-aware entry). */ + gapQuestion: string; + /** No model answered this turn: the `llm_*` code that caused it, and + * `sources` is a plain full-text hit list instead of citations. */ + fallback: string | null; +}; + +/** Transient query-mode progress; cleared when the turn ends. */ +export type RetrievalProgress = { phase: string; count: number | null }; + +/** How long a turn may stay silent before we say so. Long enough that a + * normal local model never trips it, short enough to beat impatience. */ +const SLOW_TURN_MS = 12_000; + +export class ChatState { + activeId = $state<string | null>(null); + retrieval = $state<RetrievalProgress | null>(null); + messages = $state<ChatMessage[]>([]); + streaming = $state(false); + error = $state<string | null>(null); + /** Nothing has come back for a while: a busy endpoint queues the request + * instead of refusing it, so silence is the only symptom the user gets. */ + slow = $state(false); + #abort: AbortController | null = null; + #slowTimer: ReturnType<typeof setTimeout> | null = null; + + /** Shared with the sidebar — same list, one fetch. */ + get conversations() { + return conversationStore.items; + } + + async loadConversations(): Promise<void> { + await conversationStore.load(); + } + + async open(id: string): Promise<void> { + this.error = null; + this.activeId = id; + this.retrieval = null; + const { data } = await api.GET('/api/conversations/{conversation_id}', { + params: { path: { conversation_id: id } } + }); + this.messages = (data?.messages ?? []) + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map((message) => ({ + role: message.role as 'user' | 'assistant', + content: message.content, + sources: message.sources ?? [], + streaming: false, + tokens: [], + noAnswer: false, + gapQuestion: '', + fallback: message.fallback ?? null + })); + } + + startNew(): void { + this.activeId = null; + this.retrieval = null; + this.messages = []; + this.error = null; + } + + async remove(id: string): Promise<void> { + if (this.activeId === id) { + this.startNew(); + } + await conversationStore.remove(id); + } + + #startSlowTimer(): void { + this.#clearSlowTimer(); + this.slow = false; + this.#slowTimer = setTimeout(() => (this.slow = true), SLOW_TURN_MS); + } + + #clearSlowTimer(): void { + if (this.#slowTimer !== null) clearTimeout(this.#slowTimer); + this.#slowTimer = null; + this.slow = false; + } + + async send(content: string): Promise<void> { + if (this.streaming) return; + // Claim the turn before awaiting anything: creating the conversation + // takes a round trip, and a second call in that window would create + // a second conversation (double-click, or two navigation callbacks). + this.streaming = true; + this.error = null; + + let conversationId = this.activeId; + if (!conversationId) { + const { data } = await api.POST('/api/conversations', { + body: { mode: 'query' } + }); + if (!data) { + this.error = m.chat_error_start_conversation(); + this.streaming = false; + return; + } + conversationId = data.id; + this.activeId = data.id; + } + + this.messages.push({ + role: 'user', + content, + sources: [], + streaming: false, + tokens: [], + noAnswer: false, + gapQuestion: '', + fallback: null + }); + await this.#stream((signal) => streamMessage(conversationId, content, signal), content); + } + + /** One streaming turn: append an assistant bubble and drain the events. */ + async #stream( + open: (signal: AbortSignal) => AsyncGenerator<StreamEvent>, + question = '' + ): Promise<void> { + this.messages.push({ + role: 'assistant', + content: '', + sources: [], + streaming: true, + tokens: [], + noAnswer: false, + gapQuestion: '', + fallback: null + }); + const assistant = this.messages[this.messages.length - 1]; + this.streaming = true; + this.#abort = new AbortController(); + this.#startSlowTimer(); + + try { + for await (const event of open(this.#abort.signal)) { + if (event.type === 'token') { + this.#clearSlowTimer(); + assistant.content += event.text; + assistant.tokens.push(event.text); + } else if (event.type === 'sources') { + assistant.sources = event.chunks; + } else if (event.type === 'state') { + this.retrieval = { phase: event.phase, count: event.count }; + if (event.phase === 'no_answer') { + assistant.noAnswer = true; + assistant.gapQuestion = question; + } + } else if (event.type === 'error') { + this.error = errorMessage(event.code); + } else if (event.type === 'fallback') { + // Not an error: the turn ends as a plain search the user reads. + assistant.fallback = event.code; + } + } + } catch (err) { + const aborted = err instanceof DOMException && err.name === 'AbortError'; + if (!aborted) { + this.error = m.chat_error_connection_lost(); + } + } finally { + this.#clearSlowTimer(); + // A turn that failed or was stopped before the first token leaves an + // empty speech bubble behind, which reads as a broken reply. A + // fallback turn is empty ON PURPOSE — its reply is the source list. + if ( + !assistant.content && + !assistant.fallback && + this.messages[this.messages.length - 1] === assistant + ) { + this.messages.pop(); + } + assistant.streaming = false; + this.streaming = false; + this.retrieval = null; + this.#abort = null; + void this.loadConversations(); + } + } + + stop(): void { + this.#abort?.abort(); + } +} + +/** One instance for the whole app, like `conversationStore`. + * + * The first message on /chat creates a conversation and the URL moves to + * /chat/[id], which unmounts one page component and mounts another. A + * per-component state would take the in-flight stream and the messages + * already on screen down with it, so the state outlives the route. + */ +export const chatState = new ChatState(); diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte new file mode 100644 index 0000000..b747378 --- /dev/null +++ b/frontend/src/lib/components/Badge.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + type Props = { + variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'accent'; + title?: string; + class?: string; + children: Snippet; + [key: string]: unknown; + }; + + let { variant = 'neutral', title, class: className = '', children, ...rest }: Props = $props(); + + const variantClasses: Record<NonNullable<Props['variant']>, string> = { + neutral: 'bg-surface-sunken text-ink-muted', + info: 'bg-surface-sunken text-secondary', + success: 'bg-success-muted text-success', + warning: 'bg-warning-muted text-warning', + danger: 'bg-danger-muted text-danger', + accent: 'bg-accent text-accent-fg' + }; +</script> + +<span + class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {variantClasses[ + variant + ]} {className}" + {title} + {...rest} +> + {@render children()} +</span> diff --git a/frontend/src/lib/components/Button.svelte b/frontend/src/lib/components/Button.svelte new file mode 100644 index 0000000..fa8a53a --- /dev/null +++ b/frontend/src/lib/components/Button.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { Button } from 'bits-ui'; + + type Props = Button.RootProps & { + variant?: 'primary' | 'secondary' | 'accent' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'icon'; + }; + + let { + variant = 'primary', + size = 'md', + class: className = '', + children, + ...rest + }: Props = $props(); + + const variantClasses: Record<NonNullable<Props['variant']>, string> = { + primary: 'bg-primary text-primary-fg hover:bg-primary-hover', + secondary: 'bg-secondary text-secondary-fg hover:bg-secondary-hover', + accent: 'bg-accent text-accent-fg hover:bg-accent-hover', + ghost: 'bg-transparent text-ink hover:bg-surface-sunken', + danger: 'bg-danger text-danger-fg hover:bg-danger-hover' + }; + const sizeClasses: Record<NonNullable<Props['size']>, string> = { + sm: 'px-3 py-1.5 text-sm rounded-full', + md: 'px-4 py-2 text-sm rounded-full', + // Square-ish circle for icon-only actions — pass an aria-label. + // `shrink-0` because a circle is the whole point: as a flex item next + // to a line of text it would otherwise squash into an oval on a + // narrow window. + icon: 'h-9 w-9 shrink-0 rounded-full' + }; +</script> + +<Button.Root + class="inline-flex cursor-pointer items-center justify-center gap-2 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 {variantClasses[ + variant + ]} {sizeClasses[size]} {className}" + {...rest} +> + {@render children?.()} +</Button.Root> diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte new file mode 100644 index 0000000..7c761a8 --- /dev/null +++ b/frontend/src/lib/components/Card.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + type Props = { + class?: string; + children: Snippet; + }; + + let { class: className = '', children }: Props = $props(); +</script> + +<div class="rounded-xl border border-border bg-surface-raised p-6 shadow-sm {className}"> + {@render children()} +</div> diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..f7c5e7f --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,52 @@ +<script lang="ts"> + import Button from '$lib/components/Button.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import { m } from '$lib/paraglide/messages'; + + // A destructive-action confirmation in the app's own modal, so delete flows + // look consistent instead of falling back to the browser's confirm(). + // Driven by the caller: `open` reflects a pending target, `onClose` clears it + // (cancel, escape, overlay or the X), `onConfirm` runs the action. + let { + open = false, + title, + message, + confirmLabel, + onConfirm, + onClose + }: { + open?: boolean; + title: string; + message?: string; + confirmLabel?: string; + onConfirm: () => void; + onClose?: () => void; + } = $props(); +</script> + +<Dialog + {open} + onOpenChange={(next) => { + if (!next) onClose?.(); + }} + {title} + description={message} + data-testid="confirm-dialog" +> + <div class="mt-2 flex justify-end gap-2"> + <Button variant="ghost" size="sm" onclick={() => onClose?.()}> + {m.common_cancel()} + </Button> + <Button + variant="danger" + size="sm" + data-testid="confirm-accept" + onclick={() => { + onConfirm(); + onClose?.(); + }} + > + {confirmLabel ?? m.common_delete()} + </Button> + </div> +</Dialog> diff --git a/frontend/src/lib/components/Dialog.svelte b/frontend/src/lib/components/Dialog.svelte new file mode 100644 index 0000000..cd1089a --- /dev/null +++ b/frontend/src/lib/components/Dialog.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import { Dialog } from 'bits-ui'; + import X from '@lucide/svelte/icons/x'; + import type { Snippet } from 'svelte'; + import { m } from '$lib/paraglide/messages'; + + type Props = { + open?: boolean; + /** For callers whose open state is derived from something else (an + * "editing this row" object, say) and cannot be two-way bound. */ + onOpenChange?: (open: boolean) => void; + title: string; + description?: string; + children: Snippet; + contentClass?: string; + 'data-testid'?: string; + }; + + let { + open = $bindable(false), + onOpenChange, + title, + description, + children, + contentClass = '', + 'data-testid': testId + }: Props = $props(); +</script> + +<Dialog.Root bind:open {onOpenChange}> + <Dialog.Portal> + <Dialog.Overlay class="fixed inset-0 z-50 bg-surface-sunken/70 backdrop-blur-sm" /> + <Dialog.Content + class="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-surface-raised p-6 shadow-lg {contentClass}" + data-testid={testId} + > + <div class="mb-4 flex items-start justify-between gap-4"> + <div> + <Dialog.Title class="text-lg font-semibold">{title}</Dialog.Title> + {#if description} + <Dialog.Description class="mt-1 text-sm text-ink-muted"> + {description} + </Dialog.Description> + {/if} + </div> + <Dialog.Close + class="cursor-pointer rounded-md p-1 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" + aria-label={m.common_close()} + > + <X size={18} /> + </Dialog.Close> + </div> + {@render children()} + </Dialog.Content> + </Dialog.Portal> +</Dialog.Root> diff --git a/frontend/src/lib/components/FormField.svelte b/frontend/src/lib/components/FormField.svelte new file mode 100644 index 0000000..76349af --- /dev/null +++ b/frontend/src/lib/components/FormField.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { Label } from 'bits-ui'; + import type { Snippet } from 'svelte'; + + type Props = { + label: string; + for: string; + error?: string | null; + /** Rendered on the label line, right-aligned — for provenance or a + * per-field action that would clutter the field itself. */ + hint?: Snippet; + children: Snippet; + }; + + let { label, for: htmlFor, error = null, hint, children }: Props = $props(); +</script> + +<div class="flex flex-col gap-1.5"> + <div class="flex items-center justify-between gap-2"> + <Label.Root for={htmlFor} class="text-sm font-medium text-ink">{label}</Label.Root> + {#if hint}{@render hint()}{/if} + </div> + {@render children()} + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} +</div> diff --git a/frontend/src/lib/components/IconAction.svelte b/frontend/src/lib/components/IconAction.svelte new file mode 100644 index 0000000..c69ed8f --- /dev/null +++ b/frontend/src/lib/components/IconAction.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { Component } from 'svelte'; + import Tooltip from '$lib/components/Tooltip.svelte'; + + // An icon-only action: a round hover target with the label as its tooltip + // AND its accessible name. The pattern appeared per table and per header + // before; one component keeps hit area, hover colour and naming identical + // wherever a row or a title offers something to do. + type Props = { + icon: Component<{ size?: number }>; + label: string; + onclick: () => void; + /** danger tints the hover state — deleting should not look like editing. */ + variant?: 'neutral' | 'danger'; + size?: 'sm' | 'md'; + testid?: string; + }; + + let { icon: Icon, label, onclick, variant = 'neutral', size = 'md', testid }: Props = $props(); + + const box = $derived(size === 'sm' ? 'h-8 w-8' : 'h-9 w-9'); + const glyph = $derived(size === 'sm' ? 15 : 17); + const hover = $derived( + variant === 'danger' + ? 'hover:bg-danger-muted hover:text-danger' + : 'hover:bg-surface-sunken hover:text-ink' + ); +</script> + +<Tooltip text={label} {label} {onclick}> + <span + class="flex items-center justify-center rounded-full text-ink-muted transition-colors {box} {hover}" + data-testid={testid} + > + <Icon size={glyph} /> + </span> +</Tooltip> diff --git a/frontend/src/lib/components/Input.svelte b/frontend/src/lib/components/Input.svelte new file mode 100644 index 0000000..d69418c --- /dev/null +++ b/frontend/src/lib/components/Input.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import type { HTMLInputAttributes } from 'svelte/elements'; + + type Props = HTMLInputAttributes; + + let { value = $bindable(''), class: className = '', ...rest }: Props = $props(); +</script> + +<!-- + Focus lifts the border rather than drawing an outer ring: the ring gets + clipped wherever an input sits inside a scroll container, and this matches + the composer cards on the landing and chat pages. +--> +<input + bind:value + class="w-full rounded-full border border-border bg-surface-raised px-4 py-2 text-sm text-ink transition-colors placeholder:text-ink-muted focus:border-border-strong focus:outline-none disabled:opacity-50 {className}" + {...rest} +/> diff --git a/frontend/src/lib/components/Markdown.svelte b/frontend/src/lib/components/Markdown.svelte new file mode 100644 index 0000000..e85a965 --- /dev/null +++ b/frontend/src/lib/components/Markdown.svelte @@ -0,0 +1,87 @@ +<script lang="ts"> + import { browser } from '$app/environment'; + import { renderMarkdown } from '$lib/markdown'; + + let { content }: { content: string } = $props(); + + // All model and document output is untrusted (a stored-XSS vector): + // render exclusively through the sanitizing renderer (marked + KaTeX + + // DOMPurify, `$lib/markdown`). During SSR there is no DOM for DOMPurify, so + // we fall back to plain text and the browser re-renders after hydration. + const html = $derived(browser ? renderMarkdown(content) : ''); +</script> + +{#if browser} + <!-- The ONLY sanctioned {@html}: everything went through DOMPurify. --> + <!-- eslint-disable-next-line svelte/no-at-html-tags --> + <div class="markdown">{@html html}</div> +{:else} + <div class="markdown whitespace-pre-wrap">{content}</div> +{/if} + +<style> + .markdown :global(p) { + margin: 0.5rem 0; + } + .markdown :global(p:first-child) { + margin-top: 0; + } + .markdown :global(p:last-child) { + margin-bottom: 0; + } + .markdown :global(ul), + .markdown :global(ol) { + margin: 0.5rem 0; + padding-left: 1.5rem; + } + .markdown :global(ul) { + list-style: disc; + } + .markdown :global(ol) { + list-style: decimal; + } + .markdown :global(h1), + .markdown :global(h2), + .markdown :global(h3), + .markdown :global(h4) { + font-weight: 600; + margin: 0.75rem 0 0.25rem; + } + .markdown :global(code) { + background: var(--pb-surface-sunken); + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; + font-size: 0.875em; + } + .markdown :global(pre) { + background: var(--pb-surface-sunken); + border-radius: 0.5rem; + padding: 0.75rem; + overflow-x: auto; + margin: 0.5rem 0; + } + .markdown :global(pre code) { + background: transparent; + padding: 0; + } + .markdown :global(a) { + color: var(--pb-secondary); + text-decoration: underline; + } + .markdown :global(blockquote) { + border-left: 3px solid var(--pb-border-strong); + padding-left: 0.75rem; + color: var(--pb-ink-muted); + margin: 0.5rem 0; + } + .markdown :global(table) { + border-collapse: collapse; + margin: 0.5rem 0; + } + .markdown :global(th), + .markdown :global(td) { + border: 1px solid var(--pb-border); + padding: 0.25rem 0.5rem; + text-align: left; + } +</style> diff --git a/frontend/src/lib/components/Menu.svelte b/frontend/src/lib/components/Menu.svelte new file mode 100644 index 0000000..2b7a783 --- /dev/null +++ b/frontend/src/lib/components/Menu.svelte @@ -0,0 +1,58 @@ +<script lang="ts"> + import { DropdownMenu } from 'bits-ui'; + import MoreHorizontal from '@lucide/svelte/icons/ellipsis'; + import type { Component } from 'svelte'; + import { m } from '$lib/paraglide/messages'; + + // The quiet half of a screen's actions: everything that has to be findable + // without competing with the one action people came for. Entries are + // labelled — an icon-only row makes the reader guess, which is exactly the + // thing this menu exists to stop. + type Item = { + label: string; + icon?: Component; + onselect: () => void; + /** Destructive entries render in the danger color, at the bottom. */ + danger?: boolean; + testid?: string; + }; + + let { + items, + label, + 'data-testid': testId + }: { items: Item[]; label?: string; 'data-testid'?: string } = $props(); +</script> + +<DropdownMenu.Root> + <DropdownMenu.Trigger + class="cursor-pointer rounded-lg border border-border p-2 text-ink-muted transition-colors hover:border-border-strong hover:text-ink focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" + aria-label={label ?? m.common_more()} + data-testid={testId} + > + <MoreHorizontal size={16} /> + </DropdownMenu.Trigger> + <DropdownMenu.Portal> + <DropdownMenu.Content + align="end" + sideOffset={6} + class="z-50 min-w-52 rounded-xl border border-border bg-surface-raised p-1 shadow-lg" + > + {#each items as item (item.label)} + <DropdownMenu.Item + onSelect={item.onselect} + data-testid={item.testid} + class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-colors data-highlighted:bg-surface-sunken {item.danger + ? 'text-danger' + : 'text-ink'}" + > + {#if item.icon} + {@const Icon = item.icon} + <Icon size={15} class="shrink-0" /> + {/if} + {item.label} + </DropdownMenu.Item> + {/each} + </DropdownMenu.Content> + </DropdownMenu.Portal> +</DropdownMenu.Root> diff --git a/frontend/src/lib/components/Popover.svelte b/frontend/src/lib/components/Popover.svelte new file mode 100644 index 0000000..a09d465 --- /dev/null +++ b/frontend/src/lib/components/Popover.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import { Popover } from 'bits-ui'; + import type { Snippet } from 'svelte'; + + type Props = { + /** Trigger content — rendered inside the Bits trigger button. */ + trigger: Snippet; + /** Popover body. */ + children: Snippet; + triggerClass?: string; + contentClass?: string; + triggerLabel?: string; + side?: 'top' | 'right' | 'bottom' | 'left'; + open?: boolean; + }; + + let { + trigger, + children, + triggerClass = '', + contentClass = '', + triggerLabel, + side = 'bottom', + open = $bindable(false) + }: Props = $props(); +</script> + +<Popover.Root bind:open> + <Popover.Trigger + class="cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none {triggerClass}" + aria-label={triggerLabel} + > + {@render trigger()} + </Popover.Trigger> + <Popover.Portal> + <Popover.Content + {side} + sideOffset={6} + class="z-50 max-w-sm rounded-lg border border-border bg-surface-raised p-3 text-sm shadow-lg {contentClass}" + > + {@render children()} + </Popover.Content> + </Popover.Portal> +</Popover.Root> diff --git a/frontend/src/lib/components/Select.svelte b/frontend/src/lib/components/Select.svelte new file mode 100644 index 0000000..5eca269 --- /dev/null +++ b/frontend/src/lib/components/Select.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import ChevronDown from '@lucide/svelte/icons/chevron-down'; + import type { HTMLSelectAttributes } from 'svelte/elements'; + import type { Snippet } from 'svelte'; + + type Props = HTMLSelectAttributes & { children: Snippet }; + + // The width is the caller's, with full width as the default that suits a + // FormField. Passing `class` REPLACES it rather than fighting it: two + // width utilities in one class attribute are resolved by stylesheet + // order, not by the order they are written, so "w-full w-44" is a coin + // flip. + let { value = $bindable(''), class: className = 'w-full', children, ...rest }: Props = $props(); +</script> + +<!-- + The select twin of Input: same pill shape, same border, same focus + behaviour. It exists because seven selects had drifted into three + different roundings, and a shared primitive is the only way that stays + fixed. + + The caret is a real icon rather than a background-image data URI. In a + data URI the SVG is its own document with no CSS context, so + `currentColor` never resolves and the arrow renders black: invisible on + the dark theme, which is the default. An overlaid element inherits + `text-ink-muted` and works in both themes. + + `pointer-events-none` on the caret so clicking it still opens the select, + and `pr-9` reserves the space it sits in. +--> +<div class="relative {className}"> + <select + bind:value + class="w-full appearance-none rounded-full border border-border bg-surface-raised py-2 pr-9 pl-4 text-sm text-ink transition-colors focus:border-border-strong focus:outline-none disabled:opacity-50" + {...rest} + > + {@render children()} + </select> + <ChevronDown + size={16} + class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-ink-muted" + /> +</div> diff --git a/frontend/src/lib/components/StreamingText.svelte b/frontend/src/lib/components/StreamingText.svelte new file mode 100644 index 0000000..b13d600 --- /dev/null +++ b/frontend/src/lib/components/StreamingText.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + // Renders streamed model output token-by-token with a fade-in, the way the + // Claude web UI does: each arriving token materialises instead of snapping + // in. Every token is its own keyed span, so Svelte mounts only the newest + // one per update and its fade plays exactly once — re-rendering the whole + // Markdown string on each token (what {@html} does) would replay every + // element's animation and flicker. + // + // This is plain text on purpose: it is the transient streaming view. The + // caller swaps to the sanitizing <Markdown> renderer once the turn settles, + // so formatting still arrives — just at the end, without mid-stream reflow. + let { tokens }: { tokens: string[] } = $props(); +</script> + +<div class="streaming-text text-sm break-words whitespace-pre-wrap"> + {#each tokens as token, i (i)}<span class="tok">{token}</span>{/each} +</div> + +<style> + @keyframes pb-token-in { + from { + opacity: 0; + filter: blur(3px); + } + to { + opacity: 1; + filter: blur(0); + } + } + .tok { + animation: pb-token-in 0.55s ease-out both; + } + /* Motion is decorative; the settled text is the same either way. */ + @media (prefers-reduced-motion: reduce) { + .tok { + animation: none; + } + } +</style> diff --git a/frontend/src/lib/components/Tabs.svelte b/frontend/src/lib/components/Tabs.svelte new file mode 100644 index 0000000..06c7b5d --- /dev/null +++ b/frontend/src/lib/components/Tabs.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import { Tabs } from 'bits-ui'; + import type { Snippet } from 'svelte'; + + type Tab = { value: string; label: string }; + + type Props = { + tabs: Tab[]; + value?: string; + /** Rendered once per tab; receives that tab's value. */ + panel: Snippet<[string]>; + class?: string; + }; + + let { + tabs, + value = $bindable(tabs[0]?.value ?? ''), + panel, + class: className = '' + }: Props = $props(); +</script> + +<Tabs.Root bind:value class="flex min-h-0 flex-col {className}"> + <Tabs.List class="flex gap-1 rounded-md bg-surface-sunken p-1"> + {#each tabs as tab (tab.value)} + <Tabs.Trigger + value={tab.value} + data-testid="tab-{tab.value}" + class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium text-ink-muted transition-colors data-[state=active]:bg-surface-raised data-[state=active]:text-ink" + > + {tab.label} + </Tabs.Trigger> + {/each} + </Tabs.List> + {#each tabs as tab (tab.value)} + <Tabs.Content value={tab.value} class="mt-2 min-h-0 flex-1"> + <!-- Only the open tab is rendered. Bits keeps inactive panels mounted + (just hidden), which for a page of independent panels means every + one of them fetches its data on arrival and hidden controls sit in + the DOM where a click can never reach them. --> + {#if tab.value === value}{@render panel(tab.value)}{/if} + </Tabs.Content> + {/each} +</Tabs.Root> diff --git a/frontend/src/lib/components/Tooltip.svelte b/frontend/src/lib/components/Tooltip.svelte new file mode 100644 index 0000000..42e2688 --- /dev/null +++ b/frontend/src/lib/components/Tooltip.svelte @@ -0,0 +1,63 @@ +<script lang="ts"> + import { Tooltip } from 'bits-ui'; + import type { Snippet } from 'svelte'; + + /** + * Text-only by default: tooltip content is never HTML, so untrusted + * strings (document excerpts, model output) are safe here. + * Pass the `content` snippet for structured — still plain-text — bodies. + */ + type Props = { + text?: string; + content?: Snippet; + children: Snippet; + triggerClass?: string; + side?: 'top' | 'right' | 'bottom' | 'left'; + delay?: number; + /** The trigger IS a button — hook clicks here, never nest one inside. */ + onclick?: () => void; + /** Accessible name. Required for icon-only triggers, whose visible + * content is an SVG and therefore nameless. */ + label?: string; + 'data-testid'?: string; + }; + + let { + text, + content, + children, + triggerClass = '', + side = 'top', + delay = 200, + onclick, + label, + 'data-testid': testId + }: Props = $props(); +</script> + +<Tooltip.Provider delayDuration={delay}> + <Tooltip.Root> + <Tooltip.Trigger + {onclick} + aria-label={label} + data-testid={testId} + class="cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none {triggerClass}" + > + {@render children()} + </Tooltip.Trigger> + <Tooltip.Portal> + <Tooltip.Content + {side} + sideOffset={6} + data-testid="tooltip-content" + class="z-50 max-w-xs rounded-md border border-border bg-surface-raised px-2.5 py-1.5 text-xs text-ink shadow-lg" + > + {#if content} + {@render content()} + {:else} + {text} + {/if} + </Tooltip.Content> + </Tooltip.Portal> + </Tooltip.Root> +</Tooltip.Provider> diff --git a/frontend/src/lib/documents/AccessPopover.svelte b/frontend/src/lib/documents/AccessPopover.svelte new file mode 100644 index 0000000..e70af20 --- /dev/null +++ b/frontend/src/lib/documents/AccessPopover.svelte @@ -0,0 +1,207 @@ +<script lang="ts"> + import Building2 from '@lucide/svelte/icons/building-2'; + import ChevronDown from '@lucide/svelte/icons/chevron-down'; + import Globe from '@lucide/svelte/icons/globe'; + import KeyRound from '@lucide/svelte/icons/key-round'; + import { SvelteSet } from 'svelte/reactivity'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import Popover from '$lib/components/Popover.svelte'; + import Select from '$lib/components/Select.svelte'; + import { visibilityLabel } from '$lib/documents/presentation'; + import { m } from '$lib/paraglide/messages'; + + // "Who can see this?" — the answer is on the chip, the controls are one + // click behind it. Visibility and the extra departments are the same + // question asked twice, so they are answered in one place; both are the + // owner's call, which is why a reviewer sees the answer and no controls. + type Department = components['schemas']['DepartmentOut']; + type DocumentDetail = components['schemas']['DocumentDetail']; + + let { + document: doc, + canManage, + onChanged + }: { + document: DocumentDetail; + canManage: boolean; + onChanged?: () => Promise<void> | void; + } = $props(); + + let open = $state(false); + let departments = $state<Department[]>([]); + const selected = new SvelteSet<string>(); + let busy = $state(false); + let error = $state<string | null>(null); + // A change that would remove the editing admin's own access is held until + // they confirm it; `pending` is what they are being asked about. + let pending = $state<{ visibility?: DocumentDetail['visibility'] } | null>(null); + + const shared = $derived(doc.shared_departments); + const shareable = $derived(departments.filter((entry) => entry.id !== doc.department_id)); + const VisibilityIcon = $derived(doc.visibility === 'restricted' ? KeyRound : Globe); + + $effect(() => { + if (!open) return; + selected.clear(); + for (const department of shared) selected.add(department.id); + error = null; + pending = null; + void api.GET('/api/departments').then(({ data }) => (departments = data ?? [])); + }); + + function toggle(id: string) { + if (selected.has(id)) selected.delete(id); + else selected.add(id); + } + + async function setVisibility(visibility: DocumentDetail['visibility'], confirmLockout = false) { + busy = true; + error = null; + const { response, error: err } = await api.PATCH('/api/documents/{document_id}', { + params: { path: { document_id: doc.id } }, + body: { visibility, confirm_lockout: confirmLockout } + }); + busy = false; + if (response.ok) { + pending = null; + await onChanged?.(); + return; + } + if ((err as { code?: string })?.code === 'self_lockout_warning' && !confirmLockout) { + pending = { visibility }; + return; + } + error = m.visibility_save_failed(); + } + + async function saveDepartments(confirmLockout = false) { + busy = true; + error = null; + const { response, error: err } = await api.PUT('/api/documents/{document_id}/departments', { + params: { path: { document_id: doc.id } }, + body: { department_ids: [...selected], confirm_lockout: confirmLockout } + }); + busy = false; + if (response.ok) { + pending = null; + await onChanged?.(); + return; + } + if ((err as { code?: string })?.code === 'self_lockout_warning' && !confirmLockout) { + pending = {}; + return; + } + error = m.sharing_save_failed(); + } +</script> + +<Popover bind:open contentClass="w-80" triggerLabel={m.access_popover_title()}> + {#snippet trigger()} + <span + class="flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-ink-muted transition-colors hover:border-border-strong hover:text-ink" + data-testid="access-chip" + > + <VisibilityIcon size={12} /> + {visibilityLabel(doc.visibility)} + {#if shared.length > 0} + <span class="text-ink-muted">{m.access_plus_departments({ count: shared.length })}</span> + {/if} + <ChevronDown size={12} class="opacity-60" /> + </span> + {/snippet} + + <div class="flex flex-col gap-3" data-testid="access-controls"> + <p class="text-sm font-medium">{m.access_popover_title()}</p> + + {#if canManage} + <label class="flex flex-col gap-1 text-xs text-ink-muted"> + {m.visibility_label()} + <Select + value={doc.visibility} + disabled={busy} + onchange={(event) => + setVisibility(event.currentTarget.value as DocumentDetail['visibility'])} + data-testid="visibility-select" + > + <option value="public">{m.document_visibility_public()}</option> + <option value="department">{m.document_visibility_department()}</option> + <option value="restricted">{m.document_visibility_restricted()}</option> + </Select> + </label> + {:else} + <p class="text-sm">{m.document_visibility_line({ visibility: doc.visibility })}</p> + {/if} + + <div class="flex flex-col gap-1"> + <p class="text-xs text-ink-muted">{m.access_extra_departments()}</p> + {#if canManage} + {#if shareable.length === 0} + <p class="text-sm text-ink-muted">{m.sharing_none_shareable()}</p> + {:else} + <ul class="flex max-h-44 flex-col overflow-y-auto"> + {#each shareable as department (department.id)} + <li> + <label + class="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-surface-sunken" + > + <input + type="checkbox" + class="accent-accent" + checked={selected.has(department.id)} + onchange={() => toggle(department.id)} + /> + {department.name} + </label> + </li> + {/each} + </ul> + <Button + size="sm" + class="self-start" + disabled={busy} + onclick={() => saveDepartments()} + data-testid="share-save" + > + {m.sharing_save()} + </Button> + {/if} + {:else if shared.length === 0} + <p class="text-sm text-ink-muted">{m.access_no_extra_departments()}</p> + {:else} + <ul class="flex flex-col gap-1 text-sm"> + {#each shared as department (department.id)} + <li class="flex items-center gap-1.5"> + <Building2 size={13} class="text-ink-muted" /> + {department.name} + </li> + {/each} + </ul> + {/if} + </div> + + {#if pending} + <div class="flex flex-col gap-2 rounded-lg bg-warning-muted px-3 py-2"> + <span class="text-sm text-warning" role="alert">{m.sharing_lockout_warning()}</span> + <div class="flex gap-2"> + <Button + variant="danger" + size="sm" + disabled={busy} + onclick={() => + pending?.visibility ? setVisibility(pending.visibility, true) : saveDepartments(true)} + > + {m.sharing_lockout_confirm()} + </Button> + <Button variant="ghost" size="sm" onclick={() => (pending = null)}> + {m.common_cancel()} + </Button> + </div> + </div> + {/if} + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + </div> +</Popover> diff --git a/frontend/src/lib/documents/CaptureSuccess.svelte b/frontend/src/lib/documents/CaptureSuccess.svelte new file mode 100644 index 0000000..9206406 --- /dev/null +++ b/frontend/src/lib/documents/CaptureSuccess.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import Eye from '@lucide/svelte/icons/eye'; + import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question'; + import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; + import Button from '$lib/components/Button.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import ReviewRequestForm from '$lib/documents/ReviewRequestForm.svelte'; + import { m } from '$lib/paraglide/messages'; + + // The moment the document becomes readable for everyone it is visible to. + // Offered right here, because this is when the doubt is freshest: if you + // are not sure about a detail, ask someone now — the document stays + // published and carries the question until they answer. + let { open = $bindable(false), documentId }: { open?: boolean; documentId: string } = $props(); + + let mode = $state<'done' | 'ask'>('done'); + let askedName = $state<string | null>(null); + + // Reset each time the reward opens (the component stays mounted). + $effect(() => { + if (open) { + mode = 'done'; + askedName = null; + } + }); + + async function view() { + open = false; + await goto(resolve(`/documents/${documentId}`)); + } +</script> + +<Dialog bind:open title={m.capture_success_title()} data-testid="capture-success"> + <div class="flex flex-col items-center gap-4 pt-2 text-center"> + <div class="check" aria-hidden="true"> + <svg viewBox="0 0 52 52"> + <circle cx="26" cy="26" r="24" /> + <path d="M15 27l7 7 15-15" /> + </svg> + </div> + + {#if mode === 'done'} + <p class="max-w-sm text-sm text-ink-muted">{m.capture_success_body()}</p> + <div class="mt-1 flex flex-wrap justify-center gap-2"> + <Button onclick={view} data-testid="success-view"> + <Eye size={16} /> + {m.capture_success_view()} + </Button> + <Button variant="secondary" onclick={() => (mode = 'ask')} data-testid="success-ask"> + <MessageCircleQuestion size={16} /> + {m.capture_success_ask()} + </Button> + </div> + {:else if askedName} + <p class="text-sm text-ink" data-testid="review-asked"> + {m.review_ask_sent({ name: askedName })} + </p> + <Button onclick={view}>{m.capture_success_view()}</Button> + {:else} + <div class="w-full text-left"> + <p class="mb-3 text-sm text-ink-muted">{m.review_ask_hint()}</p> + <ReviewRequestForm {documentId} onSent={(name) => void (askedName = name)} /> + </div> + <Button variant="ghost" size="sm" onclick={() => (mode = 'done')}> + {m.common_back()} + </Button> + {/if} + </div> +</Dialog> + +<style> + .check svg { + width: 4rem; + height: 4rem; + } + .check circle { + fill: none; + stroke: var(--pb-success); + stroke-width: 3; + stroke-dasharray: 151; + stroke-dashoffset: 151; + animation: pb-check-circle 0.5s ease-out forwards; + } + .check path { + fill: none; + stroke: var(--pb-success); + stroke-width: 4; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 40; + stroke-dashoffset: 40; + animation: pb-check-mark 0.35s 0.4s ease-out forwards; + } + @keyframes pb-check-circle { + to { + stroke-dashoffset: 0; + } + } + @keyframes pb-check-mark { + to { + stroke-dashoffset: 0; + } + } + @media (prefers-reduced-motion: reduce) { + .check circle, + .check path { + animation: none; + stroke-dashoffset: 0; + } + } +</style> diff --git a/frontend/src/lib/documents/DocumentCard.svelte b/frontend/src/lib/documents/DocumentCard.svelte new file mode 100644 index 0000000..13cf4a8 --- /dev/null +++ b/frontend/src/lib/documents/DocumentCard.svelte @@ -0,0 +1,65 @@ +<script lang="ts"> + import BookOpen from '@lucide/svelte/icons/book-open'; + import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question'; + import PencilLine from '@lucide/svelte/icons/pencil-line'; + import { resolve } from '$app/paths'; + import type { DocumentRow } from '$lib/documents/list.svelte'; + import { formatDate } from '$lib/documents/presentation'; + import { m } from '$lib/paraglide/messages'; + + // A row in a list of a hundred: the title carries it, everything else is + // context in one grey line. Badges are for EXCEPTIONS only — "published", + // "public" and "yours" are the normal case, and repeating them on every + // card turns the two rows that actually need attention into more of the + // same. What is marked here: an unanswered question, a draft nobody else + // can see, a help page shipped with the product. + let { document, departmentName }: { document: DocumentRow; departmentName: string | undefined } = + $props(); + + const flagged = $derived(document.open_reviews > 0); +</script> + +<a + href={resolve(`/documents/${document.id}`)} + class="flex h-full flex-col gap-1.5 rounded-xl border bg-surface-raised px-4 py-3 transition-colors hover:border-border-strong {flagged + ? 'border-warning/40' + : 'border-border'}" +> + <p class="truncate font-medium">{document.title}</p> + + <p class="truncate text-xs text-ink-muted"> + <!-- A search hit shows the section that matched; a browsed row its + department, which is what the filters are about. --> + {document.heading_path || departmentName || m.documents_no_department()} + <span class="opacity-70"> + · {m.documents_updated_at({ date: formatDate(document.updated_at) })} + </span> + </p> + + {#if flagged || document.status !== 'published' || document.is_builtin} + <div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> + {#if document.open_reviews > 0} + <!-- Somebody asked whether this is still right, and nobody has + answered — true of drafts and published documents alike. --> + <span class="flex items-center gap-1 text-warning" data-testid="open-review-badge"> + <MessageCircleQuestion size={12} /> + {m.documents_badge_open_reviews({ count: document.open_reviews })} + </span> + {/if} + {#if document.status === 'draft'} + <span class="flex items-center gap-1 text-ink-muted"> + <PencilLine size={12} /> + {m.document_draft_chip()} + </span> + {:else if document.status === 'archived'} + <span class="text-ink-muted">{m.documents_status_archived()}</span> + {/if} + {#if document.is_builtin} + <span class="flex items-center gap-1 text-secondary"> + <BookOpen size={12} /> + {m.documents_badge_builtin()} + </span> + {/if} + </div> + {/if} +</a> diff --git a/frontend/src/lib/documents/DocumentFilters.svelte b/frontend/src/lib/documents/DocumentFilters.svelte new file mode 100644 index 0000000..d0bf697 --- /dev/null +++ b/frontend/src/lib/documents/DocumentFilters.svelte @@ -0,0 +1,213 @@ +<script lang="ts"> + import Download from '@lucide/svelte/icons/download'; + import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; + import X from '@lucide/svelte/icons/x'; + import type { components } from '$lib/api/schema'; + import Input from '$lib/components/Input.svelte'; + import Select from '$lib/components/Select.svelte'; + import type { AccessFilter, DocumentList } from '$lib/documents/list.svelte'; + import { documentView } from '$lib/documents/view.svelte'; + import { m } from '$lib/paraglide/messages'; + + // The search field is the tool people reach for; the six ways to narrow a + // list are the tool they reach for once a month. So the field owns the row + // and the rest lives behind one "Filter" toggle — except the filters that + // are currently ON, which stay visible as removable chips, because a list + // that is quietly filtered is a list that lies. + type Department = components['schemas']['DepartmentOut']; + + let { list, departments }: { list: DocumentList; departments: Department[] } = $props(); + + let showFilters = $state(false); + + // $derived: a const list keeps the language it was built in. + const SORTS = $derived([ + { value: 'updated' as const, label: m.documents_sort_updated() }, + { value: 'created' as const, label: m.documents_sort_created() } + ]); + + const ACCESS_FILTERS = $derived([ + { value: 'all' as const, label: m.documents_access_all() }, + { value: 'author' as const, label: m.documents_access_mine() }, + { value: 'department' as const, label: m.documents_access_department() }, + { value: 'public' as const, label: m.documents_access_public() }, + { value: 'granted' as const, label: m.documents_access_granted() } + ]); + + const statusLabels = $derived<Record<string, string>>({ + published: m.documents_status_published(), + draft: m.documents_status_draft(), + archived: m.documents_status_archived() + }); + + // What is narrowing the list right now, in the words of the control that + // set it — each one removable where it is shown. + const active = $derived.by(() => { + const chips: { label: string; clear: () => void }[] = []; + if (list.status) { + chips.push({ + label: statusLabels[list.status] ?? list.status, + clear: () => { + list.status = ''; + list.reload(); + } + }); + } + if (list.department) { + const name = departments.find((entry) => entry.id === list.department)?.name; + if (name) { + chips.push({ + label: name, + clear: () => { + list.department = ''; + list.reload(); + } + }); + } + } + if (list.access !== 'all') { + const label = ACCESS_FILTERS.find((entry) => entry.value === list.access)?.label; + if (label) chips.push({ label, clear: () => (list.access = 'all') }); + } + if (list.assignedToMe) { + chips.push({ + label: m.documents_filter_my_reviews(), + clear: () => { + list.assignedToMe = false; + list.reload(); + } + }); + } + return chips; + }); + + const filterHint = $derived(list.searching ? m.documents_filter_disabled_hint() : undefined); +</script> + +<div class="flex flex-wrap items-center gap-2"> + <div class="min-w-56 flex-1"> + <Input + placeholder={m.documents_search_placeholder()} + bind:value={list.search} + oninput={() => list.onSearchInput()} + data-testid="document-search" + /> + </div> + <button + type="button" + class="flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm transition-colors {showFilters || + active.length > 0 + ? 'border-border-strong text-ink' + : 'border-border text-ink-muted hover:text-ink'}" + onclick={() => (showFilters = !showFilters)} + data-testid="filters-toggle" + > + <SlidersHorizontal size={15} /> + {m.documents_filters()} + </button> + <!-- A ZIP of everything the user can read (Markdown + frontmatter), built + and streamed by the backend; a plain download, not a route. --> + <!-- eslint-disable svelte/no-navigation-without-resolve --> + <a + href="/api/documents/export" + download + title={m.documents_export_hint()} + class="flex items-center gap-1.5 rounded-full border border-border px-3.5 py-2 text-sm text-ink-muted transition-colors hover:border-border-strong hover:text-ink" + data-testid="export-button" + > + <Download size={15} /> + {m.documents_export()} + </a> + <!-- eslint-enable svelte/no-navigation-without-resolve --> +</div> + +{#if active.length > 0 && !showFilters} + <div class="flex flex-wrap items-center gap-1.5" data-testid="active-filters"> + {#each active as chip (chip.label)} + <button + type="button" + class="flex cursor-pointer items-center gap-1 rounded-full border border-border-strong bg-surface-sunken px-3 py-1 text-xs text-ink" + onclick={chip.clear} + > + {chip.label} + <X size={12} class="opacity-60" /> + </button> + {/each} + </div> +{/if} + +{#if showFilters} + <div class="flex flex-col gap-2 rounded-xl border border-border bg-surface-raised p-3"> + <div class="flex flex-wrap items-end gap-2"> + <Select + class="w-44" + bind:value={list.status} + onchange={() => list.reload()} + disabled={list.searching} + title={filterHint} + data-testid="status-filter" + > + <option value="">{m.documents_filter_all_statuses()}</option> + <option value="published">{m.documents_status_published()}</option> + <option value="draft">{m.documents_status_draft()}</option> + <option value="archived">{m.documents_status_archived()}</option> + </Select> + <Select + class="w-44" + bind:value={list.department} + onchange={() => list.reload()} + disabled={list.searching} + title={filterHint} + > + <option value="">{m.documents_filter_all_departments()}</option> + {#each departments as entry (entry.id)} + <option value={entry.id}>{entry.name}</option> + {/each} + </Select> + <Select + class="w-44" + value={documentView.sort} + onchange={(event) => { + documentView.set({ sort: event.currentTarget.value as 'updated' | 'created' }); + list.reload(); + }} + disabled={list.searching} + title={list.searching ? m.documents_sort_disabled_hint() : undefined} + data-testid="sort-select" + > + {#each SORTS as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </Select> + <!-- Documents somebody asked this user to check. --> + <button + class="cursor-pointer rounded-full border px-3 py-2 text-sm transition-colors {list.assignedToMe + ? 'border-accent bg-accent/10 text-ink' + : 'border-border text-ink-muted hover:text-ink'}" + onclick={() => { + list.assignedToMe = !list.assignedToMe; + list.reload(); + }} + disabled={list.searching} + data-testid="review-filter" + > + {m.documents_filter_my_reviews()} + </button> + </div> + + <div class="flex flex-wrap items-center gap-1.5" data-testid="access-filter"> + <span class="mr-1 text-xs text-ink-muted">{m.documents_access_filter_label()}</span> + {#each ACCESS_FILTERS as option (option.value)} + <button + class="cursor-pointer rounded-full border px-3 py-1 text-xs transition-colors {list.access === + option.value + ? 'border-border-strong bg-surface-sunken text-ink' + : 'border-border text-ink-muted hover:text-ink'}" + onclick={() => (list.access = option.value as AccessFilter)} + > + {option.label} + </button> + {/each} + </div> + </div> +{/if} diff --git a/frontend/src/lib/documents/DocumentHistory.svelte b/frontend/src/lib/documents/DocumentHistory.svelte new file mode 100644 index 0000000..4940883 --- /dev/null +++ b/frontend/src/lib/documents/DocumentHistory.svelte @@ -0,0 +1,133 @@ +<script lang="ts"> + import History from '@lucide/svelte/icons/history'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Card from '$lib/components/Card.svelte'; + import VersionDiffDialog from '$lib/documents/VersionDiffDialog.svelte'; + import { i18n } from '$lib/i18n/locale.svelte'; + import { m } from '$lib/paraglide/messages'; + + type DocumentEventOut = components['schemas']['DocumentEventOut']; + type DocumentVersion = components['schemas']['DocumentVersion']; + + let { + documentId, + canEdit, + onChanged + }: { + documentId: string; + canEdit: boolean; + onChanged?: () => void; + } = $props(); + + let events = $state<DocumentEventOut[]>([]); + // The last few entries answer "what happened lately"; the whole trail is a + // click away rather than a wall of rows under every document. + let expanded = $state(false); + const SHOWN = 4; + const visible = $derived(expanded ? events : events.slice(0, SHOWN)); + // The version whose own change is shown in the modal: its snapshot against + // the one it replaced, so the entry you click is the change you see. + let selected = $state<DocumentVersion | null>(null); + + const dateFormat = $derived( + new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium', timeStyle: 'short' }) + ); + function formatDate(iso: string): string { + return dateFormat.format(new Date(iso)); + } + + const actionLabels = $derived<Record<string, string>>({ + created: m.history_action_created(), + edited: m.history_action_edited(), + published: m.history_action_published(), + archived: m.history_action_archived(), + visibility_changed: m.history_action_visibility_changed(), + review_requested: m.history_action_review_requested(), + review_resolved: m.history_action_review_resolved() + }); + + async function load() { + const { data } = await api.GET('/api/documents/{document_id}/history', { + params: { path: { document_id: documentId } } + }); + events = data ?? []; + } + + $effect(() => { + void documentId; + void load(); + }); + + async function view(event: DocumentEventOut) { + const { data } = await api.GET('/api/documents/{document_id}/versions/{event_id}', { + params: { path: { document_id: documentId, event_id: event.id } } + }); + if (data) selected = data; + } + + async function restore() { + if (!selected?.content_md) return; + await api.PATCH('/api/documents/{document_id}', { + params: { path: { document_id: documentId } }, + body: { content_md: selected.content_md } + }); + selected = null; + await load(); + onChanged?.(); + } +</script> + +<Card> + <h2 class="flex items-center gap-1.5 text-sm font-semibold text-ink-muted"> + <History size={14} /> + {m.history_title()} + </h2> + {#if events.length === 0} + <p class="mt-2 text-sm text-ink-muted">{m.history_empty()}</p> + {:else} + <ol class="mt-3 space-y-2" data-testid="document-history"> + {#each visible as event (event.id)} + <li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm"> + <span class="font-medium">{actionLabels[event.action] ?? event.action}</span> + <span class="text-ink-muted"> + {m.history_by({ actor: event.actor_name ?? m.history_actor_unknown() })} + </span> + <span class="text-xs text-ink-muted">{formatDate(event.created_at)}</span> + {#if event.has_snapshot} + <button + type="button" + class="cursor-pointer text-xs text-secondary underline" + onclick={() => view(event)} + > + {m.history_view_changes()} + </button> + {/if} + </li> + {/each} + </ol> + {#if events.length > SHOWN} + <button + type="button" + class="mt-2 cursor-pointer text-xs text-secondary underline" + onclick={() => (expanded = !expanded)} + data-testid="history-toggle" + > + {expanded ? m.history_show_less() : m.history_show_all({ count: events.length })} + </button> + {/if} + {/if} +</Card> + +{#if selected} + <VersionDiffDialog + open={true} + onClose={() => (selected = null)} + title={m.history_diff_title()} + description={formatDate(selected.created_at)} + original={selected.previous_content_md ?? ''} + modified={selected.content_md ?? ''} + restoreLabel={canEdit ? m.history_restore() : undefined} + onRestore={canEdit ? restore : undefined} + /> +{/if} diff --git a/frontend/src/lib/documents/OpenWork.svelte b/frontend/src/lib/documents/OpenWork.svelte new file mode 100644 index 0000000..ff0158b --- /dev/null +++ b/frontend/src/lib/documents/OpenWork.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import ClipboardCheck from '@lucide/svelte/icons/clipboard-check'; + import PencilLine from '@lucide/svelte/icons/pencil-line'; + import Send from '@lucide/svelte/icons/send'; + import { resolve } from '$app/paths'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import { formatDate } from '$lib/documents/presentation'; + import { m } from '$lib/paraglide/messages'; + + // What is waiting for this person, on the page they land on: drafts they + // started and never published, and documents a colleague asked them to + // check. Both are invisible everywhere else — a draft is private by + // definition, and a question addressed to you is easy to miss in a list — + // so they are the one thing the landing page volunteers. + type DocumentSummary = components['schemas']['DocumentSummary']; + + const SHOWN = 3; + + let drafts = $state<DocumentSummary[]>([]); + let reviewCount = $state(0); + let publishing = $state<string | null>(null); + + async function load() { + // Drafts are author-only, bar one exception: a draft someone asked you + // to check is readable too. "Your drafts" means the ones you wrote. + const [mine, queue] = await Promise.all([ + api.GET('/api/documents', { + params: { query: { status: 'draft', sort: 'updated', per_page: 20 } } + }), + api.GET('/api/documents', { + params: { query: { assigned_to_me: true, per_page: 1 } } + }) + ]); + drafts = (mine.data?.items ?? []).filter((item) => item.access_reason === 'author'); + reviewCount = queue.data?.total ?? 0; + } + + $effect(() => { + void load(); + }); + + async function publish(id: string) { + publishing = id; + await api.POST('/api/documents/{document_id}/publish', { + params: { path: { document_id: id } } + }); + publishing = null; + await load(); + } +</script> + +{#if reviewCount > 0} + <!-- The path IS resolved; the query string is data, not part of the route. --> + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> + <a + href="{resolve('/documents')}?review=1" + class="flex items-center justify-between gap-3 rounded-xl border border-accent bg-accent/10 px-4 py-3 text-sm transition-colors hover:border-accent-hover" + data-testid="review-queue-banner" + > + <span class="flex items-center gap-2 font-medium"> + <ClipboardCheck size={16} class="text-accent" /> + {m.landing_review_pending({ count: reviewCount })} + </span> + <span class="whitespace-nowrap text-accent">{m.landing_review_open()} →</span> + </a> +{/if} + +{#if drafts.length > 0} + <div class="rounded-xl border border-border bg-surface-raised p-4" data-testid="drafts-card"> + <div class="flex flex-wrap items-baseline justify-between gap-2"> + <p class="flex items-center gap-2 text-sm font-medium"> + <PencilLine size={15} class="text-ink-muted" /> + {m.landing_drafts_title({ count: drafts.length })} + </p> + <p class="text-xs text-ink-muted">{m.landing_drafts_hint()}</p> + </div> + <ul class="mt-2 flex flex-col divide-y divide-border"> + {#each drafts.slice(0, SHOWN) as draft (draft.id)} + <li class="flex flex-wrap items-center gap-2 py-2"> + <a + href={resolve(`/documents/${draft.id}/edit`)} + class="min-w-0 flex-1 truncate text-sm hover:underline" + > + {draft.title} + <span class="ml-1 text-xs text-ink-muted"> + {m.documents_updated_at({ date: formatDate(draft.updated_at) })} + </span> + </a> + <button + class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs text-ink-muted transition-colors hover:border-accent hover:text-ink disabled:opacity-50" + onclick={() => publish(draft.id)} + disabled={publishing === draft.id} + data-testid="draft-publish" + > + <Send size={13} /> + {m.landing_drafts_publish()} + </button> + </li> + {/each} + </ul> + {#if drafts.length > SHOWN} + <!-- The path IS resolved; the query string is data, not part of the route. --> + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> + <a + href="{resolve('/documents')}?status=draft" + class="mt-1 inline-block text-xs text-secondary underline" + > + {m.landing_drafts_all({ count: drafts.length })} + </a> + {/if} + </div> +{/if} diff --git a/frontend/src/lib/documents/ReviewPanel.svelte b/frontend/src/lib/documents/ReviewPanel.svelte new file mode 100644 index 0000000..2f4ed1d --- /dev/null +++ b/frontend/src/lib/documents/ReviewPanel.svelte @@ -0,0 +1,129 @@ +<script lang="ts"> + import Check from '@lucide/svelte/icons/check'; + import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question'; + import Pencil from '@lucide/svelte/icons/pencil'; + import UserCheck from '@lucide/svelte/icons/user-check'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import { formatDate } from '$lib/documents/presentation'; + import { m } from '$lib/paraglide/messages'; + + // What is still open on this document, and what the reader can do about it. + // + // An open question is the one thing a reader must see before trusting the + // text, so it sits above the content — not in the history, not behind a + // tab. The person who was asked gets the answer buttons; the author can + // close a question that has become moot. + type DocumentDetail = components['schemas']['DocumentDetail']; + + let { + document: doc, + onEdit, + onChanged + }: { + document: DocumentDetail; + onEdit: () => void; + onChanged: () => Promise<void> | void; + } = $props(); + + let busy = $state(false); + let error = $state<string | null>(null); + + const open = $derived(doc.reviews.filter((review) => review.resolved_at === null)); + const answered = $derived(doc.reviews.filter((review) => review.resolved_at !== null)); + + async function resolve(reviewId: string) { + busy = true; + error = null; + const { data } = await api.POST('/api/documents/{document_id}/reviews/{review_id}/resolve', { + params: { path: { document_id: doc.id, review_id: reviewId } } + }); + busy = false; + if (!data) { + error = m.document_review_failed(); + return; + } + await onChanged(); + } +</script> + +{#if open.length > 0} + <div + class="flex flex-col gap-3 rounded-xl border border-warning/40 bg-warning-muted p-4" + data-testid="open-reviews" + > + {#each open as review (review.id)} + <div class="flex flex-col gap-2"> + <p class="flex items-start gap-2 text-sm"> + <MessageCircleQuestion size={16} class="mt-0.5 shrink-0 text-warning" /> + <span> + {#if review.question} + <span class="text-ink-muted"> + {m.document_review_asked_by({ name: review.requester_name ?? '' })} + </span> + <!-- The question is user-written text: plain, never rendered. --> + <span class="font-medium">{review.question}</span> + {:else} + <span class="font-medium"> + {m.document_review_asked_plain({ name: review.requester_name ?? '' })} + </span> + {/if} + <span class="mt-0.5 block text-xs text-ink-muted"> + {m.document_review_waiting_on({ + name: review.reviewer_name ?? '', + date: formatDate(review.created_at) + })} + </span> + </span> + </p> + <div class="flex flex-wrap gap-2 pl-6"> + {#if review.is_mine} + <Button + size="sm" + disabled={busy} + onclick={() => resolve(review.id)} + data-testid="review-confirm" + > + <Check size={15} /> + {m.document_review_confirm()} + </Button> + {#if doc.can_edit} + <Button size="sm" variant="secondary" onclick={onEdit}> + <Pencil size={15} /> + {m.document_review_fix()} + </Button> + {/if} + {:else if doc.can_edit} + <Button + size="sm" + variant="ghost" + disabled={busy} + onclick={() => resolve(review.id)} + data-testid="review-close" + > + {m.document_review_close()} + </Button> + {/if} + </div> + </div> + {/each} + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + </div> +{/if} + +{#if answered.length > 0} + <ul class="flex flex-col gap-1 text-xs text-ink-muted" data-testid="answered-reviews"> + {#each answered as review (review.id)} + <li class="flex items-center gap-1.5"> + <UserCheck size={13} class="shrink-0 text-success" /> + {m.document_review_answered({ + name: review.resolved_by_name ?? '', + date: formatDate(review.resolved_at ?? review.created_at) + })} + </li> + {/each} + </ul> +{/if} diff --git a/frontend/src/lib/documents/ReviewRequestForm.svelte b/frontend/src/lib/documents/ReviewRequestForm.svelte new file mode 100644 index 0000000..7723d42 --- /dev/null +++ b/frontend/src/lib/documents/ReviewRequestForm.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import Select from '$lib/components/Select.svelte'; + import { m } from '$lib/paraglide/messages'; + + // Asking a colleague to check something: who, and what exactly to look at. + // The question is the point — "please review" says nothing, "do the 14 + // holiday days still hold?" is answerable — so it gets the larger field, + // but it stays optional. + type ReviewerCandidate = components['schemas']['ReviewerCandidate']; + + let { + documentId, + onSent + }: { documentId: string; onSent: (name: string) => Promise<void> | void } = $props(); + + let candidates = $state<ReviewerCandidate[]>([]); + let loaded = $state(false); + let selected = $state(''); + let question = $state(''); + let busy = $state(false); + let error = $state<string | null>(null); + + $effect(() => { + void (async () => { + const { data } = await api.GET('/api/documents/{document_id}/reviewers', { + params: { path: { document_id: documentId } } + }); + candidates = data ?? []; + selected = candidates[0]?.id ?? ''; + loaded = true; + })(); + }); + + async function send() { + if (!selected) return; + busy = true; + error = null; + const { data } = await api.POST('/api/documents/{document_id}/reviews', { + params: { path: { document_id: documentId } }, + body: { reviewer_id: selected, question: question.trim() || null } + }); + busy = false; + if (!data) { + error = m.review_ask_failed(); + return; + } + await onSent(candidates.find((candidate) => candidate.id === selected)?.name ?? ''); + } +</script> + +{#if loaded && candidates.length === 0} + <p class="text-sm text-ink-muted" data-testid="review-ask-empty">{m.review_ask_none()}</p> +{:else} + <div class="flex flex-col gap-3"> + <label class="flex flex-col gap-1 text-sm"> + <span class="text-ink-muted">{m.review_ask_reviewer()}</span> + <Select bind:value={selected} data-testid="reviewer-select"> + {#each candidates as candidate (candidate.id)} + <option value={candidate.id}>{candidate.name}</option> + {/each} + </Select> + </label> + <label class="flex flex-col gap-1 text-sm"> + <span class="text-ink-muted">{m.review_ask_question()}</span> + <textarea + bind:value={question} + rows="3" + maxlength="2000" + placeholder={m.review_ask_question_placeholder()} + class="w-full resize-y rounded-lg border border-border bg-surface p-2.5 text-sm text-ink transition-colors placeholder:text-ink-muted focus:border-border-strong focus:outline-none" + data-testid="review-question"></textarea> + </label> + {#if error} + <p role="alert" class="text-sm text-danger">{error}</p> + {/if} + <div class="flex justify-end"> + <Button onclick={send} disabled={busy || !selected} data-testid="review-ask-send"> + {m.review_ask_send()} + </Button> + </div> + </div> +{/if} diff --git a/frontend/src/lib/documents/SaveDialog.svelte b/frontend/src/lib/documents/SaveDialog.svelte new file mode 100644 index 0000000..25a91a7 --- /dev/null +++ b/frontend/src/lib/documents/SaveDialog.svelte @@ -0,0 +1,154 @@ +<script lang="ts"> + import Sparkles from '@lucide/svelte/icons/sparkles'; + import { lineNumbers, EditorView } from '@codemirror/view'; + import { markdown } from '@codemirror/lang-markdown'; + import { unifiedMergeView } from '@codemirror/merge'; + import { api } from '$lib/api/client'; + import Button from '$lib/components/Button.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import Input from '$lib/components/Input.svelte'; + import Tooltip from '$lib/components/Tooltip.svelte'; + import { editorTheme } from '$lib/documents/editorTheme'; + import { m } from '$lib/paraglide/messages'; + + // Read before you save: saving shows what changed since the last save and + // asks again. There is no autosave, because this look at the diff is the + // point — you see what you are about to put your name on, and can still + // back out. The title sits right next to it: this is the moment you notice + // that the document is still called "Onboarding: Neue Kollegin". A draft + // can go straight from here to published. + type Props = { + open: boolean; + documentId: string; + /** The text being saved, and the last saved text it is diffed against. */ + content: string; + baseline: string; + title: string; + /** Whether this save may also publish: a draft, and the caller's to + * publish. */ + canPublish: boolean; + busy: boolean; + error: string | null; + onSave: () => void; + onPublish: () => void; + }; + + let { + open = $bindable(), + documentId, + content, + baseline, + title = $bindable(), + canPublish, + busy, + error, + onSave, + onPublish + }: Props = $props(); + + let mergeHost = $state<HTMLDivElement>(); + let suggesting = $state(false); + let suggestError = $state<string | null>(null); + + const unchanged = $derived(content === baseline); + + // A read-only unified merge view (VSCode-style), mounted only while open. + $effect(() => { + if (!open || !mergeHost) return; + const view = new EditorView({ + parent: mergeHost, + doc: content, + extensions: [ + lineNumbers(), + markdown(), + EditorView.lineWrapping, + EditorView.editable.of(false), + editorTheme, + unifiedMergeView({ original: baseline, mergeControls: false }) + ] + }); + return () => view.destroy(); + }); + + // Asked for, not volunteered: a title suggestion costs a model call, and + // most saves are on a document that is already named. The result lands in + // the field, where it can be edited or typed over. + async function suggestTitle() { + suggesting = true; + suggestError = null; + const { data } = await api.POST('/api/documents/{document_id}/suggest-title', { + params: { path: { document_id: documentId } } + }); + suggesting = false; + if (!data) { + suggestError = m.editor_title_suggest_failed(); + return; + } + title = data.title; + } +</script> + +<Dialog + bind:open + title={m.editor_save_title()} + description={m.editor_save_hint()} + contentClass="w-[min(52rem,calc(100vw-2rem))]" + data-testid="editor-save-dialog" +> + <div class="mb-3 flex flex-col gap-1"> + <span class="text-sm text-ink-muted">{m.editor_title_label()}</span> + <div class="flex items-center gap-2"> + <Input bind:value={title} class="flex-1 font-medium" data-testid="save-title" /> + <Tooltip text={m.editor_title_suggest()}> + <button + type="button" + class="cursor-pointer rounded-full border border-border p-2 text-accent transition-colors hover:border-accent disabled:opacity-50" + onclick={suggestTitle} + disabled={suggesting} + aria-label={m.editor_title_suggest()} + data-testid="title-suggest" + > + <Sparkles size={16} class={suggesting ? 'animate-pulse' : ''} /> + </button> + </Tooltip> + </div> + {#if suggestError} + <p role="alert" class="text-sm text-danger">{suggestError}</p> + {/if} + </div> + + {#if unchanged} + <p class="text-sm text-ink-muted" data-testid="editor-no-changes">{m.editor_no_changes()}</p> + {:else} + <div + bind:this={mergeHost} + class="max-h-[55vh] overflow-auto rounded-lg border border-border bg-surface" + data-testid="editor-diff" + ></div> + {/if} + + {#if error} + <p role="alert" class="mt-2 text-sm text-danger">{error}</p> + {/if} + + <div class="mt-4 flex flex-wrap justify-end gap-2"> + <Button variant="ghost" disabled={busy} onclick={() => (open = false)}> + {m.common_cancel()} + </Button> + <Button + variant={canPublish ? 'secondary' : 'primary'} + disabled={busy} + onclick={onSave} + data-testid="editor-save" + > + {m.editor_save()} + </Button> + {#if canPublish} + <!-- The draft's way out: saved and readable in one step, the author's + own decision — nobody has to approve it. --> + <Button disabled={busy} onclick={onPublish} data-testid="editor-publish"> + {m.editor_save_and_publish()} + </Button> + {/if} + </div> +</Dialog> diff --git a/frontend/src/lib/documents/VersionDiffDialog.svelte b/frontend/src/lib/documents/VersionDiffDialog.svelte new file mode 100644 index 0000000..f4fe1a6 --- /dev/null +++ b/frontend/src/lib/documents/VersionDiffDialog.svelte @@ -0,0 +1,72 @@ +<script lang="ts"> + import { EditorView, lineNumbers } from '@codemirror/view'; + import { markdown } from '@codemirror/lang-markdown'; + import { unifiedMergeView } from '@codemirror/merge'; + import Button from '$lib/components/Button.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import { editorTheme } from '$lib/documents/editorTheme'; + + let { + open = $bindable(false), + onClose, + title, + description, + original, + modified, + restoreLabel, + onRestore + }: { + open?: boolean; + onClose?: () => void; + title: string; + description?: string; + // Read-only unified diff: `original` (the older text) on the left, + // `modified` (usually the current document) as the working copy. + original: string; + modified: string; + restoreLabel?: string; + onRestore?: () => void; + } = $props(); + + let host = $state<HTMLDivElement>(); + + // Mounted only while the modal is open (the host binds when the portal + // renders), like the editor's own pre-save diff. + $effect(() => { + if (!open || !host) return; + const view = new EditorView({ + parent: host, + doc: modified, + extensions: [ + lineNumbers(), + markdown(), + EditorView.lineWrapping, + EditorView.editable.of(false), + editorTheme, + unifiedMergeView({ original, mergeControls: false }) + ] + }); + return () => view.destroy(); + }); +</script> + +<Dialog + bind:open + onOpenChange={(next) => { + if (!next) onClose?.(); + }} + {title} + {description} + contentClass="w-[min(52rem,calc(100vw-2rem))]" + data-testid="version-diff" +> + <div + bind:this={host} + class="max-h-[60vh] overflow-auto rounded-md border border-border p-2" + ></div> + {#if restoreLabel && onRestore} + <div class="mt-4 flex justify-end"> + <Button variant="ghost" size="sm" onclick={onRestore}>{restoreLabel}</Button> + </div> + {/if} +</Dialog> diff --git a/frontend/src/lib/documents/WritingEditor.svelte b/frontend/src/lib/documents/WritingEditor.svelte new file mode 100644 index 0000000..71f7fa6 --- /dev/null +++ b/frontend/src/lib/documents/WritingEditor.svelte @@ -0,0 +1,464 @@ +<script lang="ts"> + import { EditorView, keymap, lineNumbers, drawSelection } from '@codemirror/view'; + import { EditorState } from '@codemirror/state'; + import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; + import { markdown } from '@codemirror/lang-markdown'; + import AlertTriangle from '@lucide/svelte/icons/triangle-alert'; + import ArrowLeft from '@lucide/svelte/icons/arrow-left'; + import { beforeNavigate } from '$app/navigation'; + import { page } from '$app/state'; + import { resolve } from '$app/paths'; + import { api } from '$lib/api/client'; + import { errorMessage } from '$lib/api/errors'; + import type { components } from '$lib/api/schema'; + import { streamRefine } from '$lib/api/refine'; + import Button from '$lib/components/Button.svelte'; + import Input from '$lib/components/Input.svelte'; + import CaptureSuccess from '$lib/documents/CaptureSuccess.svelte'; + import SaveDialog from '$lib/documents/SaveDialog.svelte'; + import { InlineSuggestion } from '$lib/documents/editor/inlineSuggestion'; + import { editorTheme } from '$lib/documents/editorTheme'; + import { activeSection } from '$lib/documents/sections'; + import { i18n } from '$lib/i18n/locale.svelte'; + import { m } from '$lib/paraglide/messages'; + import { untrack } from 'svelte'; + + type DocumentDetail = components['schemas']['DocumentDetail']; + + let { document: doc }: { document: DocumentDetail } = $props(); + + // The route mounts this keyed on `doc.id`, so the document is fixed for the + // component's life: read it once (untrack) and keep editable copies. + const documentId = untrack(() => doc.id); + const isDraft = untrack(() => doc.status === 'draft'); + const initialDoc = untrack(() => doc.content_md); + // Publishing is the owner's call. A colleague asked to check a draft edits + // and saves here like anyone else, but does not decide who gets to read it. + const canPublish = untrack( + () => doc.access_reason === 'author' || page.data.user?.role === 'admin' + ); + + let content = $state(initialDoc); + let baseline = $state(initialDoc); // last saved — the diff is against this + let title = $state(untrack(() => doc.title)); + let busy = $state(false); + // Two errors, because they belong to two surfaces: a refinement that could + // not run is about the text you are writing (and is shown once, with the + // pause), a failed save is about the dialog you are standing in. Sharing + // one made the save modal report that the model was unreachable. + let error = $state<string | null>(null); + let saveError = $state<string | null>(null); + let saving = $state(false); // the save/diff modal is open + let published = $state(false); // the success/reward modal is open + + // After an accept or dismiss the writer must add some new text before the + // next suggestion fires, so it does not immediately re-propose what it just + // wrote. Starts large so the first suggestion is allowed. + let charsSinceGate = Infinity; + const COOLDOWN_CHARS = 40; + const IDLE_MS = 2000; + + // A dead endpoint must not be knocked on every two seconds. After a + // failure the suggestions go quiet and say so, and the next attempt waits: + // a while for an endpoint that is not there, briefly for one that is just + // busy. Writing continues untouched either way — the assistant is the + // optional half of this editor. + const RETRY_MS: Record<string, number> = { + llm_unreachable: 120_000, + llm_misconfigured: 300_000, + llm_busy: 30_000, + llm_failed: 60_000 + }; + let pausedCode = $state<string | null>(null); + let retryAt = 0; + + // What is happening to this document right now, in the writer's terms. + // Picking a documentation type creates the draft immediately (rule 6: the + // document IS the state), and leaving an untouched skeleton deletes it + // again — both are right, and both were invisible, which is what made a + // document that "did not exist yet" confusing. Now the line under the + // editor says which of the three it is. + let savedAt = $state<string | null>(null); + const untouched = $derived(content === initialDoc && isEmptySkeleton(content)); + + let host = $state<HTMLDivElement>(); + let view: EditorView | undefined; + let refineTimer: ReturnType<typeof setTimeout> | undefined; + let refineAbort: AbortController | undefined; + + // The suggestion is a block inside the editor, not a pane beside it; it owns + // its DOM and its CodeMirror extension (lib/documents/editor). + const inline = new InlineSuggestion({ onAccept: accept, onDismiss: dismiss }); + + // Plain locals, not $state: the suggestion is rendered by the widget, so + // nothing here needs to drive Svelte's template. + let suggesting = false; + let suggestion = ''; + let suggestionRange: { start: number; end: number } | null = null; + + function cursorLine(state: EditorState): number { + return state.doc.lineAt(state.selection.main.head).number; + } + + function firstHeading(text: string, range: { start: number; end: number }): string { + const first = text.split('\n')[range.start - 1] ?? ''; + const match = /^#{1,6}\s+(.*)$/.exec(first.trim()); + return match ? match[1] : ''; + } + + function cancelSuggestion() { + refineAbort?.abort(); + refineAbort = undefined; + suggesting = false; + suggestion = ''; + suggestionRange = null; + inline.clear(); + } + + function scheduleRefine() { + clearTimeout(refineTimer); + // Any edit makes a shown suggestion stale, so drop it and re-arm. + cancelSuggestion(); + refineTimer = setTimeout(runRefine, IDLE_MS); + } + + async function runRefine() { + if (!view || !content.trim() || charsSinceGate < COOLDOWN_CHARS || suggesting) return; + // Still in the quiet period after a failure: no request, no second + // error message about the same dead endpoint. + if (Date.now() < retryAt) return; + const line = cursorLine(view.state); + const heading = firstHeading(content, activeSection(content, line)); + suggesting = true; + suggestion = ''; + suggestionRange = null; + refineAbort = new AbortController(); + try { + for await (const event of streamRefine(documentId, content, line, refineAbort.signal)) { + if (event.type === 'section') { + suggestionRange = { start: event.start_line, end: event.end_line }; + // Anchor the widget just below the section it will replace. + const lines = view.state.doc; + inline.show(lines.line(Math.min(event.end_line, lines.lines)).to, heading); + } else if (event.type === 'grounding') { + inline.setGrounding(event.references); + } else if (event.type === 'token') { + suggestion += event.text; + inline.stream(suggestion); + } else if (event.type === 'error') { + pause(event.code); + inline.clear(); + break; + } else if (event.type === 'done') { + break; + } + } + if (suggestion.trim()) { + inline.finish(suggestion); + // It answered, so whatever was wrong is over. + resume(); + } else { + inline.clear(); + } + } catch { + // Aborted because the user resumed typing — expected, stay quiet. + inline.clear(); + } finally { + suggesting = false; + refineAbort = undefined; + } + } + + function accept() { + if (!view || !suggestionRange || !suggestion.trim()) return; + const lines = view.state.doc; + const from = lines.line(Math.min(suggestionRange.start, lines.lines)).from; + const to = lines.line(Math.min(suggestionRange.end, lines.lines)).to; + const text = suggestion.trimEnd(); + inline.clear(); + view.dispatch({ changes: { from, to, insert: text } }); + charsSinceGate = 0; // start the cooldown (the dispatch above re-armed it) + suggestion = ''; + suggestionRange = null; + view.focus(); + } + + function dismiss() { + cancelSuggestion(); + charsSinceGate = 0; + view?.focus(); + } + + function pause(code: string) { + pausedCode = code; + error = errorMessage(code); + retryAt = Date.now() + (RETRY_MS[code] ?? RETRY_MS.llm_failed); + } + + function resume() { + pausedCode = null; + error = null; + retryAt = 0; + } + + /** "Try now" — the writer knows better than a timer when the endpoint is + * back, so asking again is one click and does not wait it out. */ + function retryNow() { + resume(); + charsSinceGate = Infinity; + void runRefine(); + } + + $effect(() => { + if (!host) return; + const listener = EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + content = update.state.doc.toString(); + let added = 0; + update.changes.iterChanges((_a, _b, _c, _d, inserted) => (added += inserted.length)); + charsSinceGate += added; + scheduleRefine(); + }); + view = new EditorView({ + doc: initialDoc, + parent: host, + extensions: [ + lineNumbers(), + history(), + drawSelection(), + keymap.of([...defaultKeymap, ...historyKeymap]), + markdown(), + EditorView.lineWrapping, + editorTheme, + inline.extension, + listener + ] + }); + inline.bind(view); + return () => { + clearTimeout(refineTimer); + refineAbort?.abort(); + inline.bind(undefined); + view?.destroy(); + view = undefined; + }; + }); + + async function save(): Promise<boolean> { + busy = true; + saveError = null; + const { data } = await api.PATCH('/api/documents/{document_id}', { + params: { path: { document_id: documentId } }, + body: { title, content_md: content } + }); + busy = false; + if (!data) { + saveError = m.document_save_failed(); + return false; + } + baseline = content; + savedAt = new Intl.DateTimeFormat(i18n.locale, { timeStyle: 'short' }).format(new Date()); + saving = false; + return true; + } + + // Save, then make the draft readable — one action, the author's own. + async function publish() { + if (!(await save())) return; + busy = true; + const { data } = await api.POST('/api/documents/{document_id}/publish', { + params: { path: { document_id: documentId } } + }); + busy = false; + if (!data) { + saveError = m.document_save_failed(); + return; + } + published = true; // open the reward modal + } + + // A draft that is only the (unedited) template skeleton — headings and blank + // lines, no captured knowledge. + function isEmptySkeleton(text: string): boolean { + return text.split('\n').every((line) => line.trim() === '' || /^#{1,6}\s/.test(line.trim())); + } + + // On the way out, take care of the draft so nothing is lost and nothing is + // left as clutter. Skipped once published (the document is no longer a draft). + beforeNavigate(() => { + if (published || !isDraft) return; + if (content === initialDoc && isEmptySkeleton(content)) { + // An abandoned, never-filled template: discard it so empty drafts do + // not pile up in the document list. + void api.DELETE('/api/documents/{document_id}', { + params: { path: { document_id: documentId } } + }); + } else if (content !== baseline) { + // Unsaved draft edits: persist them (a draft is private and not + // indexed, so this is cheap) so leaving never loses work. + void api.PATCH('/api/documents/{document_id}', { + params: { path: { document_id: documentId } }, + body: { title, content_md: content } + }); + } + }); +</script> + +<div class="flex min-h-0 flex-1 flex-col gap-3"> + <!-- Title and text, and nothing else: who may READ this is a property of the + document as it stands, and is set where it is shown (AccessPopover). --> + <div class="flex items-center gap-2"> + <a + href={resolve(`/documents/${documentId}`)} + class="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2.5 py-2 text-sm text-ink-muted transition-colors hover:border-border-strong hover:text-ink" + data-testid="editor-back" + > + <ArrowLeft size={15} /> + {m.editor_back()} + </a> + <Input bind:value={title} class="text-lg font-semibold" data-testid="editor-title" /> + </div> + + {#if pausedCode} + <!-- One line, once: the endpoint is not answering, suggestions are off + until it does (or until this button says otherwise). --> + <p class="flex flex-wrap items-center gap-x-2 text-sm text-warning" data-testid="refine-paused"> + <AlertTriangle size={14} class="shrink-0" /> + {error} + <span class="text-ink-muted">{m.editor_suggestions_paused()}</span> + <button + type="button" + class="cursor-pointer underline decoration-dotted underline-offset-2" + onclick={retryNow} + data-testid="refine-retry" + > + {m.editor_suggestions_retry()} + </button> + </p> + {/if} + + <!-- You write here. A refined version of the section at your cursor streams + INLINE as a block right below that section, so the suggestion appears + exactly where you are editing; accepting overwrites that section. --> + <div + bind:this={host} + class="editor-host min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface px-3" + data-testid="editor-source" + ></div> + + <!-- Saving sits where you finish: bottom right, after the text. It opens the + diff rather than writing straight through — see SaveDialog. --> + <div class="flex items-center justify-end gap-2"> + <span class="text-xs text-ink-muted" data-testid="editor-state"> + {#if content !== baseline} + {m.editor_unsaved()} + {:else if savedAt} + {m.editor_saved_at({ time: savedAt })} + {:else if untouched} + <!-- Nothing written yet: leaving now takes the empty draft with it, + which is better said than discovered. --> + {m.editor_untouched_draft()} + {:else} + {m.editor_draft_exists()} + {/if} + </span> + <Button onclick={() => (saving = true)} data-testid="editor-open-save"> + {m.editor_save()} + </Button> + </div> +</div> + +<SaveDialog + bind:open={saving} + bind:title + {documentId} + {content} + {baseline} + canPublish={isDraft && canPublish} + {busy} + error={saveError} + onSave={save} + onPublish={publish} +/> + +<CaptureSuccess bind:open={published} {documentId} /> + +<style> + /* The inline suggestion block, injected by CodeMirror into the editor flow. + Styled globally because it is not part of Svelte's scoped markup, and keyed + to the design tokens so it follows light/dark. */ + .editor-host :global(.pb-suggestion) { + margin: 0.4rem 0 0.7rem; + padding: 0.55rem 0.75rem 0.65rem; + border: 1px solid var(--pb-accent); + border-radius: 0.6rem; + background: var(--pb-surface-raised); + color: var(--pb-ink); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; + font-size: 0.9rem; + line-height: 1.55; + white-space: normal; + } + .editor-host :global(.pb-suggestion-header) { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.3rem; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--pb-accent); + } + .editor-host :global(.pb-suggestion-header)::before { + content: '✨'; + } + .editor-host :global(.pb-suggestion--loading .pb-suggestion-body)::after { + content: '▍'; + margin-left: 1px; + color: var(--pb-secondary); + animation: pb-suggestion-blink 1s step-start infinite; + } + @keyframes pb-suggestion-blink { + 50% { + opacity: 0; + } + } + .editor-host :global(.pb-suggestion-grounding) { + margin-top: 0.4rem; + font-size: 0.72rem; + color: var(--pb-ink-muted); + } + .editor-host :global(.pb-suggestion-actions) { + display: flex; + gap: 0.4rem; + margin-top: 0.55rem; + } + .editor-host :global(.pb-suggestion-btn) { + cursor: pointer; + border-radius: 999px; + padding: 0.15rem 0.7rem; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid var(--pb-border); + background: transparent; + color: var(--pb-ink-muted); + transition: + color 0.15s, + background 0.15s, + border-color 0.15s; + } + .editor-host :global(.pb-suggestion-btn:hover) { + color: var(--pb-ink); + border-color: var(--pb-border-strong); + } + .editor-host :global(.pb-suggestion-accept) { + background: var(--pb-success-muted); + color: var(--pb-success); + border-color: transparent; + } +</style> diff --git a/frontend/src/lib/documents/editor/inlineSuggestion.ts b/frontend/src/lib/documents/editor/inlineSuggestion.ts new file mode 100644 index 0000000..7e1ed9d --- /dev/null +++ b/frontend/src/lib/documents/editor/inlineSuggestion.ts @@ -0,0 +1,172 @@ +// The refinement suggestion, shown as a block INSIDE the editor. +// +// It is a CodeMirror block widget anchored just below the section it would +// replace, so the suggestion appears exactly where the writer is working +// rather than in a disconnected pane. The DOM is built and mutated +// imperatively on purpose: the widget outlives Svelte's render cycle ( +// CodeMirror keeps the element while the decoration lives), and streaming +// tokens into a node is cheaper than re-rendering a component per token. +// +// Its styles live with the component that hosts the editor +// (`WritingEditor.svelte`), scoped through `.editor-host :global(...)`, since +// that is the element they are injected into. + +import { StateEffect, StateField, type Extension } from '@codemirror/state'; +import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view'; +import type { GroundingReference } from '$lib/api/refine'; +import { renderMarkdown } from '$lib/markdown'; +import { m } from '$lib/paraglide/messages'; + +type Handlers = { onAccept: () => void; onDismiss: () => void }; + +/** Wraps an element we own; CodeMirror positions it in the document flow. */ +class SuggestionWidget extends WidgetType { + constructor(private readonly el: HTMLElement) { + super(); + } + toDOM() { + return this.el; + } + eq(other: SuggestionWidget) { + return other.el === this.el; + } + ignoreEvent() { + // Let the accept/dismiss buttons handle their own clicks. + return true; + } +} + +export class InlineSuggestion { + readonly extension: Extension; + + #root: HTMLElement; + #label: HTMLElement; + #body: HTMLElement; + #grounding: HTMLElement; + #actions: HTMLElement; + #setPos = StateEffect.define<number | null>(); + #view: EditorView | undefined; + #measureScheduled = false; + + constructor(handlers: Handlers) { + const root = document.createElement('div'); + root.className = 'pb-suggestion'; + root.setAttribute('data-testid', 'editor-suggestion'); + + const header = document.createElement('div'); + header.className = 'pb-suggestion-header'; + this.#label = document.createElement('span'); + this.#label.className = 'pb-suggestion-label'; + header.append(this.#label); + + this.#body = document.createElement('div'); + this.#body.className = 'pb-suggestion-body markdown'; + + this.#grounding = document.createElement('div'); + this.#grounding.className = 'pb-suggestion-grounding'; + this.#grounding.hidden = true; + + this.#actions = document.createElement('div'); + this.#actions.className = 'pb-suggestion-actions'; + this.#actions.hidden = true; + this.#actions.append( + this.#button(m.editor_accept(), 'editor-accept', handlers.onAccept, true), + this.#button(m.editor_dismiss(), 'editor-dismiss', handlers.onDismiss, false) + ); + + root.append(header, this.#body, this.#grounding, this.#actions); + this.#root = root; + + const setPos = this.#setPos; + const element = () => this.#root; + this.extension = StateField.define<DecorationSet>({ + create: () => Decoration.none, + update(deco, tr) { + deco = deco.map(tr.changes); + for (const effect of tr.effects) { + if (effect.is(setPos)) { + deco = + effect.value === null + ? Decoration.none + : Decoration.set([ + Decoration.widget({ + widget: new SuggestionWidget(element()), + block: true, + side: 1 + }).range(effect.value) + ]); + } + } + return deco; + }, + provide: (field) => EditorView.decorations.from(field) + }); + } + + #button(text: string, testid: string, handler: () => void, primary: boolean) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `pb-suggestion-btn${primary ? ' pb-suggestion-accept' : ''}`; + button.textContent = text; + button.setAttribute('data-testid', testid); + // preventDefault on mousedown keeps the editor from blurring first. + button.addEventListener('mousedown', (event) => event.preventDefault()); + button.addEventListener('click', handler); + return button; + } + + /** The view this suggestion lives in; set once the editor exists. */ + bind(view: EditorView | undefined) { + this.#view = view; + } + + /** Open the block at `pos`, in its loading state, titled by the section. */ + show(pos: number, heading: string) { + if (!this.#view) return; + this.#root.classList.add('pb-suggestion--loading'); + this.#label.textContent = heading || m.editor_suggestion_title(); + this.#body.textContent = ''; + this.#actions.hidden = true; + this.#grounding.hidden = true; + this.#view.dispatch({ effects: this.#setPos.of(pos) }); + } + + /** Plain text while it streams: Markdown is only rendered once complete. */ + stream(text: string) { + this.#body.textContent = text; + this.#measure(); + } + + finish(text: string) { + this.#root.classList.remove('pb-suggestion--loading'); + this.#body.innerHTML = renderMarkdown(text); + this.#actions.hidden = false; + this.#measure(); + } + + /** What the suggestion drew from — titles and heading paths, no content. */ + setGrounding(references: GroundingReference[]) { + if (!references.length) { + this.#grounding.hidden = true; + return; + } + const names = references.map((r) => r.heading_path || r.title).join(' · '); + this.#grounding.textContent = `${m.editor_grounding_label()}: ${names}`; + this.#grounding.hidden = false; + } + + clear() { + this.#view?.dispatch({ effects: this.#setPos.of(null) }); + } + + /** Mutating a widget's DOM does not tell CodeMirror its height changed; a + * throttled requestMeasure keeps the lines below it laid out correctly. */ + #measure() { + if (this.#measureScheduled || !this.#view) return; + this.#measureScheduled = true; + requestAnimationFrame(() => { + this.#measureScheduled = false; + this.#view?.requestMeasure(); + }); + } +} diff --git a/frontend/src/lib/documents/editorTheme.ts b/frontend/src/lib/documents/editorTheme.ts new file mode 100644 index 0000000..267b26b --- /dev/null +++ b/frontend/src/lib/documents/editorTheme.ts @@ -0,0 +1,28 @@ +import { EditorView } from '@codemirror/view'; + +/** + * The shared CodeMirror theme, keyed to the design tokens rather than a + * CodeMirror theme, so the writing editor and the read-only diff views match + * the app in both light and dark mode. + */ +export const editorTheme = EditorView.theme({ + '&': { color: 'var(--color-ink)', backgroundColor: 'transparent', height: '100%' }, + '.cm-content': { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: '0.9rem', + padding: '0.5rem 0', + caretColor: 'var(--color-accent)' + }, + '.cm-scroller': { lineHeight: '1.7' }, + '&.cm-focused': { outline: 'none' }, + '.cm-cursor': { borderLeftColor: 'var(--color-accent)', borderLeftWidth: '2px' }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { + backgroundColor: 'var(--color-surface-sunken)' + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--color-ink-muted)', + border: 'none' + }, + '.cm-activeLineGutter, .cm-activeLine': { backgroundColor: 'transparent' } +}); diff --git a/frontend/src/lib/documents/list.svelte.ts b/frontend/src/lib/documents/list.svelte.ts new file mode 100644 index 0000000..7db2bce --- /dev/null +++ b/frontend/src/lib/documents/list.svelte.ts @@ -0,0 +1,121 @@ +// What the document list is currently showing, and how it gets there. +// +// Two different queries wear one screen: an empty search browses (filtered, +// sorted, paged) and a non-empty one goes through hybrid retrieval (ranked, no +// paging, no filters). Keeping both here means the page renders results and +// this decides what "results" are. + +import { api } from '$lib/api/client'; +import type { components } from '$lib/api/schema'; +import { documentView } from '$lib/documents/view.svelte'; + +type DocumentSummary = components['schemas']['DocumentSummary']; +type DocumentSearchHit = components['schemas']['DocumentSearchHit']; + +/** A row is a summary, plus the matched heading when it came from a search. */ +export type DocumentRow = DocumentSummary & Partial<DocumentSearchHit>; + +export type AccessFilter = 'all' | 'author' | 'department' | 'public' | 'granted'; + +// Typing fires a request per keystroke. Showing "Loading…" immediately makes +// the list flicker on every fast response, so the spinner only appears once a +// request is actually slow — the previous results stay until the new ones come. +const LOADING_DELAY_MS = 400; +// Debounced: every keystroke would otherwise cost an embedding call. +const SEARCH_DEBOUNCE_MS = 300; + +export class DocumentList { + documents = $state<DocumentRow[]>([]); + total = $state(0); + perPage = $state(30); + page = $state(1); + loading = $state(true); + + search = $state(''); + status = $state(''); + department = $state(''); + assignedToMe = $state(false); + // access_reason is computed per request, so filtering by it is a client + // concern — no extra round trip. + access = $state<AccessFilter>('all'); + + #inFlight = 0; + #debounce: ReturnType<typeof setTimeout> | undefined; + + constructor(options: { reviewQueue?: boolean; status?: string } = {}) { + // The landing page deep-links into the two lists it advertises: the + // documents someone asked this user to check (?review=1), and their own + // drafts (?status=draft). Both are otherwise filter controls that are + // easy to miss. + if (options.reviewQueue) this.assignedToMe = true; + if (options.status) this.status = options.status; + } + + get searching(): boolean { + return this.search.trim().length > 0; + } + + /** Search is ranked rather than paged, so the pager only applies to browsing. */ + get pages(): number { + return this.searching ? 1 : Math.max(1, Math.ceil(this.total / this.perPage)); + } + + get visible(): DocumentRow[] { + return this.access === 'all' + ? this.documents + : this.documents.filter((row) => row.access_reason === this.access); + } + + async load(): Promise<void> { + const request = ++this.#inFlight; + const slow = setTimeout(() => { + if (request === this.#inFlight) this.loading = true; + }, LOADING_DELAY_MS); + + const query = this.search.trim(); + const { data } = query + ? await api.GET('/api/documents/search', { params: { query: { q: query } } }) + : await api.GET('/api/documents', { + params: { + query: { + status: (this.status || undefined) as DocumentSummary['status'] | undefined, + department: this.department || undefined, + assigned_to_me: this.assignedToMe || undefined, + sort: documentView.sort, + page: this.page + } + } + }); + + clearTimeout(slow); + // A slower earlier request must not overwrite newer results. + if (request !== this.#inFlight) return; + + if (Array.isArray(data)) { + this.documents = data; + this.total = data.length; + } else { + this.documents = data?.items ?? []; + this.total = data?.total ?? 0; + this.perPage = data?.per_page ?? this.perPage; + } + this.loading = false; + } + + /** Any change to what is listed starts over at page one — staying on page 4 + * of a different result set shows an empty screen. */ + reload(): void { + this.page = 1; + void this.load(); + } + + goTo(next: number): void { + this.page = Math.min(Math.max(1, next), this.pages); + void this.load(); + } + + onSearchInput(): void { + clearTimeout(this.#debounce); + this.#debounce = setTimeout(() => this.reload(), SEARCH_DEBOUNCE_MS); + } +} diff --git a/frontend/src/lib/documents/presentation.ts b/frontend/src/lib/documents/presentation.ts new file mode 100644 index 0000000..3349e37 --- /dev/null +++ b/frontend/src/lib/documents/presentation.ts @@ -0,0 +1,126 @@ +// How a document describes itself: why you may see it, where it stands, when +// it was touched. +// +// The list and the detail page both answer those three questions, so the +// vocabulary lives here rather than twice. A plain module, not `.svelte.ts`: +// nothing here holds state, and the reactivity comes from the caller reading +// `i18n.locale` inside its own template. Written as functions with explicit +// cases: Paraglide is a compiler and can only check and tree-shake message +// keys it can see literally (docs/i18n.md), so a lookup by computed key would +// ship every message and turn a typo into a blank. + +import Archive from '@lucide/svelte/icons/archive'; +import Building2 from '@lucide/svelte/icons/building-2'; +import CheckCircle2 from '@lucide/svelte/icons/circle-check-big'; +import Globe from '@lucide/svelte/icons/globe'; +import KeyRound from '@lucide/svelte/icons/key-round'; +import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question'; +import PencilLine from '@lucide/svelte/icons/pencil-line'; +import UserIcon from '@lucide/svelte/icons/user'; +import { i18n } from '$lib/i18n/locale.svelte'; +import { m } from '$lib/paraglide/messages'; + +export const ACCESS_ICONS: Record<string, typeof Globe> = { + author: UserIcon, + department: Building2, + public: Globe, + granted: KeyRound, + review: MessageCircleQuestion +}; + +export function accessLabel(reason: string): string { + switch (reason) { + case 'author': + return m.documents_access_label_author(); + case 'department': + return m.documents_access_label_department(); + case 'public': + return m.documents_access_label_public(); + case 'review': + return m.documents_access_label_review(); + default: + return m.documents_access_label_granted(); + } +} + +export function accessHint(reason: string): string { + switch (reason) { + case 'author': + return m.documents_access_hint_author(); + case 'department': + return m.documents_access_hint_department(); + case 'public': + return m.documents_access_hint_public(); + case 'review': + return m.documents_access_hint_review(); + default: + return m.documents_access_hint_granted(); + } +} + +// Three states, and only three: a document is being written, readable, or +// retired. Doubt about the CONTENT is a review request instead — it can sit on +// a draft or on a document published for months, so it was never a status. +export const STATUS_ICONS: Record<string, typeof CheckCircle2> = { + draft: PencilLine, + published: CheckCircle2, + archived: Archive +}; + +export const STATUS_VARIANTS: Record<string, 'neutral' | 'warning' | 'success'> = { + draft: 'neutral', + published: 'success', + archived: 'neutral' +}; + +export function statusLabel(status: string): string { + switch (status) { + case 'draft': + return m.documents_status_draft(); + case 'published': + return m.documents_status_published(); + default: + return m.documents_status_archived(); + } +} + +/** The body without its own leading `# Title`. + * + * Every document starts with a heading that repeats its title, and every + * surface that shows the document already shows that title above the text. + * Rendering both makes the reader read the same words twice, so the heading + * is dropped where it is a duplicate — and kept when the author wrote + * something else there. + */ +export function bodyWithoutTitle(contentMd: string, title: string): string { + const match = /^\s*#\s+(.+?)\s*(\n|$)/.exec(contentMd); + if (!match || match[1].trim().toLowerCase() !== title.trim().toLowerCase()) return contentMd; + return contentMd.slice(match[0].length).replace(/^\n+/, ''); +} + +export function visibilityLabel(visibility: string): string { + switch (visibility) { + case 'public': + return m.documents_visibility_public(); + case 'department': + return m.documents_visibility_department(); + default: + return m.documents_visibility_restricted(); + } +} + +// The INTERFACE language, not the browser's: someone on an English browser who +// picked German would otherwise get German labels around English dates. +// Reading i18n.locale inside the function keeps it reactive; the formatter is +// kept until the language changes, because building one per row is wasteful. +let formatter: { locale: string; format: Intl.DateTimeFormat } | null = null; + +export function formatDate(iso: string): string { + if (formatter?.locale !== i18n.locale) { + formatter = { + locale: i18n.locale, + format: new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium' }) + }; + } + return formatter.format.format(new Date(iso)); +} diff --git a/frontend/src/lib/documents/sections.ts b/frontend/src/lib/documents/sections.ts new file mode 100644 index 0000000..9294d9c --- /dev/null +++ b/frontend/src/lib/documents/sections.ts @@ -0,0 +1,56 @@ +// Client mirror of backend app/authoring/sections.py::active_section, used +// ONLY for the visual highlight of the section the cursor is in. The server +// owns the authoritative range an accepted suggestion overwrites (the +// `section` SSE frame), so this stays deliberately simple: heading-delimited, +// no oversized-section paragraph fallback. + +const HEADING = /^(#{1,6})\s+/; + +function headingLines(lines: string[]): Array<[number, number]> { + const out: Array<[number, number]> = []; + let inFence = false; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trimStart().startsWith('```')) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const match = HEADING.exec(lines[i]); + if (match) out.push([i, match[1].length]); + } + return out; +} + +/** 1-based inclusive [start, end] line range of the section at `cursorLine`. */ +export function activeSection(text: string, cursorLine: number): { start: number; end: number } { + const lines = text.split('\n'); + const n = lines.length; + if (n === 0) return { start: 1, end: 1 }; + const cursor0 = Math.max(1, Math.min(cursorLine, n)) - 1; + const headings = headingLines(lines); + + let owner: [number, number] | null = null; + for (const heading of headings) { + if (heading[0] <= cursor0) owner = heading; + else break; + } + + let start0: number; + let end0: number; + if (!owner) { + start0 = 0; + end0 = headings.length ? headings[0][0] - 1 : n - 1; + } else { + start0 = owner[0]; + const level = owner[1]; + end0 = n - 1; + for (const [idx, lvl] of headings) { + if (idx > start0 && lvl <= level) { + end0 = idx - 1; + break; + } + } + } + while (end0 > start0 && lines[end0].trim() === '') end0--; + return { start: start0 + 1, end: end0 + 1 }; +} diff --git a/frontend/src/lib/documents/view.svelte.ts b/frontend/src/lib/documents/view.svelte.ts new file mode 100644 index 0000000..31d36aa --- /dev/null +++ b/frontend/src/lib/documents/view.svelte.ts @@ -0,0 +1,48 @@ +// How this person likes to look at the document list. +// +// Per-device, like the theme and the sidebar collapse: which layout reads +// better depends on the screen in front of you, not on who you are. Stored +// under one key so a future third preference does not need a third entry. + +const STORAGE_KEY = 'pablan.documents.view'; + +export type Sort = 'updated' | 'created'; + +type Stored = { sort: Sort }; + +// Grid is the only view for now: a card shows the department and the +// updated date next to the title, which is what people scan for. The list +// layout and its toggle were removed rather than kept as dead options; +// the markup is one branch away in git if it comes back. +const DEFAULTS: Stored = { sort: 'updated' }; + +function parse(raw: string | null): Stored { + if (!raw) return DEFAULTS; + try { + const value = JSON.parse(raw) as Partial<Stored>; + return { sort: value.sort === 'created' ? 'created' : 'updated' }; + } catch { + // Corrupted or from an older shape: the defaults are always valid. + return DEFAULTS; + } +} + +class DocumentView { + #state = $state<Stored>({ ...DEFAULTS }); + + /** Call once the component is mounted — localStorage has no server side. */ + init(): void { + this.#state = parse(localStorage.getItem(STORAGE_KEY)); + } + + get sort(): Sort { + return this.#state.sort; + } + + set(patch: Partial<Stored>): void { + this.#state = { ...this.#state, ...patch }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.#state)); + } +} + +export const documentView = new DocumentView(); diff --git a/frontend/src/lib/i18n/locale.svelte.ts b/frontend/src/lib/i18n/locale.svelte.ts new file mode 100644 index 0000000..7f118b2 --- /dev/null +++ b/frontend/src/lib/i18n/locale.svelte.ts @@ -0,0 +1,86 @@ +// The active interface language, as a rune the whole UI reads through. +// +// Paraglide message functions call `getLocale()` internally. Pointing that +// at a `$state` (see `init()`) means every `m.some_key()` in markup becomes +// reactive: switching the language re-renders the strings in place, with no +// reload and therefore no flash of the old language or lost form input. + +import { browser } from '$app/environment'; +import { api } from '$lib/api/client'; +import { + defineCustomClientStrategy, + getLocale, + overwriteGetLocale, + setLocale +} from '$lib/paraglide/runtime'; + +export type Locale = 'de' | 'en'; + +class I18n { + /** Resolved language. Never null: the strategy chain always lands on + * something, at worst the base locale. */ + #locale = $state<Locale>('de'); + /** What the ACCOUNT says, which is a different question: null means + * "follow the browser", and the switcher has to show that as its own + * option rather than as whichever language it currently resolves to. */ + #preference = $state<Locale | null>(null); + + get locale(): Locale { + return this.#locale; + } + + get preference(): Locale | null { + return this.#preference; + } + + /** Wire the runtime to this store. Called once from the root layout, + * before anything renders a message. */ + init(preference: Locale | null): void { + this.#preference = preference; + this.#locale = getLocale() as Locale; + // BROWSER ONLY. This module is a singleton, and on the server that + // singleton is shared by every concurrent request: overwriting the + // runtime's locale resolution there leaks one visitor's language into + // everyone else's render. On the server, Paraglide's own middleware + // already resolves per request, which is exactly what we want. + // In the browser the singleton is per tab, so pointing getLocale() at + // a rune is safe and is what makes a switch re-render in place. + if (browser) { + overwriteGetLocale(() => this.#locale); + } + } + + /** null puts the account back to following the browser. */ + async choose(preference: Locale | null): Promise<void> { + this.#preference = preference; + // Clearing the preference has to fall back to the browser rather than + // stick on the language that happened to be showing. + const next = preference ?? detectFromBrowser(); + this.#locale = next; + // NOT `getLocale()`: init() pointed that at #locale, so reading it + // back here would just return the value we are trying to replace. + await api.PUT('/api/account/locale', { body: { locale: preference } }); + // reload: false, so the switch happens in place. The runtime still + // runs the chain to persist the cookie for the next server render. + await setLocale(next, { reload: false }); + // Server-rendered on first paint; on a client switch it is ours. + document.documentElement.lang = next; + } +} + +function detectFromBrowser(): Locale { + if (!browser) return 'de'; + return navigator.languages?.some((tag) => tag.toLowerCase().startsWith('en')) ? 'en' : 'de'; +} + +export const i18n = new I18n(); + +// The account preference is written through the API, so the strategy has +// nothing to persist itself; it only reports what the store already knows. +// Registering it is still required, because the compiled chain names it. +defineCustomClientStrategy('custom-userPreference', { + getLocale: () => i18n.preference ?? undefined, + setLocale: async () => { + /* handled by I18n.choose, which owns the API call */ + } +}); diff --git a/frontend/src/lib/i18n/strategy.server.ts b/frontend/src/lib/i18n/strategy.server.ts new file mode 100644 index 0000000..36c7336 --- /dev/null +++ b/frontend/src/lib/i18n/strategy.server.ts @@ -0,0 +1,21 @@ +import { defineCustomServerStrategy } from '$lib/paraglide/runtime'; + +/** The locale of the user behind a request, stashed by the auth handle. + * + * A custom server strategy only receives the `Request`, but the locale + * lives on the user row, and `hooks.server.ts` has already fetched `/me` + * to populate `locals.user`. Handing the answer over through a WeakMap + * keyed by the request object avoids a second round trip per page, and the + * entry disappears with the request rather than being cleaned up by hand. + */ +const localeByRequest = new WeakMap<Request, string>(); + +export function rememberRequestLocale(request: Request, locale: string | null): void { + if (locale) localeByRequest.set(request, locale); +} + +defineCustomServerStrategy('custom-userPreference', { + // The runtime types `request` as optional because not every strategy + // needs one; ours does, and without it there is simply no user to ask. + getLocale: (request) => (request ? localeByRequest.get(request) : undefined) +}); diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts new file mode 100644 index 0000000..b7040d3 --- /dev/null +++ b/frontend/src/lib/markdown.ts @@ -0,0 +1,34 @@ +import DOMPurify from 'dompurify'; +import { Marked } from 'marked'; +import markedKatex from 'marked-katex-extension'; + +// One configured instance (module scope), so the KaTeX extension is registered +// exactly once rather than per component render. The local model emits real +// LaTeX — inline `$...$` and display `$$...$$`, with \frac, \sqrt, superscripts +// etc. — so it is rendered as math instead of shown as raw source. +const marked = new Marked(); +marked.use( + markedKatex({ + // A malformed formula renders as plain text, never throws mid-answer. + throwOnError: false, + // Also accept `$...$` / `$$...$$` the way the model tends to write it. + nonStandard: true + }) +); + +// KaTeX emits <span class="katex"> trees positioned with inline styles, plus a +// MathML mirror for screen readers. DOMPurify already allows MathML and keeps +// class; these ADD_* just make sure the positioning styles and a few KaTeX +// attributes survive. ADD_* extend the default allow-list, they do not replace +// it, so the XSS guarantees on the rest of the document are unchanged. +const SANITIZE = { + ADD_ATTR: ['aria-hidden', 'style', 'encoding'], + ADD_TAGS: ['annotation', 'semantics', 'math'] +}; + +/** Render untrusted Markdown (model / document output) to sanitized HTML, with + * LaTeX math rendered by KaTeX. Browser-only: DOMPurify needs a DOM. */ +export function renderMarkdown(content: string): string { + const raw = marked.parse(content, { async: false }) as string; + return DOMPurify.sanitize(raw, SANITIZE) as unknown as string; +} diff --git a/frontend/src/lib/nav/SettingsDialog.svelte b/frontend/src/lib/nav/SettingsDialog.svelte new file mode 100644 index 0000000..80fa40a --- /dev/null +++ b/frontend/src/lib/nav/SettingsDialog.svelte @@ -0,0 +1,267 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + import LogOut from '@lucide/svelte/icons/log-out'; + import Monitor from '@lucide/svelte/icons/monitor'; + import Moon from '@lucide/svelte/icons/moon'; + import Shield from '@lucide/svelte/icons/shield'; + import Sun from '@lucide/svelte/icons/sun'; + import UserPen from '@lucide/svelte/icons/user-pen'; + import { resolve } from '$app/paths'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import { i18n, type Locale } from '$lib/i18n/locale.svelte'; + import { m } from '$lib/paraglide/messages'; + import Badge from '$lib/components/Badge.svelte'; + import Button from '$lib/components/Button.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import FormField from '$lib/components/FormField.svelte'; + import Input from '$lib/components/Input.svelte'; + import { theme, type Theme } from '$lib/theme.svelte'; + + type Props = { + open?: boolean; + user: components['schemas']['UserOut']; + }; + + let { open = $bindable(false), user }: Props = $props(); + + // $derived, not a constant: the labels are messages, so they have to + // re-evaluate when the language changes. This is the pattern every + // migrated route follows for option lists. + const THEME_OPTIONS = $derived<{ value: Theme; label: string; icon: typeof Sun }[]>([ + { value: 'system', label: m.settings_theme_system(), icon: Monitor }, + { value: 'light', label: m.settings_theme_light(), icon: Sun }, + { value: 'dark', label: m.settings_theme_dark(), icon: Moon } + ]); + + // Language names stay in their own language: "Deutsch" is not translated + // to "German", because the point of the entry is to be recognised by + // someone who cannot read the current interface language. + const LOCALE_OPTIONS = $derived<{ value: Locale | null; label: string }[]>([ + { value: null, label: m.settings_locale_automatic() }, + { value: 'de', label: 'Deutsch' }, + { value: 'en', label: 'English' } + ]); + + let changing = $state(false); + let currentPassword = $state(''); + let newPassword = $state(''); + let confirmPassword = $state(''); + let busy = $state(false); + let error = $state<string | null>(null); + let done = $state(false); + + function reset() { + changing = false; + currentPassword = ''; + newPassword = ''; + confirmPassword = ''; + error = null; + done = false; + } + + // Reopening the dialog should never show a stale form or message. + $effect(() => { + if (!open) reset(); + }); + + async function pickLocale(value: Locale | null) { + // The store owns both the API write and the runtime switch, so the + // interface changes language in place instead of reloading. + await i18n.choose(value); + } + + async function submit(event: SubmitEvent) { + event.preventDefault(); + if (newPassword !== confirmPassword) { + error = m.settings_password_mismatch(); + return; + } + busy = true; + error = null; + const { response } = await api.POST('/api/account/password', { + body: { current_password: currentPassword, new_password: newPassword } + }); + busy = false; + + if (response.status === 204) { + done = true; + changing = false; + currentPassword = newPassword = confirmPassword = ''; + return; + } + error = + response.status === 403 ? m.settings_password_wrong_current() : m.settings_password_failed(); + } + + async function logout() { + open = false; + await api.POST('/api/auth/logout'); + // Clear the language cookie: locale resolves account, then this + // cookie, then the browser. Without clearing it, a next user WITHOUT + // an account language would inherit this user's — a shared workshop + // terminal would stay German for everyone after one German user. + // Account preferences still win over everything. + document.cookie = 'PARAGLIDE_LOCALE=; path=/; max-age=0; samesite=lax'; + // Auth boundaries are full document navigations, never client-side. + // Every module singleton derived from the session + // (conversation list, chat state, resolved locale) lives for one page + // load, so reloading at the session boundary guarantees the next user + // starts clean. A client goto would keep this user's conversation + // titles on screen until a manual refresh — information disclosure. + // Immune to stores added later. Do not convert this to goto. + window.location.assign(resolve('/login')); + } +</script> + +{#snippet section(title: string, body: Snippet)} + <div class="flex flex-col gap-2 border-t border-border pt-4"> + <p class="text-xs font-medium tracking-wide text-ink-muted uppercase">{title}</p> + {@render body()} + </div> +{/snippet} + +<Dialog bind:open title={m.settings_title()} data-testid="settings-dialog"> + <div class="flex flex-col gap-4"> + <div class="flex flex-col gap-1"> + <p class="font-medium">{user.name}</p> + <p class="text-sm text-ink-muted">{user.email}</p> + <div class="mt-1"> + <Badge variant={user.role === 'admin' ? 'accent' : 'neutral'}>{user.role}</Badge> + </div> + </div> + + {#snippet appearance()} + <div class="flex flex-wrap gap-1.5" data-testid="theme-switch"> + {#each THEME_OPTIONS as option (option.value)} + {@const Icon = option.icon} + {@const selected = theme.choice === option.value} + <button + type="button" + class="flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors {selected + ? 'border-border-strong bg-surface-sunken text-ink' + : 'border-border text-ink-muted hover:bg-surface-sunken hover:text-ink'}" + aria-pressed={selected} + onclick={() => theme.set(option.value)} + > + <Icon size={14} /> + {option.label} + </button> + {/each} + </div> + {/snippet} + {@render section(m.settings_section_appearance(), appearance)} + + {#snippet language()} + <div class="flex flex-wrap gap-1.5" data-testid="locale-switch"> + {#each LOCALE_OPTIONS as option (option.value ?? 'auto')} + {@const selected = i18n.preference === option.value} + <button + type="button" + class="cursor-pointer rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors {selected + ? 'border-border-strong bg-surface-sunken text-ink' + : 'border-border text-ink-muted hover:bg-surface-sunken hover:text-ink'}" + aria-pressed={selected} + onclick={() => pickLocale(option.value)} + > + {option.label} + </button> + {/each} + </div> + <p class="text-xs text-ink-muted">{m.settings_locale_hint()}</p> + {/snippet} + {@render section(m.settings_section_language(), language)} + + {#snippet security()} + {#if changing} + <form class="flex flex-col gap-3" onsubmit={submit}> + <FormField label={m.settings_password_current()} for="current-password"> + <Input + id="current-password" + type="password" + autocomplete="current-password" + bind:value={currentPassword} + required + /> + </FormField> + <FormField label={m.settings_password_new()} for="new-password"> + <Input + id="new-password" + type="password" + autocomplete="new-password" + minlength={8} + bind:value={newPassword} + required + /> + </FormField> + <FormField label={m.settings_password_repeat()} for="confirm-password" {error}> + <Input + id="confirm-password" + type="password" + autocomplete="new-password" + bind:value={confirmPassword} + required + /> + </FormField> + <p class="text-xs text-ink-muted">{m.settings_password_other_devices()}</p> + <div class="flex gap-2"> + <Button type="submit" size="sm" disabled={busy} data-testid="submit-password"> + {m.settings_password_change()} + </Button> + <Button variant="ghost" size="sm" type="button" onclick={reset}> + {m.common_cancel()} + </Button> + </div> + </form> + {:else if done} + <p class="text-sm text-success" data-testid="password-changed"> + {m.settings_password_changed()} + </p> + {:else} + <div> + <Button + variant="ghost" + size="sm" + onclick={() => (changing = true)} + data-testid="change-password" + > + {m.settings_password_change()} + </Button> + </div> + {/if} + {/snippet} + {@render section(m.settings_section_security(), security)} + + <div class="flex flex-wrap items-center gap-2 border-t border-border pt-4"> + <Button + variant="ghost" + size="sm" + href={resolve('/account/profile')} + onclick={() => (open = false)} + data-testid="open-profile" + > + <UserPen size={15} /> + {m.settings_edit_profile()} + </Button> + {#if user.role === 'admin'} + <!-- Everything that affects other people lives on /admin; this + dialog only holds what a person changes about themselves. --> + <Button + variant="ghost" + size="sm" + href={resolve('/admin')} + onclick={() => (open = false)} + data-testid="open-admin" + > + <Shield size={15} /> + {m.settings_administration()} + </Button> + {/if} + <div class="flex-1"></div> + <Button variant="ghost" size="sm" onclick={logout} data-testid="logout"> + <LogOut size={15} /> + {m.settings_logout()} + </Button> + </div> + </div> +</Dialog> diff --git a/frontend/src/lib/nav/Sidebar.svelte b/frontend/src/lib/nav/Sidebar.svelte new file mode 100644 index 0000000..bda5980 --- /dev/null +++ b/frontend/src/lib/nav/Sidebar.svelte @@ -0,0 +1,194 @@ +<script lang="ts"> + import FileText from '@lucide/svelte/icons/file-text'; + import PanelLeft from '@lucide/svelte/icons/panel-left'; + import Plus from '@lucide/svelte/icons/plus'; + import Shield from '@lucide/svelte/icons/shield'; + import Trash2 from '@lucide/svelte/icons/trash-2'; + import UserIcon from '@lucide/svelte/icons/user'; + import Users from '@lucide/svelte/icons/users'; + import { onMount } from 'svelte'; + import { page } from '$app/state'; + import { resolve } from '$app/paths'; + import type { components } from '$lib/api/schema'; + import { conversationStore } from '$lib/chat/conversations.svelte'; + import { m } from '$lib/paraglide/messages'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import SettingsDialog from '$lib/nav/SettingsDialog.svelte'; + + type Props = { + user: components['schemas']['UserOut']; + }; + + let { user }: Props = $props(); + + let settingsOpen = $state(false); + // The conversation pending deletion (confirmed in the app's own modal). + let confirmDelete = $state<string | null>(null); + + const COLLAPSE_KEY = 'pablan.sidebar.collapsed'; + const RECENT_LIMIT = 15; + + // Per-device ergonomics, not a user preference that should roam. + // + // The VISUAL collapse comes from data-sidebar on <html> (set before paint + // by the boot script in app.html and styled in app.css) — this state only + // backs the toggle's own label. Rendering the collapse conditionally here + // would flash the expanded sidebar on every reload, because Svelte cannot + // read localStorage until it hydrates. + let collapsed = $state(false); + + onMount(() => { + collapsed = document.documentElement.dataset.sidebar === 'collapsed'; + }); + + function toggleCollapsed() { + collapsed = !collapsed; + localStorage.setItem(COLLAPSE_KEY, String(collapsed)); + if (collapsed) { + document.documentElement.setAttribute('data-sidebar', 'collapsed'); + } else { + document.documentElement.removeAttribute('data-sidebar'); + } + } + + $effect(() => { + if (!conversationStore.loaded) void conversationStore.load(); + }); + + const recent = $derived(conversationStore.items.slice(0, RECENT_LIMIT)); + const activeConversationId = $derived(page.params.id ?? null); + + function isActive(path: string) { + return page.url.pathname === path; + } +</script> + +<aside + class="sidebar m-2 flex shrink-0 flex-col gap-1 overflow-hidden rounded-2xl border border-border bg-surface-raised p-2 transition-[width]" + data-testid="sidebar" +> + <div class="sidebar-row flex items-center justify-between gap-1 px-1 py-1"> + <a href={resolve('/')} class="brand-gradient-text sidebar-label text-lg font-bold">Pablan.</a> + <button + class="cursor-pointer rounded-md p-1.5 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink" + onclick={toggleCollapsed} + aria-label={collapsed ? m.nav_sidebar_expand() : m.nav_sidebar_collapse()} + data-testid="sidebar-toggle" + > + <PanelLeft size={18} /> + </button> + </div> + + <!-- Capturing knowledge is started from the conversation itself, so the + sidebar keeps a single primary action. --> + <a + href={resolve('/chat')} + class="sidebar-row flex items-center gap-2 rounded-full bg-primary px-3 py-2 text-sm font-medium whitespace-nowrap text-primary-fg transition-colors hover:bg-primary-hover" + title={m.nav_new_conversation()} + data-testid="sidebar-new-conversation" + > + <Plus size={16} class="shrink-0" /> + <span class="sidebar-label">{m.nav_new_conversation()}</span> + </a> + + <!-- Stays in the tree while collapsed: it is also the flexible spacer that + pushes the nav to the bottom. No heading and no empty-state text — + an empty sidebar is quieter than one explaining itself. --> + <div class="mt-2 min-h-0 flex-1 overflow-y-auto"> + {#if recent.length > 0} + <ul class="sidebar-label flex flex-col gap-0.5" data-testid="conversation-list"> + {#each recent as conversation (conversation.id)} + <li + class="group flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors {conversation.id === + activeConversationId + ? 'border-border-strong bg-surface-sunken text-ink' + : 'border-transparent text-ink-muted hover:bg-surface-sunken hover:text-ink'}" + > + <a + href={resolve(`/chat/${conversation.id}`)} + class="min-w-0 flex-1 truncate" + data-sveltekit-preload-data="hover" + > + {conversation.title ?? m.nav_conversation_untitled()} + </a> + <button + class="cursor-pointer text-ink-muted opacity-0 transition-opacity group-hover:opacity-100 hover:text-danger" + aria-label={m.nav_conversation_delete()} + onclick={() => (confirmDelete = conversation.id)} + > + <Trash2 size={14} /> + </button> + </li> + {/each} + </ul> + {/if} + </div> + + <nav class="flex flex-col gap-0.5 border-t border-border pt-2"> + <a + href={resolve('/documents')} + class="sidebar-row flex items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-surface-sunken {isActive( + '/documents' + ) + ? 'text-ink' + : 'text-ink-muted'}" + title={m.nav_documents()} + > + <FileText size={16} class="shrink-0" /> + <span class="sidebar-label">{m.nav_documents()}</span> + </a> + <a + href={resolve('/people')} + class="sidebar-row flex items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-surface-sunken {isActive( + '/people' + ) + ? 'text-ink' + : 'text-ink-muted'}" + title={m.nav_people()} + > + <Users size={16} class="shrink-0" /> + <span class="sidebar-label">{m.nav_people()}</span> + </a> + {#if user.role === 'admin'} + <a + href={resolve('/admin')} + class="sidebar-row flex items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-surface-sunken {isActive( + '/admin' + ) + ? 'text-ink' + : 'text-ink-muted'}" + title={m.nav_administration()} + > + <!-- The same shield the settings dialog uses for the admin role: + one mark for "this is the administration side". --> + <Shield size={16} class="shrink-0" /> + <span class="sidebar-label">{m.nav_administration()}</span> + </a> + {/if} + + <!-- A dialog rather than a menu: settings hold forms (password change, + theme, language), which a menu cannot. --> + <button + class="sidebar-row flex w-full cursor-pointer items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap text-ink transition-colors hover:bg-surface-sunken" + onclick={() => (settingsOpen = true)} + aria-label={m.nav_settings()} + title={user.name} + data-testid="user-menu" + > + <UserIcon size={16} class="shrink-0" /> + <span class="sidebar-label truncate">{user.name}</span> + </button> + </nav> +</aside> + +<SettingsDialog bind:open={settingsOpen} {user} /> + +<ConfirmDialog + open={confirmDelete !== null} + title={m.nav_conversation_delete()} + message={m.conversation_delete_confirm()} + onConfirm={() => { + if (confirmDelete) void conversationStore.remove(confirmDelete); + }} + onClose={() => (confirmDelete = null)} +/> diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts new file mode 100644 index 0000000..ae449e1 --- /dev/null +++ b/frontend/src/lib/server/api.ts @@ -0,0 +1,29 @@ +import { env } from '$env/dynamic/private'; +import type { Cookies } from '@sveltejs/kit'; + +export const SESSION_COOKIE = 'pablan_session'; + +/** Server-side calls need an absolute URL: `/api/*` is not a SvelteKit + * route, so a relative fetch is resolved by SvelteKit's own router and + * never reaches the backend. The vite proxy only applies to the browser. + * Natively the backend is on localhost; the customer stack overrides via + * PABLAN_INTERNAL_API_BASE (docker-compose.yml → http://backend:8000). */ +export function internalApiBase(): string { + return env.PABLAN_INTERNAL_API_BASE ?? 'http://localhost:8000'; +} + +/** Fetch from the backend on behalf of the signed-in user. + * + * The session cookie has to be forwarded by hand for the same reason the + * URL has to be absolute: this request does not come from the browser. + */ +export function apiFetch( + fetchFn: typeof globalThis.fetch, + cookies: Cookies, + path: string +): Promise<Response> { + const session = cookies.get(SESSION_COOKIE); + return fetchFn(`${internalApiBase()}${path}`, { + headers: session ? { cookie: `${SESSION_COOKIE}=${session}` } : {} + }); +} diff --git a/frontend/src/lib/theme.svelte.ts b/frontend/src/lib/theme.svelte.ts new file mode 100644 index 0000000..038394a --- /dev/null +++ b/frontend/src/lib/theme.svelte.ts @@ -0,0 +1,45 @@ +// Colour theme: per-device, not per-account. +// +// The same person wants dark on the laptop at night and light on the shared +// terminal in the workshop, so this lives in localStorage next to the sidebar +// collapse state rather than on the user row. `system` follows the OS and +// keeps following it — it is not resolved once and frozen. + +const STORAGE_KEY = 'pablan.theme'; + +export const THEMES = ['system', 'light', 'dark'] as const; +export type Theme = (typeof THEMES)[number]; + +function isTheme(value: unknown): value is Theme { + return typeof value === 'string' && (THEMES as readonly string[]).includes(value); +} + +class ThemeStore { + // Starts at the value the boot script in app.html already applied, so the + // first render agrees with what is on screen. + #choice = $state<Theme>('system'); + + init(): void { + const stored = localStorage.getItem(STORAGE_KEY); + this.#choice = isTheme(stored) ? stored : 'system'; + } + + get choice(): Theme { + return this.#choice; + } + + set(theme: Theme): void { + this.#choice = theme; + if (theme === 'system') { + localStorage.removeItem(STORAGE_KEY); + // Drop the attribute entirely: the stylesheet's own + // prefers-color-scheme default takes over from here. + document.documentElement.removeAttribute('data-theme'); + return; + } + localStorage.setItem(STORAGE_KEY, theme); + document.documentElement.setAttribute('data-theme', theme); + } +} + +export const theme = new ThemeStore(); diff --git a/frontend/src/routes/(app)/+layout.server.ts b/frontend/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..9a359ab --- /dev/null +++ b/frontend/src/routes/(app)/+layout.server.ts @@ -0,0 +1,10 @@ +import { redirect } from '@sveltejs/kit'; + +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => { + if (!locals.user) { + redirect(303, '/login'); + } + return { user: locals.user }; +}; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..fc953cf --- /dev/null +++ b/frontend/src/routes/(app)/+layout.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import Sidebar from '$lib/nav/Sidebar.svelte'; + import { theme } from '$lib/theme.svelte'; + + let { data, children } = $props(); + + // The boot script in app.html already applied the theme before paint; + // this only tells the store which button to mark as selected. + onMount(() => theme.init()); +</script> + +<div class="flex h-screen"> + <Sidebar user={data.user} /> + <main class="flex min-h-0 min-w-0 flex-1 flex-col px-4 py-6"> + {@render children()} + </main> +</div> diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..13f8b2f --- /dev/null +++ b/frontend/src/routes/(app)/+page.svelte @@ -0,0 +1,178 @@ +<script lang="ts"> + import Building2 from '@lucide/svelte/icons/building-2'; + import Check from '@lucide/svelte/icons/check'; + import ClipboardList from '@lucide/svelte/icons/clipboard-list'; + import ArrowUp from '@lucide/svelte/icons/arrow-up'; + import Search from '@lucide/svelte/icons/search'; + import UserPlus from '@lucide/svelte/icons/user-plus'; + import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; + import { api } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; + import Button from '$lib/components/Button.svelte'; + import OpenWork from '$lib/documents/OpenWork.svelte'; + import { m } from '$lib/paraglide/messages'; + + type DocumentStats = components['schemas']['DocumentStats']; + + let { data } = $props(); + + let question = $state(''); + let input = $state<HTMLTextAreaElement | null>(null); + let stats = $state<DocumentStats | null>(null); + + $effect(() => { + void (async () => { + const { data: loaded } = await api.GET('/api/documents/stats'); + if (loaded) stats = loaded; + })(); + }); + + // The box is what this page is for, so the cursor starts in it. + $effect(() => { + input?.focus(); + }); + + const firstName = $derived(data.user.name.split(' ')[0]); + + // The whole vocative is one message, so a language may put the name + // wherever it belongs. The name still needs its own styling, so the + // rendered string is split around it rather than assembled from two + // half-sentences. + const greeting = $derived.by(() => { + const hour = new Date().getHours(); + const name = firstName; + if (hour < 11) return m.landing_greeting_morning({ name }); + if (hour < 18) return m.landing_greeting_afternoon({ name }); + return m.landing_greeting_evening({ name }); + }); + const greetingParts = $derived(greeting.split(firstName)); + + // A fresh install has nothing to work with yet — walk the admin through + // setup instead of showing an empty product. + const showFirstRun = $derived( + data.user.role === 'admin' && + stats !== null && + (stats.departments_total === 0 || stats.documents_total === 0) + ); + + async function ask(event: SubmitEvent) { + event.preventDefault(); + const text = question.trim(); + if (!text) return; + // The path IS resolved; the rule cannot see through the query string, + // which is data rather than part of the route. + // eslint-disable-next-line svelte/no-navigation-without-resolve + await goto(`${resolve('/chat')}?q=${encodeURIComponent(text)}`); + } + + function onKeydown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + (event.currentTarget as HTMLElement).closest('form')?.requestSubmit(); + } + } +</script> + +<svelte:head><title>Pablan + + +
+
+

+ {greetingParts[0]}{firstName}{greetingParts[1] ?? ''} +

+

{m.landing_prompt()}

+
+ +
+ +
+ {m.landing_input_note()} + +
+
+ + + + + + + + {#if showFirstRun && stats} +
+

{m.landing_setup_title()}

+ +
+ {/if} +
diff --git a/frontend/src/routes/(app)/account/profile/+page.svelte b/frontend/src/routes/(app)/account/profile/+page.svelte new file mode 100644 index 0000000..bb6f7b1 --- /dev/null +++ b/frontend/src/routes/(app)/account/profile/+page.svelte @@ -0,0 +1,165 @@ + + +{m.profile_edit_title()} – Pablan + +
+
+

{user?.name ?? m.profile_edit_title()}

+

+ {department ?? m.people_no_department()} · {m.profile_edit_subtitle()} +

+
+ + {#if personal?.document_id} + +

{m.profile_document_title()}

+

{m.profile_document_hint()}

+
+ + {personal.title} + {#if personal.status && personal.status !== 'published'} + {statusLabel(personal.status)} + {/if} +
+
+ + +
+
+ {:else} + +

{m.profile_capture_title()}

+

{m.profile_capture_hint()}

+ {#if error} + + {/if} + +
+ {/if} + + {#if mine.length > 0} + + {/if} + + {#if user} + + + {m.profile_view_public()} + + {/if} +
diff --git a/frontend/src/routes/(app)/admin/+page.server.ts b/frontend/src/routes/(app)/admin/+page.server.ts new file mode 100644 index 0000000..9ce4fa1 --- /dev/null +++ b/frontend/src/routes/(app)/admin/+page.server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals }) => { + if (locals.user?.role !== 'admin') { + redirect(303, '/'); + } +}; diff --git a/frontend/src/routes/(app)/admin/+page.svelte b/frontend/src/routes/(app)/admin/+page.svelte new file mode 100644 index 0000000..364fef1 --- /dev/null +++ b/frontend/src/routes/(app)/admin/+page.svelte @@ -0,0 +1,70 @@ + + +{m.admin_page_title()} – Pablan + +
+
+

{m.admin_page_title()}

+

{m.admin_page_subtitle()}

+
+ + + {#snippet panel(value)} +
+ {#if value === 'people'} + + + {:else if value === 'templates'} + + + + {:else if value === 'llm'} + + + + + {:else if value === 'prompts'} + + + + {/if} +
+ {/snippet} +
+
diff --git a/frontend/src/routes/(app)/chat/+page.svelte b/frontend/src/routes/(app)/chat/+page.svelte new file mode 100644 index 0000000..074af97 --- /dev/null +++ b/frontend/src/routes/(app)/chat/+page.svelte @@ -0,0 +1,16 @@ + + +{m.chat_page_title()} – Pablan + + diff --git a/frontend/src/routes/(app)/chat/[id]/+page.server.ts b/frontend/src/routes/(app)/chat/[id]/+page.server.ts new file mode 100644 index 0000000..93b62a2 --- /dev/null +++ b/frontend/src/routes/(app)/chat/[id]/+page.server.ts @@ -0,0 +1,22 @@ +import { error } from '@sveltejs/kit'; +import { apiFetch } from '$lib/server/api'; +import type { PageServerLoad } from './$types'; + +/** Resolve the conversation server-side so a pasted link renders the right + * thing on first paint instead of flashing an empty composer. + * + * The API is owner-scoped and answers 404 for someone else's conversation, + * so an unknown id and a foreign id are indistinguishable from here — which + * is the point: existence must not leak, same semantics as the documents + * API. + */ +export const load: PageServerLoad = async ({ params, cookies, fetch }) => { + const response = await apiFetch(fetch, cookies, `/api/conversations/${params.id}`); + if (response.status === 404) { + error(404, 'Conversation not found'); + } + if (!response.ok) { + error(response.status, 'Could not load the conversation'); + } + return { conversationId: params.id }; +}; diff --git a/frontend/src/routes/(app)/chat/[id]/+page.svelte b/frontend/src/routes/(app)/chat/[id]/+page.svelte new file mode 100644 index 0000000..f0339c0 --- /dev/null +++ b/frontend/src/routes/(app)/chat/[id]/+page.svelte @@ -0,0 +1,10 @@ + + +{m.chat_page_title()} – Pablan + + diff --git a/frontend/src/routes/(app)/documents/+page.svelte b/frontend/src/routes/(app)/documents/+page.svelte new file mode 100644 index 0000000..2aed488 --- /dev/null +++ b/frontend/src/routes/(app)/documents/+page.svelte @@ -0,0 +1,97 @@ + + +{m.documents_page_title()} – Pablan + + +
+
+

{m.documents_page_title()}

+ + +
+ + + + {#if list.visible.length === 0} +

+ {list.loading ? m.documents_loading() : m.documents_empty()} +

+ {:else} + +
    + {#each list.visible as document (document.id)} +
  • + +
  • + {/each} +
+ {/if} + + {#if !list.searching && list.pages > 1} +
+ + + {m.documents_pager_status({ page: list.page, pages: list.pages, total: list.total })} + + +
+ {/if} +
diff --git a/frontend/src/routes/(app)/documents/[id]/+page.svelte b/frontend/src/routes/(app)/documents/[id]/+page.svelte new file mode 100644 index 0000000..99a11b1 --- /dev/null +++ b/frontend/src/routes/(app)/documents/[id]/+page.svelte @@ -0,0 +1,287 @@ + + +{document?.title ?? m.document_fallback_title()} – Pablan + +{#if notFound && checkedByMe} + +

{m.document_review_thanks_title()}

+

{m.document_review_thanks_body()}

+ + {m.document_back_to_list()} + +
+{:else if notFound} + +

{m.document_not_found_title()}

+

{m.document_not_found_body()}

+ + {m.document_back_to_list()} + +
+{:else if document} +
+
+

+ {document.title} +

+
+ {#if isDraft && isOwner} + + + {/if} + {#if document.can_edit} + + {/if} + {#if menuItems.length > 0} + + {/if} +
+
+ + +
+ {#if document.is_builtin} + + + {m.documents_badge_builtin()} + + {:else} + {#if isDraft} + + + {m.document_draft_chip()} + + {:else if document.status === 'archived'} + + + {m.documents_status_archived()} + + {:else} + + {m.documents_status_published()} + + {/if} + + + + {#if document.access_reason === 'author'} + {m.documents_access_label_author()} + {:else if document.access_reason === 'review' || document.access_reason === 'granted'} + + {accessLabel(document.access_reason)} + + {/if} + + + {m.documents_updated_at({ date: formatDate(document.updated_at) })} + + {/if} +
+ + {#if isDraft} +

+ {document.access_reason === 'author' + ? m.document_draft_hint() + : m.document_draft_hint_reviewer()} +

+ {/if} + + + + + + + + + {#if !document.is_builtin} + + {/if} +
+ + + { + asking = false; + await load(); + }} + /> + + + (confirmDelete = false)} + /> +{/if} diff --git a/frontend/src/routes/(app)/documents/[id]/edit/+page.svelte b/frontend/src/routes/(app)/documents/[id]/edit/+page.svelte new file mode 100644 index 0000000..ae2312c --- /dev/null +++ b/frontend/src/routes/(app)/documents/[id]/edit/+page.svelte @@ -0,0 +1,55 @@ + + +{doc?.title ?? m.document_fallback_title()} – Pablan + +{#if notFound} + +

{m.document_not_found_title()}

+

{m.document_not_found_body()}

+ + {m.document_back_to_list()} + +
+{:else if doc} +
+ {#key doc.id} + + {/key} +
+{/if} diff --git a/frontend/src/routes/(app)/documents/new/+page.svelte b/frontend/src/routes/(app)/documents/new/+page.svelte new file mode 100644 index 0000000..b2df4b6 --- /dev/null +++ b/frontend/src/routes/(app)/documents/new/+page.svelte @@ -0,0 +1,137 @@ + + +{m.capture_title()} – Pablan + +
+
+

{m.capture_title()}

+

{m.capture_subtitle()}

+
+ + {#if error} + + {/if} + + {#if matches.length > 0} +
+

{m.capture_matches_title()}

+
    + {#each matches as match (match.document_id)} +
  • + +
  • + {/each} +
+
+ {/if} + + +
+ {#if matches.length > 0} +

{m.capture_templates_title()}

+ {/if} +
    + {#each templates as template (template.id)} +
  • + +
  • + {/each} +
+
+
diff --git a/frontend/src/routes/(app)/people/+page.svelte b/frontend/src/routes/(app)/people/+page.svelte new file mode 100644 index 0000000..57108ea --- /dev/null +++ b/frontend/src/routes/(app)/people/+page.svelte @@ -0,0 +1,105 @@ + + +{m.people_title()} – Pablan + +
+
+
+

{m.people_title()}

+

{m.people_subtitle()}

+
+ {#if people.length > 6} + + {/if} +
+ + {#if matching.length === 0} +

{m.people_none_found()}

+ {/if} + +
+ {#each byDepartment as [department, members] (department)} +
+

+ {department} + {members.length} +

+ +
+ {/each} +
+
diff --git a/frontend/src/routes/(app)/people/[id]/+page.svelte b/frontend/src/routes/(app)/people/[id]/+page.svelte new file mode 100644 index 0000000..e3267a1 --- /dev/null +++ b/frontend/src/routes/(app)/people/[id]/+page.svelte @@ -0,0 +1,78 @@ + + +{person?.name ?? m.people_title()} – Pablan + +{#if notFound} + +

{m.people_not_found()}

+ + {m.people_back()} + +
+{:else if person} +
+
+ + {initials(person.name)} + +
+

{person.name}

+
+ {person.department ?? m.people_no_department()} + {#if person.role === 'admin'} + {m.people_role_admin()} + {/if} +
+
+ {#if isSelf} + + {/if} +
+
+{/if} diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..0aaf594 --- /dev/null +++ b/frontend/src/routes/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => ({ + locale: (locals.user?.locale ?? null) as 'de' | 'en' | null +}); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..7bbcc6e --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,25 @@ + + + +{@render children()} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..cadccdc --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,6 @@ +import type { LayoutLoad } from './$types'; + +/** The account's language preference has to reach the client before the + * first message renders, so it travels with the layout data rather than + * being fetched. `null` means "follow the browser". */ +export const load: LayoutLoad = async ({ data }) => data ?? { locale: null }; diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 0000000..950dcc9 --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals }) => { + if (locals.user) { + redirect(303, '/'); + } +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..ddc3fef --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,79 @@ + + +{m.login_page_title()} – Pablan + +
+ +
+

Pablan.

+

{m.login_subtitle()}

+
+ +
+ + + + + + + {#if error} + + {/if} + +
+
+
diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..fef095e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,43 @@ +import { paraglideVitePlugin } from '@inlang/paraglide-js'; +import tailwindcss from '@tailwindcss/vite'; +import adapter from '@sveltejs/adapter-node'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + paraglideVitePlugin({ + project: './project.inlang', + outdir: './src/lib/paraglide', + // Order is the precedence. The account setting wins, because a + // language a person chose should follow them to every device; the + // cookie keeps that decision available before /me has answered; + // the browser's Accept-Language is the first-contact guess; the + // base locale is the floor. No `url` strategy: Pablan is one + // installation for one company, so /de/ path prefixes would buy + // nothing and break every existing link. + strategy: ['custom-userPreference', 'cookie', 'preferredLanguage', 'baseLocale'], + // A missing translation must fail the build, never fall back + // silently to another language (see scripts/check-messages.py). + experimentalMiddlewareLocaleSplitting: false + }), + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + + adapter: adapter() + }) + ], + + server: { + // Dev-only same-origin setup: /api goes to the native FastAPI process. + // In production the reverse proxy does this (see deploy/). + proxy: { + '/api': 'http://localhost:8000' + } + } +}); diff --git a/help/administration.de.md b/help/administration.de.md new file mode 100644 index 0000000..7af8959 --- /dev/null +++ b/help/administration.de.md @@ -0,0 +1,61 @@ +--- +key: administration +title: "Administration: Abteilungen, Benutzer und Endpunkte" +--- + +# Administration: Abteilungen, Benutzer und Endpunkte + +Der Bereich **Administration** ist nur für Benutzer mit der Rolle +Administrator sichtbar. Er ist in vier Reiter geteilt: Benutzer und +Abteilungen, Dokumentationsvorlagen, Sprachmodell-Endpunkte und +System-Prompts. + +## Abteilungen + +Abteilungen sind die Grundlage der Berechtigungen: Sie entscheiden, wer +Dokumente mit der Sichtbarkeit "Abteilung" sieht und an wen eingeschränkte +Dokumente freigegeben werden können. Lege sie an, bevor du Benutzer einlädst +— jeder Benutzer gehört zu genau einer Abteilung. + +Eine Abteilung zu löschen löscht weder ihre Benutzer noch ihre Dokumente; +beide bleiben ohne Abteilung zurück. + +## Benutzer + +Beim Anlegen vergibst du E-Mail, Name, Abteilung, Rolle und ein +Startkennwort. Setzt du später ein neues Kennwort, werden alle Sitzungen +dieses Benutzers sofort beendet. + +Administratoren können sich nicht selbst löschen und sich nicht selbst die +Administratorrolle entziehen — sonst könnte sich die letzte Administratorin +versehentlich aussperren. + +## Vorlagen + +Vorlagen bestimmen, welche Dokumentationsarten unter "Wissen festhalten" +angeboten werden. Jede Vorlage ist ein Markdown-Gerüst aus Überschriften, das +der Editor beim Schreiben vorgibt, zusammen mit Hinweisen, wie das Modell die +einzelnen Abschnitte ausformuliert. + +Du bearbeitest eine Vorlage in einem Formular: Schreibstil fürs Modell, dann +Abschnitt für Abschnitt eine Überschrift mit einem Hinweis, was in diesen +Abschnitt gehört. Abschnitte lassen sich hinzufügen, entfernen und in der +Reihenfolge verschieben; das Gerüst, mit dem der Editor öffnet, ergibt sich aus +den Überschriften. Dazu kommen Titelvorschlag, Sprache und Sichtbarkeit nach +dem Veröffentlichen. Über **Neue Vorlage** legst du eine von Grund auf an, +oder du fügst eine aus dem Katalog hinzu und passt sie an. Jede Vorlage ist +bearbeitbar; wer lieber direkt am Text arbeitet, blendet über **YAML anzeigen** +die Rohfassung ein. + +## Sprachmodell-Endpunkte + +Pablan spricht mit drei Modellrollen, die getrennt konfigurierbar sind: + +- **chat** — führt die Gespräche und formuliert Antworten. +- **utility** — erledigt strukturierte Kleinarbeit im Hintergrund. +- **embedding** — erzeugt die Vektoren für die Suche. + +Über die Testfunktion prüfst du alle drei Endpunkte, bevor du speicherst. +Wird die Rolle **embedding** auf ein anderes Modell umgestellt, müssen alle +Dokumente neu indexiert werden — sonst passen alte und neue Vektoren nicht +zusammen. diff --git a/help/dokumente-und-sichtbarkeit.de.md b/help/dokumente-und-sichtbarkeit.de.md new file mode 100644 index 0000000..e729eff --- /dev/null +++ b/help/dokumente-und-sichtbarkeit.de.md @@ -0,0 +1,73 @@ +--- +key: dokumente-und-sichtbarkeit +title: "Dokumente, Sichtbarkeit und Prüfung" +--- + +# Dokumente, Sichtbarkeit und Prüfung + +Jedes Dokument ist Markdown und gehört einer Autorin oder einem Autor. Wer es +sehen darf, entscheidet die Sichtbarkeit. + +## Sichtbarkeitsstufen + +- **Öffentlich** — alle im Unternehmen. +- **Abteilung** — nur die Abteilung, zu der das Dokument gehört. +- **Eingeschränkt** — nur ausdrücklich freigegebene Abteilungen. + +Die Sichtbarkeit gilt überall gleich: in der Liste, in der Suche und als +Quelle einer Chat-Antwort. Ein Dokument, das du nicht öffnen darfst, kann +dir auch nicht als Beleg untergeschoben werden. + +Eingestellt wird sie in der Dokumentansicht unter **Sichtbar für**, direkt +neben dem Teilen mit weiteren Abteilungen — beides gehört der Autorin des +Dokuments (und Administratoren). + +## Status eines Dokuments + +- **Entwurf** — in Arbeit, nur für die Autorin sichtbar und in keiner Suche. +- **Veröffentlicht** — durchsuchbar im Rahmen der Sichtbarkeit. +- **Archiviert** — aus der Suche genommen, aber nicht gelöscht. + +Veröffentlichen ist die Entscheidung der Autorin und ein Klick — niemand muss +etwas genehmigen. Der Status sagt, wo ein Dokument steht, nicht ob sein Inhalt +stimmt. Dafür gibt es die Prüfung. + +## Um Prüfung bitten + +Wenn du etwas aufgeschrieben hast, dir bei einem Punkt aber nicht sicher bist, +bitte jemanden um Prüfung: Du wählst eine Person aus, die das Dokument lesen +darf, und schreibst dazu, worum es geht — zum Beispiel "Stimmt das so mit den +14 Urlaubstagen?". + +- Die Frage kann an einem Entwurf hängen oder an einem längst + veröffentlichten Dokument. Beides kommt vor, und beides ist erlaubt. +- Solange die Frage offen ist, ist das Dokument überall als ungeprüft + markiert: in der Liste, auf der Dokumentenseite und bei den Quellen unter + einer Chat-Antwort. Wer es liest, weiß also, dass hier noch etwas offen ist. +- Wer gefragt wurde, darf das Dokument bis zur Antwort auch **bearbeiten** — + wer einen falschen Wert entdeckt, soll ihn korrigieren, statt eine zweite + Frage zu stellen. Titel und Text also, nicht die Sichtbarkeit und nicht das + Veröffentlichen: das bleibt bei der Autorin. +- Ist der geprüfte Text noch ein Entwurf, endet mit deiner Antwort auch dein + Zugriff darauf — er gehört wieder der Person, die ihn schreibt. +- Beantwortet wird mit **Stimmt so**. Danach verschwindet die Markierung, und + im Verlauf bleibt stehen, wer wann geprüft hat. Ist eine Frage + gegenstandslos geworden, kann die Autorin sie schließen. + +Offene Fragen an dich findest du auf der Startseite und über den Filter +"Zur Prüfung bei mir" in der Dokumentenliste. + +## Wer darf ändern + +Ändern dürfen die Autorin des Dokuments, Administratoren und wer gerade um +eine Prüfung gebeten wurde. Löschen und Teilen bleibt bei der Autorin. Bei +allen anderen Dokumenten fehlen die entsprechenden Schaltflächen. In der Liste +und in der Detailansicht ist außerdem vermerkt, warum du ein Dokument siehst — +weil es öffentlich ist, deiner Abteilung gehört, dir freigegeben wurde oder von +dir stammt. + +## Diese Hilfeseiten + +Die Hilfedokumente zu Pablan selbst gehören zum Produkt. Sie sind für alle +sichtbar, lassen sich aber nicht bearbeiten oder löschen — sie werden mit +jeder neuen Version aktualisiert. diff --git a/help/fragen-und-antworten.de.md b/help/fragen-und-antworten.de.md new file mode 100644 index 0000000..0d5eece --- /dev/null +++ b/help/fragen-und-antworten.de.md @@ -0,0 +1,70 @@ +--- +key: fragen-und-antworten +title: "Fragen stellen und Antworten mit Quellen" +--- + +# Fragen stellen und Antworten mit Quellen + +Stelle Fragen so, wie du sie einer Kollegin stellen würdest — ganze Sätze, +kein Suchmaschinen-Stichwort. Pablan sucht gleichzeitig nach Bedeutung und +nach exakten Begriffen, findet also auch dann etwas, wenn im Dokument andere +Wörter stehen als in deiner Frage. + +## Was während der Suche passiert + +Über der entstehenden Antwort läuft eine kurze Statuszeile mit: erst +"durchsuche die Wissensbasis", dann die Zahl der gefundenen Stellen, dann +"schreibe Antwort". Sie verschwindet, sobald die Antwort steht. + +## Quellen prüfen + +Unter jeder Antwort stehen die Dokumente, aus denen sie stammt. + +- **Mit der Maus darüber**: zeigt die zitierte Textstelle samt Abschnitt. +- **Klick**: öffnet das Dokument neben dem Gespräch, ohne dass du die + Unterhaltung verlässt. +- **Warnzeichen an einer Quelle**: Zu diesem Dokument ist eine Prüfung offen — + jemand hat gefragt, ob der Inhalt noch stimmt, und es steht noch keine + Antwort. Die Aussage kann trotzdem richtig sein, ist aber unbestätigt. Ein + Klick zeigt dir den Hinweis auch im Dokument selbst. + +Nimm die Quellen ernst: Sie sind der Unterschied zwischen einer belegten und +einer geratenen Antwort. Bei wichtigen Entscheidungen lohnt der Blick in das +Originaldokument. + +## Wenn nichts dokumentiert ist + +Findet die Suche nichts Belastbares, sagt Pablan das offen, statt eine Antwort +zu erfinden. Unter der Antwort erscheint dann das Angebot, das fehlende Wissen +selbst festzuhalten — es führt dich zu "Wissen festhalten", wo du eine +Dokumentationsart wählst und das Dokument im Schreib-Editor verfasst. + +Genau daraus wächst die Wissensbasis: Jede unbeantwortete Frage ist ein +Hinweis darauf, was noch fehlt. + +## Wenn viele gleichzeitig fragen + +Ein Sprachmodell beantwortet nur eine begrenzte Zahl von Fragen gleichzeitig. +Fragen mehrere Kolleg:innen im selben Moment, wartet deine Frage kurz in der +Reihe; über der Antwort steht dann, dass das Modell gerade ausgelastet ist. +Sobald ein Platz frei wird, läuft deine Frage ganz normal weiter. Nur wenn +sehr viel gleichzeitig ankommt, bekommst du stattdessen sofort den Hinweis, +es später noch einmal zu versuchen. + +## Wenn gerade kein Sprachmodell läuft + +Ist das Sprachmodell nicht erreichbar oder ausgelastet, bleibt die Wissensbasis +trotzdem benutzbar: Pablan sucht dann klassisch im Volltext, also nach den +Wörtern, die du geschrieben hast, und zeigt dir die Fundstellen als Liste. Ein +Hinweis über der Liste sagt dir, dass kein Modell verwendet wurde. + +Die Treffer öffnest du wie eine Quelle mit einem Klick neben dem Gespräch. Es +gibt in diesem Fall keine formulierte Antwort und keine Bedeutungssuche: Was +nicht wörtlich im Dokument steht, wird auch nicht gefunden. Sobald das Modell +wieder läuft, beantwortet dieselbe Frage sich wieder wie gewohnt. + +## Sprache + +Antworte Pablan in der Sprache, die dir liegt. Die Antwort kommt in derselben +Sprache — auch wenn die zugrunde liegenden Dokumente in einer anderen Sprache +verfasst sind. diff --git a/help/kolleginnen-und-profil.de.md b/help/kolleginnen-und-profil.de.md new file mode 100644 index 0000000..bba35a0 --- /dev/null +++ b/help/kolleginnen-und-profil.de.md @@ -0,0 +1,43 @@ +--- +key: kolleginnen-und-profil +title: "Kolleg:innen und dein Profil" +--- + +# Kolleg:innen und dein Profil + +Unter **Kolleg:innen** findest du ein Verzeichnis aller Personen im +Unternehmen mit Name und Abteilung. Was jemand weiß, steht nicht in einem +Steckbrief, sondern in dem Dokument, das die Person über sich geschrieben hat +— such einfach nach dem Thema, dann findest du beides: das Wissen und die +Person dahinter. + +## Das Verzeichnis + +Der Eintrag **Kolleg:innen** in der Seitenleiste öffnet die Übersicht. Jede +Karte zeigt Name und Abteilung. Ein Klick auf eine Karte öffnet das Profil. + +Das Verzeichnis ist für alle angemeldeten Personen sichtbar. E-Mail-Adressen +und Passwörter werden nie angezeigt. + +## Dein eigenes Profil + +Dein Profil erreichst du über **Einstellungen → Profil bearbeiten** oder über +die Schaltfläche **Profil bearbeiten** auf deiner eigenen Profilseite. + +Dort steht genau eine Sache: **dein Dokument über dich**. Hast du noch keins, +legst du es mit **Dokument über dich anlegen** an; existiert es schon, siehst du +seinen Titel und Status und kommst mit **Bearbeiten** direkt in den +Schreib-Editor. + +### Was in diesem Dokument steht + +Die Vorlage fragt drei Dinge: was du machst, wobei man dich fragen kann und +womit du arbeitest. Die Überschriften gibt sie vor, du füllst sie in deinen +Worten aus, mit derselben Schreibhilfe wie bei jedem anderen Dokument. Es ist +ausdrücklich dein Dokument — niemand füllt es für dich aus. + +Es ist ein Dokument wie jedes andere: Es startet als Entwurf, den zunächst nur +du siehst, und sobald du es veröffentlichst, findet die Suche es. Genau +deshalb gibt es keinen zusätzlichen Steckbrief im Profil: Was du über dich +schreibst, soll auffindbar sein, eine Versionsgeschichte haben und wie jedes +andere Wissen im Unternehmen behandelt werden. diff --git a/help/pablan-ueberblick.de.md b/help/pablan-ueberblick.de.md new file mode 100644 index 0000000..a147f41 --- /dev/null +++ b/help/pablan-ueberblick.de.md @@ -0,0 +1,53 @@ +--- +key: pablan-ueberblick +title: "Pablan: Überblick und Grundprinzipien" +--- + +# Pablan: Überblick und Grundprinzipien + +## Was ist das hier? + +Diese Anwendung heißt **Pablan**. Wenn du wissen willst, wie *dieses Tool*, +*diese App*, *diese Software*, *das Programm hier* oder schlicht *das hier* +funktioniert — oder auf Englisch *this app*, *this tool*, *this software* — +dann geht es um Pablan, und die Antwort steht auf dieser Seite. Du benutzt +Pablan gerade: das Chat-Fenster, die Dokumentenliste und die Suche gehören +alle dazu. + +Pablan sammelt das Wissen, das sonst nur in Köpfen steckt, und macht es +durchsuchbar. Alles läuft auf der Infrastruktur des Unternehmens — Dokumente, +Suche und Sprachmodell-Anbindung verlassen das Haus nicht. + +## Die zwei Arbeitsweisen + +**Fragen stellen.** Du fragst in normaler Sprache, Pablan durchsucht die +Wissensbasis und antwortet mit Belegen. Jede Antwort zeigt, aus welchen +Dokumenten sie stammt. + +**Wissen festhalten.** Du schreibst das Dokument selbst in einem geteilten +Editor, und Pablan formuliert jeden Abschnitt mit dir aus. Das ist der Weg, auf +dem neues Wissen in die Wissensbasis kommt. + +Fragen stellst du im Chat-Fenster; zum Festhalten öffnet "Wissen festhalten" +einen eigenen Schreib-Editor. + +## Was Pablan von einem normalen Chatbot unterscheidet + +- **Antworten sind belegt.** Pablan erfindet nichts dazu: Was nicht in der + Wissensbasis steht, wird als Lücke gemeldet statt geraten. +- **Berechtigungen greifen vor dem Sprachmodell.** Du bekommst nur Inhalte + zu sehen, die du auch als Dokument öffnen dürftest. Ein gesperrtes Dokument + kann nicht als Quelle auftauchen. +- **Markdown ist die Wahrheit.** Jedes Dokument ist ein normales + Markdown-Dokument, das du lesen, bearbeiten und exportieren kannst. Die + Suchindizes daneben sind jederzeit neu berechenbar. +- **Nichts wird ohne dich veröffentlicht.** Du verfasst zuerst einen Entwurf, + den nur du siehst; veröffentlicht wird er, wenn du es sagst. + +## Wo finde ich was + +- **Neues Gespräch** startet eine Unterhaltung. +- **Wissen festhalten** öffnet die Liste der Dokumentationsarten. +- **Dokumente** listet alles, was du sehen darfst, mit Suche und Filtern. +- **Administration** (nur für Administratoren) verwaltet Abteilungen, + Benutzer, Vorlagen und die Sprachmodell-Endpunkte. diff --git a/help/wissen-festhalten.de.md b/help/wissen-festhalten.de.md new file mode 100644 index 0000000..371ce24 --- /dev/null +++ b/help/wissen-festhalten.de.md @@ -0,0 +1,110 @@ +--- +key: wissen-festhalten +title: "Wissen festhalten: schreiben mit Assistenz" +--- + +# Wissen festhalten: schreiben mit Assistenz + +Wissen festzuhalten heißt in Pablan: Du schreibst das Dokument selbst, und das +Modell hilft dir beim Formulieren. Du musst nicht wissen, wie man sauber +gliedert — die Dokumentationsart gibt dir ein Gerüst aus Überschriften vor, und +du füllst es in deinen eigenen Worten. + +## Ablauf + +1. **Dokumentationsart wählen.** Über "Wissen festhalten" erscheint die Liste + der verfügbaren Arten. Jede Art ist ein Markdown-Gerüst mit den + Überschriften, die zu dieser Art gehören — und die Überschriften sind + genau die Fragen, die eine Kollegin dir stellen würde: + + - **Ablauf: wie wir das machen** — wann das gilt, Schritt für Schritt, wenn + es klemmt, wer zuständig ist. + - **Störung: was los war und was geholfen hat** — für die Nachbereitung, + solange es frisch ist. + - **Über dich: Rolle und Ansprechbarkeit** — dein eigenes Dokument, siehe + "Kolleg:innen und dein Profil". + - **Notiz: einfach aufschreiben** — bewusst ohne Gerüst, wenn du schon + weißt, was du sagen willst. + + Je nach Unternehmen kommen weitere Arten dazu; das legt die Administration + fest. Startest du das Festhalten aus einem Chat heraus, zeigt Pablan dir + zusätzlich passende bestehende Dokumente an — so kannst du ein vorhandenes + Dokument erweitern, statt neu anzufangen. +2. **Schreiben.** Es öffnet sich ein geteilter Editor. Links schreibst du das + Dokument — direkt in Markdown, entlang der vorgegebenen Überschriften. Du + musst nichts einleiten und in keiner bestimmten Reihenfolge vorgehen. +3. **Vorschlag übernehmen.** Machst du beim Tippen eine kurze Pause, schlägt + Pablan rechts eine ausformulierte Fassung des Abschnitts vor, an dem du + gerade schreibst — aus deinen Stichworten wird reifer Text, ohne dass Fakten + dazukommen. **Übernehmen** ersetzt den Abschnitt links durch den Vorschlag. + Danach schreibst du weiter; sobald genug neuer Text steht, kommt der nächste + Vorschlag. +4. **Speichern.** Unten rechts sitzt **Speichern**. Bevor gespeichert wird, + zeigt Pablan dir eine übersichtliche Zeilen-Ansicht — wie in einem + Code-Editor: was hinzugekommen, was geändert und was entfernt wurde. So + siehst du genau, was du seit dem letzten Stand geändert hast, und kannst + immer noch abbrechen. Darüber steht der **Titel** zum Ändern — hier fällt + einem auf, wenn das Dokument noch den Namen der Vorlage trägt. Über das + Sternchen daneben schlägt Pablan dir einen Titel aus dem Inhalt vor. Das + gilt genauso, wenn du ein bestehendes Dokument überarbeitest. +5. **Veröffentlichen.** Solange du nicht veröffentlichst, gehört der Entwurf nur + dir. Veröffentlichen ist deine eigene Entscheidung und ein Klick — im + Speichern-Dialog als **Speichern und veröffentlichen**, auf der + Dokumentenseite oder direkt von der Startseite aus, die dir deine nicht + veröffentlichten Entwürfe anzeigt. Danach bestätigt dir Pablan, dass dein + Wissen festgehalten ist, und bietet dir an, es prüfen zu lassen. +6. **Prüfen lassen, wenn du unsicher bist.** Du kannst jederzeit eine Kollegin + oder einen Kollegen bitten, das Dokument anzusehen, und dazuschreiben, worum + es geht — zum Beispiel "Stimmt das so mit den 14 Urlaubstagen?". Mehr dazu + unter "Dokumente, Sichtbarkeit und Prüfung". + +## Der Vorschlag rechts + +Der Vorschlag betrifft immer nur den Abschnitt, an dem du gerade schreibst — +nicht das ganze Dokument. Das hält lange Dokumente übersichtlich und die +Vorschläge schnell. + +- Der Vorschlag erscheint, während du eine Pause machst, und baut sich Wort für + Wort auf. Tippst du weiter, bricht er ab — du unterbrichst also nichts. +- **Übernehmen** überschreibt genau diesen einen Abschnitt links. Was du an + anderer Stelle geschrieben hast, bleibt unangetastet. +- Nach dem Übernehmen kommt der nächste Vorschlag erst, wenn du wieder genug + Neues geschrieben hast. So drängt sich die Assistenz nicht auf. + +Du entscheidest jederzeit, ob du einen Vorschlag übernimmst oder bei deiner +eigenen Formulierung bleibst. Das Dokument links ist immer die Wahrheit — der +Vorschlag rechts ist nur ein Angebot. + +## Wenn keine Art passt + +Dann nimm **Notiz: einfach aufschreiben**. Die hat bewusst gar kein Gerüst: +Du fängst mit einem leeren Dokument an, schreibst in deinen Worten, und Pablan +formuliert Abschnitt für Abschnitt mit. Überschriften setzt du selbst, wo du +sie brauchst. + +## Was mit dem Entwurf passiert + +Unter dem Editor steht, woran du bist: ein neuer Entwurf, der beim Verlassen +verworfen wird, solange du nichts geschrieben hast — ein Entwurf, den nur du +siehst — oder die Uhrzeit des letzten Speicherns. + +Läuft gerade kein Sprachmodell, sagt Pablan das einmal und stellt die +Vorschläge still, statt es alle paar Sekunden erneut zu versuchen. Schreiben +kannst du normal weiter; über **Jetzt erneut versuchen** holst du die +Vorschläge zurück, sobald der Endpunkt wieder läuft. + +Im Editor änderst du Titel und Text. **Wer das Dokument sehen darf**, stellst +du in der Dokumentansicht ein — dort, wo du es auch mit weiteren Abteilungen +teilst. Das ist die Entscheidung der Autorin: Wer nur um eine Prüfung gebeten +wurde, darf den Text korrigieren, aber nicht die Sichtbarkeit ändern. + +Was du schreibst, ist von Anfang an das Dokument, nicht eine Vorschau davon. Es +gehört zunächst nur dir und taucht in keiner Suche auf — auch dann nicht, wenn +du es tagelang liegen lässt. Erst wenn du veröffentlichst, wird es im Rahmen +der eingestellten Sichtbarkeit gefunden. Vorher wie nachher kannst du Titel, +Text und Sichtbarkeit frei ändern. + +Verlässt du den Editor mit ungespeicherten Änderungen, sichert Pablan sie im +Entwurf, damit nichts verloren geht. Ein Gerüst, in das du nie etwas +geschrieben hast, wird stattdessen verworfen — so sammeln sich keine leeren +Entwürfe an. diff --git a/templates/anlage.de.yaml b/templates/anlage.de.yaml new file mode 100644 index 0000000..655f01c --- /dev/null +++ b/templates/anlage.de.yaml @@ -0,0 +1,59 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# Not in the starter set: a company with machines adds it, an office does +# not. The headings follow how a machine is actually handed over — what it +# does, how you run it, what it needs, and what it does that only the person +# who works with it every day knows. +id: anlage +name: "Maschine oder Anlage: Betrieb und Eigenheiten" +version: "1.0" +kind: authoring +locale: de +description: > + Bedienung, Wartung und die Eigenheiten, die nur jemand kennt, der täglich + damit arbeitet. + +model: + temperature: 0.35 + min_class_hint: "12b" + +persona: | + Du bist ein präziser technischer Redakteur. Du hältst fest, wie eine + Maschine betrieben und gewartet wird — sachlich, in vollständigen Sätzen, + Abläufe als nummerierte Liste. Typenbezeichnungen, Intervalle, Messwerte + und Fehlercodes übernimmst du exakt und erfindest keine dazu. Keine + Floskeln, keine Emojis. + +title_template: "Neue Anlage" + +skeleton: | + ## Was die Anlage macht + + ## Bedienung im Alltag + + ## Wartung und Intervalle + + ## Eigenheiten und Fehlerbilder + +sections: + - heading: "Was die Anlage macht" + hint: > + Typ und Aufgabe der Maschine, wo sie steht und in welchen Ablauf sie + gehört. + - heading: "Bedienung im Alltag" + hint: > + Anfahren, Rüsten, Abschalten — die Handgriffe, die jede Schicht + braucht, in ihrer Reihenfolge. + - heading: "Wartung und Intervalle" + hint: > + Was in welchem Abstand zu tun ist, mit Schmierstoffen, Ersatzteilen + und Prüfpunkten. + - heading: "Eigenheiten und Fehlerbilder" + hint: > + Was diese Maschine anders macht als das Handbuch sagt: bekannte + Fehlerbilder, Tricks, Dinge, die man nicht tun darf. + +metadata: + visibility: department diff --git a/templates/anlage.en.yaml b/templates/anlage.en.yaml new file mode 100644 index 0000000..c354f9f --- /dev/null +++ b/templates/anlage.en.yaml @@ -0,0 +1,54 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `anlage` (same id, locale: en). +# +# Not in the starter set: a company with machines adds it, an office does not. +id: anlage +name: "Machine or plant: running it and its quirks" +version: "1.0" +kind: authoring +locale: en +description: > + Operation, maintenance, and the quirks only somebody who works with it + daily knows. + +model: + temperature: 0.35 + min_class_hint: "12b" + +persona: | + You are a precise technical writer. You record how a machine is operated + and maintained — plainly, in complete sentences, procedures as a numbered + list. Type designations, intervals, readings and error codes stay exactly + as given, and you invent none. No filler, no emoji. + +title_template: "New machine" + +skeleton: | + ## What it does + + ## Running it day to day + + ## Maintenance and intervals + + ## Quirks and known faults + +sections: + - heading: "What it does" + hint: > + Type and purpose of the machine, where it stands and which process it + belongs to. + - heading: "Running it day to day" + hint: > + Starting, setting up, shutting down — the handling every shift needs, + in order. + - heading: "Maintenance and intervals" + hint: > + What has to be done how often, with lubricants, spare parts and check + points. + - heading: "Quirks and known faults" + hint: > + Where this machine differs from its manual: known faults, tricks, + things that must not be done. + +metadata: + visibility: department diff --git a/templates/entscheidung.de.yaml b/templates/entscheidung.de.yaml new file mode 100644 index 0000000..0b7a277 --- /dev/null +++ b/templates/entscheidung.de.yaml @@ -0,0 +1,51 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# Not in the starter set. The point of writing a decision down is the +# REASON: a year later everyone remembers the outcome and nobody remembers +# why, and then it gets re-litigated. +id: entscheidung +name: "Entscheidung: was gilt und warum" +version: "1.0" +kind: authoring +locale: de +description: > + Festhalten, was entschieden wurde, aus welchen Gründen und was daraus + folgt. + +model: + temperature: 0.35 + min_class_hint: "12b" + +persona: | + Du bist ein präziser Fachredakteur. Du hältst eine getroffene Entscheidung + fest: das Ergebnis, die Gründe und die Folgen — sachlich, in vollständigen + Sätzen, ohne die Entscheidung zu bewerten. Zahlen, Termine, Namen und + Alternativen behältst du exakt bei. Keine Floskeln, keine Emojis. + +title_template: "Entscheidung vom {{date}}" + +skeleton: | + ## Was entschieden wurde + + ## Warum + + ## Was daraus folgt + +sections: + - heading: "Was entschieden wurde" + hint: > + Die Entscheidung in einem Satz, dazu wer sie getroffen hat und wann + sie gilt. + - heading: "Warum" + hint: > + Die ausschlaggebenden Gründe — und welche Alternativen verworfen + wurden, damit sie nicht wieder aufgemacht werden. + - heading: "Was daraus folgt" + hint: > + Was sich dadurch ändert: für welche Abläufe, ab wann, und wer davon + betroffen ist. + +metadata: + visibility: department diff --git a/templates/entscheidung.en.yaml b/templates/entscheidung.en.yaml new file mode 100644 index 0000000..2e40dc1 --- /dev/null +++ b/templates/entscheidung.en.yaml @@ -0,0 +1,46 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `entscheidung` (same id, locale: en). +# +# Not in the starter set. The point of writing a decision down is the REASON: +# a year later everyone remembers the outcome and nobody remembers why. +id: entscheidung +name: "Decision: what stands and why" +version: "1.0" +kind: authoring +locale: en +description: > + Record what was decided, on what grounds, and what follows from it. + +model: + temperature: 0.35 + min_class_hint: "12b" + +persona: | + You are a precise technical editor. You record a decision that has been + made: the outcome, the reasons and the consequences — plainly, in complete + sentences, without judging it. Numbers, dates, names and alternatives stay + exactly as given. No filler, no emoji. + +title_template: "Decision on {{date}}" + +skeleton: | + ## What was decided + + ## Why + + ## What follows from it + +sections: + - heading: "What was decided" + hint: > + The decision in one sentence, who made it, and from when it applies. + - heading: "Why" + hint: > + The reasons that settled it — and which alternatives were dropped, so + they do not get reopened. + - heading: "What follows from it" + hint: > + What changes: for which processes, from when, and who it affects. + +metadata: + visibility: department diff --git a/templates/notiz.de.yaml b/templates/notiz.de.yaml new file mode 100644 index 0000000..8a085f1 --- /dev/null +++ b/templates/notiz.de.yaml @@ -0,0 +1,37 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# The open one, and deliberately EMPTY: three generic headings ("Worum es +# geht / Details / Was andere wissen müssen") are what a blank page looks +# like when it is trying to be helpful, and nobody writes a document that +# way. Someone reaching for this already knows what they want to say, so the +# editor gets out of the way and the assistant matures whatever they write. +id: notiz +name: "Notiz: einfach aufschreiben" +version: "1.0" +kind: authoring +locale: de +description: > + Ohne Vorgaben schreiben. Die Gliederung ergibt sich aus dem, was du sagst. + +model: + temperature: 0.5 # a touch more freedom than the structured templates + min_class_hint: "12b" + +persona: | + Du bist ein präziser Fachredakteur, der Wissen aus dem Arbeitsalltag + festhält. Du formulierst sachlich und klar, in vollständigen Sätzen, und + behältst Zahlen, Namen und Beispiele exakt bei. Wo etwas eine Reihenfolge + hat, machst du eine Liste daraus. Keine Floskeln, keine Emojis, keine + Begeisterungsbekundungen. + +title_template: "Notiz vom {{date}}" + +# No skeleton on purpose — see the note above. +skeleton: "" + +sections: [] + +metadata: + visibility: department diff --git a/templates/notiz.en.yaml b/templates/notiz.en.yaml new file mode 100644 index 0000000..10ba997 --- /dev/null +++ b/templates/notiz.en.yaml @@ -0,0 +1,34 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `notiz` (same id, locale: en). +# +# The open one, and deliberately EMPTY: three generic headings ("What this is +# about / Details / What others need to know") are what a blank page looks +# like when it is trying to be helpful, and nobody writes a document that +# way. +id: notiz +name: "Note: just write it down" +version: "1.0" +kind: authoring +locale: en +description: > + Write without a structure. The shape follows from what you have to say. + +model: + temperature: 0.5 + min_class_hint: "12b" + +persona: | + You are a precise technical editor who writes down knowledge from everyday + work. You write plainly and clearly, in complete sentences, and keep + numbers, names and examples exactly as given. Where something has an + order, you make it a list. No filler, no emoji, no enthusiasm. + +title_template: "Note from {{date}}" + +# No skeleton on purpose — see the note above. +skeleton: "" + +sections: [] + +metadata: + visibility: department diff --git a/templates/person.de.yaml b/templates/person.de.yaml new file mode 100644 index 0000000..daadc22 --- /dev/null +++ b/templates/person.de.yaml @@ -0,0 +1,52 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# The document a person writes about themselves. It replaced an "onboarding" +# blueprint somebody else filled in FOR the new colleague: nobody does that, +# and the person themselves knows the answers. The profile page starts this +# one (api/account.py PERSONAL_BLUEPRINT). +id: person +name: "Über dich: Rolle und Ansprechbarkeit" +version: "1.0" +kind: authoring +locale: de +description: > + Wofür du zuständig bist und wobei Kolleg:innen dich fragen können. + +model: + temperature: 0.4 + min_class_hint: "12b" + +persona: | + Du bist ein präziser Fachredakteur. Du hältst fest, was eine Person im + Unternehmen macht und wobei man sie fragen kann — sachlich, in der ersten + Person, in vollständigen Sätzen. Namen, Systeme und Zuständigkeiten + behältst du exakt bei. Keine Floskeln, keine Selbstlobformeln, keine + Emojis. + +title_template: "{{user.name}}" + +skeleton: | + ## Was ich mache + + ## Wobei ihr mich fragen könnt + + ## Womit ich arbeite + +sections: + - heading: "Was ich mache" + hint: > + Die eigene Rolle in zwei, drei Sätzen: wofür man zuständig ist und was + im Alltag tatsächlich anfällt. + - heading: "Wobei ihr mich fragen könnt" + hint: > + Die Themen, bei denen man diese Person anspricht — und, wenn es das + gibt, wofür jemand anderes zuständig ist. + - heading: "Womit ich arbeite" + hint: > + Die Systeme, Maschinen oder Werkzeuge, in denen die Person zu Hause + ist. + +metadata: + visibility: public diff --git a/templates/person.en.yaml b/templates/person.en.yaml new file mode 100644 index 0000000..5a574a1 --- /dev/null +++ b/templates/person.en.yaml @@ -0,0 +1,47 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `person` (same id, locale: en). +# +# The document a person writes about themselves; the profile page starts this +# one (api/account.py PERSONAL_BLUEPRINT). +id: person +name: "About you: role and what to ask you" +version: "1.0" +kind: authoring +locale: en +description: > + What you are responsible for, and what colleagues can come to you with. + +model: + temperature: 0.4 + min_class_hint: "12b" + +persona: | + You are a precise technical editor. You write down what a person does in + this company and what they can be asked about — plainly, in the first + person, in complete sentences. Names, systems and responsibilities stay + exactly as given. No filler, no self-praise, no emoji. + +title_template: "{{user.name}}" + +skeleton: | + ## What I do + + ## What you can ask me about + + ## What I work with + +sections: + - heading: "What I do" + hint: > + The role in two or three sentences: what it is responsible for, and + what actually fills the day. + - heading: "What you can ask me about" + hint: > + The topics people come to this person with — and, where it matters, + what somebody else handles instead. + - heading: "What I work with" + hint: > + The systems, machines or tools this person is at home in. + +metadata: + visibility: public diff --git a/templates/projekt-debrief.de.yaml b/templates/projekt-debrief.de.yaml new file mode 100644 index 0000000..16904b5 --- /dev/null +++ b/templates/projekt-debrief.de.yaml @@ -0,0 +1,51 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# Not in the starter set. Kept short on purpose: a debrief that asks for a +# full project report gets written by nobody, and the only part anyone reads +# later is what would be done differently. +id: projekt-debrief +name: "Projekt-Rückblick: was wir gelernt haben" +version: "1.0" +kind: authoring +locale: de +description: > + Nach einem Projekt festhalten, was es gebracht hat und was beim nächsten + Mal anders laufen sollte. + +model: + temperature: 0.4 + min_class_hint: "12b" + +persona: | + Du bist ein präziser Fachredakteur. Du hältst nach einem Projekt fest, + worum es ging und was daraus zu lernen ist — sachlich, in vollständigen + Sätzen, ohne Schuldzuweisung und ohne Beschönigung. Zahlen, Termine und + Namen behältst du exakt bei. Keine Floskeln, keine Emojis. + +title_template: "Projekt-Rückblick vom {{date}}" + +skeleton: | + ## Worum es ging + + ## Wie es gelaufen ist + + ## Was wir beim nächsten Mal anders machen + +sections: + - heading: "Worum es ging" + hint: > + Ausgangslage und Ziel des Projekts, in zwei, drei Sätzen, mit dem + Zeitraum und den Beteiligten. + - heading: "Wie es gelaufen ist" + hint: > + Das Ergebnis und der Weg dorthin — was funktioniert hat und wo es + gehakt hat. + - heading: "Was wir beim nächsten Mal anders machen" + hint: > + Die konkreten Schlüsse: was wiederholt und was vermieden werden + sollte. + +metadata: + visibility: department diff --git a/templates/projekt-debrief.en.yaml b/templates/projekt-debrief.en.yaml new file mode 100644 index 0000000..18ab3ab --- /dev/null +++ b/templates/projekt-debrief.en.yaml @@ -0,0 +1,47 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `projekt-debrief` (same id, locale: en). +# +# Not in the starter set. Kept short on purpose: a debrief that asks for a +# full project report gets written by nobody. +id: projekt-debrief +name: "Project review: what we learned" +version: "1.0" +kind: authoring +locale: en +description: > + After a project, record what came of it and what should go differently + next time. + +model: + temperature: 0.4 + min_class_hint: "12b" + +persona: | + You are a precise technical editor. After a project you record what it was + about and what there is to learn from it — plainly, in complete sentences, + without blame and without gloss. Numbers, dates and names stay exactly as + given. No filler, no emoji. + +title_template: "Project review from {{date}}" + +skeleton: | + ## What it was about + + ## How it went + + ## What we do differently next time + +sections: + - heading: "What it was about" + hint: > + Starting point and goal of the project, in two or three sentences, + with the timeframe and who was involved. + - heading: "How it went" + hint: > + The outcome and the road to it — what worked and where it snagged. + - heading: "What we do differently next time" + hint: > + The concrete conclusions: what to repeat and what to avoid. + +metadata: + visibility: department diff --git a/templates/prozess.de.yaml b/templates/prozess.de.yaml new file mode 100644 index 0000000..14b2a4b --- /dev/null +++ b/templates/prozess.de.yaml @@ -0,0 +1,57 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# The most common thing anyone writes down: how something is done here. The +# headings are the questions a colleague actually asks — when does this +# apply, what do I do, what goes wrong, who do I ask. +id: prozess +name: "Ablauf: wie wir das machen" +version: "1.0" +kind: authoring +locale: de +description: > + Ein wiederkehrender Ablauf, Schritt für Schritt — so, dass jemand anderes + ihn allein schafft. + +model: + temperature: 0.4 + min_class_hint: "12b" + +persona: | + Du bist ein präziser Fachredakteur für Arbeitsanweisungen. Du schreibst + sachlich, in vollständigen Sätzen, und machst aus einer Abfolge eine + nummerierte Liste. Zahlen, Fristen, Systemnamen und Zuständigkeiten + behältst du exakt bei und erfindest keine dazu. Keine Floskeln, keine + Emojis. + +title_template: "Neuer Ablauf" + +skeleton: | + ## Wann das gilt + + ## Schritt für Schritt + + ## Wenn es klemmt + + ## Wer zuständig ist + +sections: + - heading: "Wann das gilt" + hint: > + Der Auslöser: in welcher Situation dieser Ablauf greift, und wo er + nicht gilt. + - heading: "Schritt für Schritt" + hint: > + Die Schritte in ihrer Reihenfolge, jeder als eine Handlung — mit den + Systemen, Formularen und Fristen, die dazugehören. + - heading: "Wenn es klemmt" + hint: > + Die Sonderfälle und die Stellen, an denen es erfahrungsgemäß hakt, + samt dem, was dann zu tun ist. + - heading: "Wer zuständig ist" + hint: > + Wer den Ablauf verantwortet und wen man bei Rückfragen anspricht. + +metadata: + visibility: department diff --git a/templates/prozess.en.yaml b/templates/prozess.en.yaml new file mode 100644 index 0000000..bf5f125 --- /dev/null +++ b/templates/prozess.en.yaml @@ -0,0 +1,54 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `prozess` (same id, locale: en). +# +# The most common thing anyone writes down: how something is done here. The +# headings are the questions a colleague actually asks. +id: prozess +name: "Process: how we do this" +version: "1.0" +kind: authoring +locale: en +description: > + A recurring process, step by step — written so somebody else gets through + it alone. + +model: + temperature: 0.4 + min_class_hint: "12b" + +persona: | + You are a precise technical editor for work instructions. You write + plainly, in complete sentences, and turn a sequence into a numbered list. + Numbers, deadlines, system names and responsibilities stay exactly as + given, and you invent none. No filler, no emoji. + +title_template: "New process" + +skeleton: | + ## When this applies + + ## Step by step + + ## When it goes wrong + + ## Who owns it + +sections: + - heading: "When this applies" + hint: > + The trigger: the situation this process covers, and where it does not + apply. + - heading: "Step by step" + hint: > + The steps in order, each one an action — with the systems, forms and + deadlines that belong to them. + - heading: "When it goes wrong" + hint: > + The special cases and the places this reliably snags, and what to do + then. + - heading: "Who owns it" + hint: > + Who is responsible for the process and who to ask about it. + +metadata: + visibility: department diff --git a/templates/stoerung.de.yaml b/templates/stoerung.de.yaml new file mode 100644 index 0000000..51e6151 --- /dev/null +++ b/templates/stoerung.de.yaml @@ -0,0 +1,58 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# Content is German (product content for the German market — Language policy); +# keys and structure are English. +# +# Written right after something broke, while it is still fresh — which is why +# the headings are past tense and the last one is the only forward-looking +# question worth asking. +id: stoerung +name: "Störung: was los war und was geholfen hat" +version: "1.0" +kind: authoring +locale: de +description: > + Nach einem Ausfall oder Fehler festhalten, woran es lag und wie es behoben + wurde. + +model: + temperature: 0.35 + min_class_hint: "12b" + +persona: | + Du bist ein präziser Fachredakteur für Störungsberichte. Du schreibst + sachlich und knapp, in vollständigen Sätzen. Fehlermeldungen, Codes, + Bauteilbezeichnungen und Messwerte übernimmst du wörtlich. Du trennst + Beobachtung von Vermutung und erfindest keine Ursache dazu. Keine + Floskeln, keine Emojis. + +title_template: "Störung vom {{date}}" + +skeleton: | + ## Was passiert ist + + ## Woran es lag + + ## Was geholfen hat + + ## Damit es nicht wiederkommt + +sections: + - heading: "Was passiert ist" + hint: > + Das Störungsbild: was ausgefallen ist, wann, und was das für den + Betrieb bedeutet hat. Fehlermeldungen wörtlich. + - heading: "Woran es lag" + hint: > + Die Ursache, soweit sie bekannt ist — und was nur Vermutung ist, wird + als solche gekennzeichnet. + - heading: "Was geholfen hat" + hint: > + Die Maßnahme, die die Störung tatsächlich behoben hat, mit den + Schritten in ihrer Reihenfolge. + - heading: "Damit es nicht wiederkommt" + hint: > + Was vorbeugt: Wartung, Prüfung, Ersatzteil, Änderung am Ablauf. Nur + was wirklich vorgesehen ist. + +metadata: + visibility: department diff --git a/templates/stoerung.en.yaml b/templates/stoerung.en.yaml new file mode 100644 index 0000000..d2f33d9 --- /dev/null +++ b/templates/stoerung.en.yaml @@ -0,0 +1,52 @@ +# Catalog blueprint — inert until an admin adds it (docs/authoring-templates.md). +# The English variant of `stoerung` (same id, locale: en). +# +# Written right after something broke, while it is still fresh. +id: stoerung +name: "Incident: what happened and what fixed it" +version: "1.0" +kind: authoring +locale: en +description: > + After a failure or a fault, record what caused it and how it was resolved. + +model: + temperature: 0.35 + min_class_hint: "12b" + +persona: | + You are a precise technical editor for incident reports. You write plainly + and briefly, in complete sentences. Error messages, codes, part numbers + and readings are quoted verbatim. You separate observation from guess and + invent no cause. No filler, no emoji. + +title_template: "Incident on {{date}}" + +skeleton: | + ## What happened + + ## What caused it + + ## What fixed it + + ## Keeping it from happening again + +sections: + - heading: "What happened" + hint: > + The symptom: what failed, when, and what it meant for operations. + Error messages verbatim. + - heading: "What caused it" + hint: > + The cause as far as it is known — anything that is a guess is marked + as one. + - heading: "What fixed it" + hint: > + The measure that actually resolved it, with the steps in order. + - heading: "Keeping it from happening again" + hint: > + What prevents a repeat: maintenance, a check, a spare part, a change + to the process. Only what is actually planned. + +metadata: + visibility: department