feat(agent): add test frontend and Docker stack

This commit is contained in:
2026-09-16 21:42:05 +02:00
parent c8b55fbd38
commit 4f5107a46a
16 changed files with 1065 additions and 71 deletions
+29 -5
View File
@@ -5,10 +5,10 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
## Current focus
API-first-Festigung (D19): Der eigenständige Wissensdienst erhält einen
versionierten, authentisierbaren v1-Vertrag und eine explizite Privacy-Grenze.
Als Nächstes folgt der dünne Odoo-Client; Lohndaten bleiben bis zum separaten
tenant-autorisierten Datenvertrag außerhalb der Agenten-API.
Der Docker-Compose-Stack (D21) für UI + API auf `100.103.83.12` ist lokal
gebaut und validiert. Offen ist nur der Zielhost-Smoke-Test samt Verifikation
des tatsächlichen Ollama-DNS-Alias im externen Netz `ollama-default`; danach
kann der dünne Odoo-Client folgen.
## Completed (2026-09-14)
@@ -436,6 +436,27 @@ tenant-autorisierten Datenvertrag außerhalb der Agenten-API.
ist durch `check_same_thread=False` plus `RLock` worker-thread-sicher. Neue
Nicht-lokaler CLI-Bind ohne `PV_API_KEY` wird fail-closed abgelehnt. Neue
API-/Concurrency-Tests erhöhen die Offline-Suite von 75 auf **82 grüne Tests**.
- **D20 (Test-Frontend, 2026-09-16):** FastAPI liefert unter `/` ein
dependency-freies, responsives Frontend aus `web/` aus. Es nutzt same-origin
`/v1/ask` und `/v1/health`, zeigt Status, Quellen, Konflikte, Rückfragen,
Suchplan und Grounding-Metadaten und hält den Verlauf nur im DOM. Ein
optional gemerkter Service-Key liegt ausschließlich im `sessionStorage` des
Tabs; kein Secret wird in die Assets injiziert. Modelltext wird ohne
`innerHTML` gerendert. CSP gilt gezielt für UI/Assets, damit `/docs` nutzbar
bleibt; globale Header setzen `nosniff`, `DENY` und `no-referrer`. Betrieb
auf dem Tailscale-Interface: `python -m agent.cli serve --host
100.103.83.12` mit gesetztem `PV_API_KEY`; UI auf Port 8080. Tests 82 → 84.
- **D21 (Docker-Compose-Deployment, 2026-09-16):** `compose.yaml` baut einen
gemeinsamen UI-/API-Container, bindet Port 8080 nur an `100.103.83.12` und
tritt dem externen Netz `ollama-default` bei. Ollama wird nicht dupliziert;
`OLLAMA_URL` ist konfigurierbar (Beispiel `http://ollama:11434`, Alias auf
Zielhost noch zu verifizieren). `data/` ist schreibbar, `wissensbasis/`
read-only; Root-FS read-only, Capabilities entfernt, no-new-privileges,
unprivilegierter `PUID:PGID`, verpflichtender `PV_API_KEY`. `.dockerignore`
hält `.env`, Index, Layer-1-Korpora und KB aus dem Image; Runtime-Image nutzt
`requirements-runtime.txt`. Compose `config --quiet`, Image-Build und
read-only Import-/Asset-Smoke-Test bestanden. Deployment: `docs/DOCKER.md`.
Statische Deploymenttests erhöhen die Suite von 84 auf **87 Tests**.
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
korrektheit > Latenz.
@@ -446,7 +467,10 @@ tenant-autorisierten Datenvertrag außerhalb der Agenten-API.
- `.agents/skills/pv-rag-agent/SKILL.md` — verbindliche Regeln.
- `agent/README.md` — Betrieb, Konfiguration, Host-Schritte.
- `docs/API.md` — v1-Vertrag, Authentisierung, Privacy-Grenze und Odoo-Aufruf.
- `agent/api.py` — versionierte Service-Oberfläche und Sicherheitsgrenzen.
- `agent/api.py` — versionierte Service-Oberfläche, UI-Auslieferung und Sicherheitsgrenzen.
- `web/` — dependency-freies Test-Frontend.
- `compose.yaml`, `Dockerfile`, `.env.example`, `.dockerignore` — Zielhost-Stack.
- `docs/DOCKER.md` — Deployment, Indexaufbau und Smoke-Tests.
- `agent/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert).
- `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung).
- `wissensbasis/README.md` — Layer-2-Schema + Rechtsprechungs-Provenance.
+21
View File
@@ -0,0 +1,21 @@
.git
.gitignore
.env
.env.*
!.env.example
.agents
.firecrawl
.lexis360
.rechtsprechung
.wiku
.pytest_cache
.venv
__pycache__
*.pyc
*.pyo
data
docs
tests
tools
wissensbasis
planung.md
+18
View File
@@ -0,0 +1,18 @@
# Laufzeit-UID/GID des Besitzers von ./data auf dem Docker-Host
PUID=1000
PGID=1000
# Starker, zufälliger Service-Key; niemals diesen Beispielwert verwenden.
PV_API_KEY=CHANGE_ME
# Optional getrennt; leer bedeutet: PV_API_KEY auch für /v1/reindex verwenden.
PV_ADMIN_API_KEY=
# DNS-Name und interner Port des Ollama-Containers im Netz ollama-default.
# Vor dem Start auf dem Zielhost mit `docker network inspect ollama-default`
# gegen den tatsächlichen Container-/Aliasnamen prüfen.
OLLAMA_URL=http://ollama:11434
PV_PORT=8080
PV_ANSWER_MODEL=qwen3.8:27b
PV_EMBED_MODEL=bge-m3
PV_THINK=false
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements-runtime.txt ./
RUN python -m pip install --no-cache-dir -r requirements-runtime.txt
COPY agent/ ./agent/
COPY web/ ./web/
EXPOSE 8080
CMD ["python", "-m", "agent.cli", "serve", "--host", "0.0.0.0"]
+27 -2
View File
@@ -43,14 +43,39 @@ python -m agent.cli eval
python -m agent.cli eval --answers --json-out data/eval-report.json
# 4) HTTP-API + Test-Chat
python -m agent.cli serve # http://127.0.0.1:8080 (/v1/ask, /v1/health, /v1/reindex)
python -m agent.cli serve # http://127.0.0.1:8080/ (UI + v1-API)
# Zielhost im Tailscale-Netz (PV_API_KEY muss gesetzt sein)
python -m agent.cli serve --host 100.103.83.12
# UI: http://100.103.83.12:8080/
```
Das dependency-freie Frontend unter `/` zeigt Health, Antwortstatus, Quellen,
Konflikte und Rückfragen. Der Service-Key wird nur im Browser eingegeben und
optional ausschließlich für den aktuellen Tab gespeichert.
Der stabile v1-Vertrag, Bearer-Authentisierung, Fehlersemantik und die
Datenschutzgrenze für die spätere Odoo-Anbindung sind in `docs/API.md`
dokumentiert. v1 ist zustandslos und akzeptiert keine Mandanten-, Mitarbeiter-
oder Abrechnungsobjekte.
## Docker Compose auf `100.103.83.12`
Der Stack in `compose.yaml` verbindet den Agenten mit dem bereits vorhandenen
externen Docker-Netz `ollama-default`; Ollama wird nicht dupliziert. Setup und
Smoke-Tests: `docs/DOCKER.md`.
```bash
cp .env.example .env # API-Key und Ollama-DNS-Alias setzen
docker compose build
docker compose up -d
```
UI: `http://100.103.83.12:8080/`. `data/` wird schreibbar und
`wissensbasis/` read-only eingebunden; weder Index noch lokale Quellkorpora
landen im Image.
## Konfiguration (Umgebungsvariablen)
| Variable | Default | Bedeutung |
@@ -216,7 +241,7 @@ agent/
api.py FastAPI v1 (/v1/ask, /v1/health, /v1/reindex), Auth + Request-IDs
cli.py ingest | ask | eval | serve
eval/ goldset.yaml + evaluate.py
web/index.html Minimaler Test-Chat
web/ Same-origin Test-Frontend (HTML, CSS, JavaScript)
tools/ Intake + Registry (build_registry.py, ingest_sources.py)
tests/ 64 Tests (offline, Fake-Ollama)
data/ index.db (gitignored)
+32
View File
@@ -12,9 +12,11 @@ import secrets
import threading
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict, Field
@@ -27,6 +29,8 @@ from .retrieve import Retriever
API_VERSION = "v1"
DATA_SCOPE = "knowledge_base_only"
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
_WEB_ASSETS = {"app.js", "styles.css"}
logger = logging.getLogger(__name__)
bearer = HTTPBearer(auto_error=False)
bearer_credentials = Depends(bearer)
@@ -170,9 +174,37 @@ async def add_request_id(request: Request, call_next):
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
if request.url.path == "/" or request.url.path.startswith("/assets/"):
response.headers["Content-Security-Policy"] = (
"default-src 'self'; base-uri 'none'; form-action 'self'; "
"frame-ancestors 'none'; img-src 'self' data:; "
"script-src 'self'; style-src 'self'; connect-src 'self'"
)
return response
@app.get("/", include_in_schema=False, response_class=FileResponse)
def frontend() -> FileResponse:
return FileResponse(
WEB_DIR / "index.html",
media_type="text/html",
headers={"Cache-Control": "no-store"},
)
@app.get("/assets/{asset_name}", include_in_schema=False, response_class=FileResponse)
def frontend_asset(asset_name: str) -> FileResponse:
if asset_name not in _WEB_ASSETS:
raise HTTPException(status_code=404, detail="Asset nicht gefunden.")
return FileResponse(
WEB_DIR / asset_name,
headers={"Cache-Control": "public, max-age=3600"},
)
def _request_id(request: Request) -> str:
return getattr(request.state, "request_id", uuid.uuid4().hex)
+62
View File
@@ -0,0 +1,62 @@
name: pv-agent
services:
pv-agent:
build:
context: .
dockerfile: Dockerfile
image: pv-agent:local
restart: unless-stopped
init: true
user: "${PUID:-1000}:${PGID:-1000}"
ports:
- "100.103.83.12:${PV_PORT:-8080}:8080"
environment:
OLLAMA_URL: "${OLLAMA_URL:-http://ollama:11434}"
PV_ANSWER_MODEL: "${PV_ANSWER_MODEL:-qwen3.8:27b}"
PV_EMBED_MODEL: "${PV_EMBED_MODEL:-bge-m3}"
PV_DB_PATH: /app/data/index.db
PV_KB_DIR: /app/wissensbasis
PV_PORT: "8080"
PV_API_KEY: "${PV_API_KEY:?PV_API_KEY muss gesetzt sein}"
PV_ADMIN_API_KEY: "${PV_ADMIN_API_KEY:-}"
PV_THINK: "${PV_THINK:-false}"
PV_NUM_CTX: "${PV_NUM_CTX:-32768}"
PV_MAX_CONTEXT_CHARS: "${PV_MAX_CONTEXT_CHARS:-90000}"
PV_NUM_PREDICT: "${PV_NUM_PREDICT:-2048}"
PV_CANDIDATE_POOL: "${PV_CANDIDATE_POOL:-150}"
PV_RRF_K: "${PV_RRF_K:-20}"
PV_DENSE_WEIGHT: "${PV_DENSE_WEIGHT:-2.0}"
PV_CONTEXT_BLOCKS: "${PV_CONTEXT_BLOCKS:-8}"
PV_PER_QUERY_SLOTS: "${PV_PER_QUERY_SLOTS:-2}"
PV_SURVEY_BLOCKS: "${PV_SURVEY_BLOCKS:-16}"
volumes:
- ./data:/app/data
- ./wissensbasis:/app/wissensbasis:ro
networks:
- ollama-default
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- python
- -c
- >-
import json, urllib.request;
d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/v1/health', timeout=5));
raise SystemExit(0 if d.get('status') == 'ok' else 1)
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
networks:
ollama-default:
external: true
name: ollama-default
+23
View File
@@ -21,6 +21,7 @@ Aufbewahrung. Ein allgemeiner API-Key allein reicht dafür nicht aus.
| Methode | Pfad | Auth | Zweck |
|---|---|---|---|
| `GET` | `/` | Eingabe im UI | Test-Frontend |
| `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort |
| `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails |
| `POST` | `/v1/reindex` | Admin-Key | Index nach KB-Änderung neu aufbauen |
@@ -28,6 +29,28 @@ Aufbewahrung. Ein allgemeiner API-Key allein reicht dafür nicht aus.
Die bisherigen Pfade `/ask`, `/health` und `/reindex` bleiben vorläufig als
`deprecated` Kompatibilitätsrouten erhalten und liefern denselben Vertrag.
## Test-Frontend
Das dependency-freie Frontend wird vom API-Prozess same-origin unter `/`
ausgeliefert. Dadurch sind keine CORS-Freigaben nötig. Es zeigt Dienststatus,
Antwort, zitierte Quellen, Quellenkonflikte, Rückfragen und technische
Grounding-Metadaten. Modellantworten werden nur als Text gerendert; HTML aus
einer Antwort wird nicht ausgeführt.
Für den vorgesehenen Tailscale-Host:
```bash
export PV_API_KEY='service-key-aus-secret-store'
python -m agent.cli serve --host 100.103.83.12
```
Danach ist die Oberfläche unter `http://100.103.83.12:8080/` erreichbar. Der
Service-Key wird vom Benutzer im Frontend eingegeben und niemals serverseitig
in HTML oder JavaScript eingebettet. Optional speichert ihn die Oberfläche nur
im `sessionStorage` des aktuellen Browser-Tabs; Chatverlauf und Fragen werden
nicht im Browser gespeichert. Außerhalb eines verschlüsselten Tailnets ist vor
den Dienst ein TLS-Reverse-Proxy zu setzen.
## Authentisierung
```bash
+130
View File
@@ -0,0 +1,130 @@
# Docker-Deployment auf `100.103.83.12`
Der Compose-Stack betreibt Test-Frontend und FastAPI-Agent gemeinsam. Er startet
keinen zweiten Ollama-Container, sondern verbindet sich mit dem vorhandenen
externen Docker-Netz `ollama-default`.
## Voraussetzungen
Auf dem Zielhost müssen vorhanden sein:
- Docker Engine mit Compose-Plugin;
- das externe Netz `ollama-default`;
- ein darin erreichbarer Ollama-Container;
- die Modelle `qwen3.8:27b` und `bge-m3` in dieser Ollama-Instanz;
- `wissensbasis/` und entweder ein vorhandenes `data/index.db` oder genügend
Zeit für den initialen Indexaufbau.
Das Netz und seine Container/Aliase prüfen:
```bash
docker network inspect ollama-default
```
Der Compose-Beispielwert nimmt den DNS-Namen `ollama` und den internen
Ollama-Port `11434` an. Das ist **nicht lokal verifiziert**, weil das Netz nur
auf dem Zielhost existiert. Falls der Container im Netz anders heißt, muss
`OLLAMA_URL` in `.env` entsprechend gesetzt werden, beispielsweise:
```text
OLLAMA_URL=http://tatsaechlicher-containername:11434
```
Die veröffentlichte Host-Portnummer `11435` ist innerhalb des gemeinsamen
Docker-Netzes normalerweise nicht relevant; Container sprechen den internen
Port des Ollama-Containers an.
## Konfiguration
```bash
cp .env.example .env
```
Dann `.env` anpassen:
1. `PV_API_KEY` durch einen starken zufälligen Wert ersetzen;
2. optional einen getrennten `PV_ADMIN_API_KEY` setzen;
3. `OLLAMA_URL` anhand des Netzwerk-Alias prüfen;
4. `PUID`/`PGID` auf den Besitzer von `data/` setzen.
`.env` ist gitignored und darf nicht committed werden. Compose verwendet die
Datei nur zur Interpolation der ausdrücklich in `compose.yaml` aufgelisteten
Variablen; sonstige lokale Secrets werden nicht pauschal in den Container
durchgereicht.
## Start mit vorhandenem Index
```bash
docker compose build
docker compose up -d
docker compose ps
docker compose logs --follow pv-agent
```
Aufruf im Tailscale-Netz:
```text
http://100.103.83.12:8080/
```
Im Frontend denselben Wert wie `PV_API_KEY` als Service-Key eingeben.
## Initialen Index im Container bauen
Falls `data/index.db` auf dem Zielhost noch fehlt:
```bash
mkdir -p data
docker compose run --rm pv-agent python -m agent.cli ingest
```
Der Lauf verwendet `bge-m3` über `OLLAMA_URL` und schreibt den Index in das
Bind-Mount `./data`. Danach den Dienst starten:
```bash
docker compose up -d
```
Der Prozess läuft als `PUID:PGID`. `data/` muss für diese IDs schreibbar sein,
insbesondere wenn `/v1/reindex` verwendet werden soll.
## Smoke-Tests
```bash
curl --fail http://100.103.83.12:8080/v1/health
curl --fail \
-H 'Authorization: Bearer <PV_API_KEY>' \
-H 'Content-Type: application/json' \
-d '{"question":"Wie hoch ist der steuerfreie Tagesgeldsatz?","mode":"knowledge"}' \
http://100.103.83.12:8080/v1/ask
```
`/v1/health` muss `"status":"ok"` liefern. Ein Status `degraded` bedeutet in
der Regel, dass der Index fehlt oder Ollama unter dem konfigurierten
Container-DNS-Namen nicht erreichbar ist.
## Sicherheitsprofil
- Port `8080` wird nur an die Tailscale-Adresse `100.103.83.12` gebunden.
- `PV_API_KEY` ist für den Compose-Start verpflichtend.
- Root-Dateisystem ist read-only; nur `./data` ist schreibbar.
- Alle Linux-Capabilities werden entfernt; `no-new-privileges` ist aktiv.
- `wissensbasis/` wird read-only eingebunden.
- Frontend und API sind same-origin; es ist keine CORS-Freigabe nötig.
- Außerhalb des verschlüsselten Tailnets ist TLS vor dem Dienst erforderlich.
## Aktualisierung
```bash
git pull
docker compose build --pull
docker compose up -d
docker image prune
```
Nach Änderungen an der Wissensbasis:
```bash
docker compose run --rm pv-agent python -m agent.cli ingest
docker compose up -d
```
+16
View File
@@ -357,4 +357,20 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
getrennten tenant-autorisierten Vertrag; sie wird nicht durch Anhängen von
Rohdaten an `/v1/ask` umgesetzt. Vertrag und Odoo-Clientregeln:
`docs/API.md`.
- **Test-Frontend (2026-09-16, D20):** FastAPI liefert ein responsives,
dependency-freies same-origin UI unter `/` aus. Es rendert Antworttext
XSS-sicher, zeigt Health, Quellen, Konflikte, Rückfragen sowie Grounding-
Metadaten und speichert weder Fragen noch Chatverlauf. Ein Service-Key wird
nur vom Benutzer eingegeben und optional im `sessionStorage` des Tabs
gehalten. Zielbetrieb ist `http://100.103.83.12:8080/` mit Bind an das
Tailscale-Interface und gesetztem `PV_API_KEY`; CSP und weitere Security-
Header schützen die UI, ohne FastAPI `/docs` zu blockieren.
- **Docker-Deployment (2026-09-16, D21):** Ein gehärteter Compose-Service
betreibt UI und API gemeinsam auf `100.103.83.12:8080` und nutzt das externe
Netz `ollama-default`. Ollama bleibt ein separater bestehender Container;
sein Netzwerk-DNS-Alias wird über `OLLAMA_URL` konfiguriert. Index und KB
bleiben Host-Bind-Mounts, Secrets und lokale Korpora außerhalb des Images.
Der Stack verlangt einen Service-Key, läuft als konfigurierbare unprivilegierte
UID/GID mit read-only Root-FS und besitzt einen Readiness-Healthcheck.
Deployment und initialer Indexaufbau: `docs/DOCKER.md`.
- Betrieb: `agent/README.md`.
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn>=0.30
httpx>=0.27
PyYAML>=6.0
numpy>=2.0
+26
View File
@@ -69,6 +69,32 @@ def configure_state(*, api_key: str = "", admin_key: str = "") -> None:
app.state.rag.retriever = FakeRetriever()
def test_frontend_is_served_with_security_headers():
with TestClient(app) as client:
configure_state(api_key="service-secret")
response = client.get("/")
assert response.status_code == 200
assert "PV Wissen" in response.text
assert "service-secret" not in response.text
assert response.headers["cache-control"] == "no-store"
assert response.headers["x-frame-options"] == "DENY"
assert response.headers["x-content-type-options"] == "nosniff"
assert "script-src 'self'" in response.headers["content-security-policy"]
def test_frontend_assets_use_v1_api_and_unknown_assets_are_hidden():
with TestClient(app) as client:
configure_state()
script = client.get("/assets/app.js")
styles = client.get("/assets/styles.css")
missing = client.get("/assets/index.html")
assert script.status_code == 200
assert styles.status_code == 200
assert 'fetch("/v1/ask"' in script.text
assert "innerHTML" not in script.text
assert missing.status_code == 404
def test_nonlocal_bind_without_api_key_is_rejected(capsys):
class Args:
host = "0.0.0.0"
+47
View File
@@ -0,0 +1,47 @@
"""Statische Schutztests für den Docker-Compose-Deploymentvertrag."""
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
def load_compose() -> dict:
return yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8"))
def test_compose_uses_external_ollama_network_and_tailscale_bind():
compose = load_compose()
service = compose["services"]["pv-agent"]
assert service["networks"] == ["ollama-default"]
assert compose["networks"]["ollama-default"] == {
"external": True,
"name": "ollama-default",
}
assert "100.103.83.12:${PV_PORT:-8080}:8080" in service["ports"]
assert service["environment"]["OLLAMA_URL"] == (
"${OLLAMA_URL:-http://ollama:11434}"
)
def test_compose_requires_auth_and_limits_container_privileges():
service = load_compose()["services"]["pv-agent"]
assert ":?" in service["environment"]["PV_API_KEY"]
assert "env_file" not in service
assert service["read_only"] is True
assert service["security_opt"] == ["no-new-privileges:true"]
assert service["cap_drop"] == ["ALL"]
assert "./data:/app/data" in service["volumes"]
assert "./wissensbasis:/app/wissensbasis:ro" in service["volumes"]
def test_docker_context_excludes_secrets_and_runtime_data():
ignored = set(
(ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines()
)
assert {".env", "data", ".lexis360", ".rechtsprechung", "wissensbasis"} <= ignored
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "requirements-runtime.txt" in dockerfile
assert "COPY agent/" in dockerfile
assert "COPY web/" in dockerfile
assert "COPY ." not in dockerfile
+345
View File
@@ -0,0 +1,345 @@
"use strict";
const elements = {
form: document.getElementById("ask-form"),
question: document.getElementById("question"),
submit: document.getElementById("submit"),
cancel: document.getElementById("cancel"),
messages: document.getElementById("messages"),
empty: document.getElementById("empty-state"),
health: document.getElementById("health"),
key: document.getElementById("api-key"),
rememberKey: document.getElementById("remember-key"),
keyVisibility: document.getElementById("key-visibility"),
topK: document.getElementById("top-k"),
charCount: document.getElementById("char-count"),
settingsToggle: document.getElementById("settings-toggle"),
settingsBody: document.getElementById("settings-body"),
};
let activeController = null;
let loadingMessage = null;
let loadingTimer = null;
let loadingStartedAt = 0;
function node(tag, className, text) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined && text !== null) item.textContent = text;
return item;
}
function requestId() {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
return `web-${globalThis.crypto.randomUUID()}`;
}
return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function apiHeaders() {
const headers = {
"Content-Type": "application/json",
"X-Request-ID": requestId(),
};
const key = elements.key.value.trim();
if (key) headers.Authorization = `Bearer ${key}`;
return headers;
}
function updateCharacterCount() {
elements.charCount.textContent = `${elements.question.value.length} / 2000`;
}
function scrollToLatest() {
elements.messages.lastElementChild?.scrollIntoView({ behavior: "smooth", block: "end" });
}
function addUserMessage(question) {
elements.empty.hidden = true;
const wrapper = node("article", "message message-user");
wrapper.append(node("div", "message-label", "Deine Frage"));
wrapper.append(node("div", "message-body", question));
elements.messages.append(wrapper);
scrollToLatest();
}
function addLoadingMessage() {
const wrapper = node("article", "message message-agent");
const label = node("div", "message-label", "PV Agent");
const body = node("div", "message-body");
const row = node("div", "loading-row");
row.append(node("span", "loader"));
const text = node("span", "loading-text", "Wissensbasis wird durchsucht … 0 s");
row.append(text);
body.append(row);
wrapper.append(label, body);
elements.messages.append(wrapper);
loadingStartedAt = Date.now();
loadingTimer = window.setInterval(() => {
const seconds = Math.round((Date.now() - loadingStartedAt) / 1000);
text.textContent = `Wissensbasis wird durchsucht … ${seconds} s`;
}, 1000);
loadingMessage = wrapper;
scrollToLatest();
}
function removeLoadingMessage() {
if (loadingTimer) window.clearInterval(loadingTimer);
loadingTimer = null;
loadingMessage?.remove();
loadingMessage = null;
}
function badge(text, variant = "") {
return node("span", `badge${variant ? ` badge-${variant}` : ""}`, text);
}
function sourcePanel(sources) {
const details = node("details", "source-panel");
const summary = node("summary", "", `${sources.length} zitierte ${sources.length === 1 ? "Quelle" : "Quellen"}`);
const list = node("div", "source-list");
for (const source of sources) {
const card = node("div", "source-card");
card.append(node("div", "source-id", source.id || "Unbekannte ID"));
card.append(node("div", "source-title", source.title || "Ohne Titel"));
const meta = [source.section, source.stand ? `Stand ${source.stand}` : null, source.work]
.filter(Boolean)
.join(" · ");
if (meta) card.append(node("div", "source-meta", meta));
list.append(card);
}
details.append(summary, list);
return details;
}
function conflictPanel(conflicts) {
const container = node("div");
for (const conflict of conflicts) {
const box = node("div", "conflict-box");
box.append(node("strong", "", `Quellenkonflikt · ${(conflict.source_ids || []).join(", ")}`));
box.append(node("p", "", conflict.summary));
container.append(box);
}
return container;
}
function followUpPanel(question) {
const box = node("div", "follow-up");
box.append(node("p", "", question));
const button = node("button", "secondary", "Rückfrage übernehmen");
button.type = "button";
button.addEventListener("click", () => {
elements.question.value = question;
updateCharacterCount();
elements.question.focus();
});
box.append(button);
return box;
}
function technicalPanel(data) {
const details = node("details", "technical-panel");
details.append(node("summary", "", "Technische Details"));
const list = node("ul", "query-list");
const facts = [
`Request-ID: ${data.request_id || ""}`,
`Antworttyp: ${data.answer_type || "specific"}`,
`Kontextblöcke: ${data.grounding?.context_count ?? data.n_context ?? ""}`,
`Regenerierungen: ${data.regenerations ?? 0}`,
`Datenumfang: ${data.grounding?.data_scope || ""}`,
];
for (const fact of facts) list.append(node("li", "", fact));
if (Array.isArray(data.planned_queries) && data.planned_queries.length) {
list.append(node("li", "", "Suchplan:"));
for (const query of data.planned_queries) {
const suffix = [query.scope, query.stand_year].filter(Boolean).join(", ");
list.append(node("li", "", `${query.text}${suffix ? ` (${suffix})` : ""}`));
}
}
details.append(list);
return details;
}
function addAgentMessage(data) {
const status = data.status || (data.verified ? (data.refused ? "refused" : "answered") : "uncertain");
const wrapper = node("article", `message message-agent is-${status}`);
wrapper.append(node("div", "message-label", "PV Agent"));
const body = node("div", "message-body");
const statusLine = node("div", "status-line");
if (status === "answered") statusLine.append(badge("Zitiergeprüft"));
if (status === "refused") statusLine.append(badge("Nicht in der Wissensbasis", "warning"));
if (status === "uncertain") statusLine.append(badge("Nicht verlässlich belegt", "error"));
if (data.answer_type === "survey") statusLine.append(badge("Überblick"));
body.append(statusLine);
body.append(node("div", "answer-text", data.answer || "Keine Antwort erhalten."));
if (Array.isArray(data.conflicts) && data.conflicts.length) {
body.append(conflictPanel(data.conflicts));
}
if (data.clarification_question) {
body.append(followUpPanel(data.clarification_question));
}
if (Array.isArray(data.sources) && data.sources.length) {
body.append(sourcePanel(data.sources));
}
const seconds = typeof data.latency_ms === "number" ? `${(data.latency_ms / 1000).toFixed(1)} s` : "";
body.append(node("div", "answer-meta", `${data.model || "Modell"} · ${seconds} · ${data.citations?.length || 0} Zitate`));
body.append(technicalPanel(data));
wrapper.append(body);
elements.messages.append(wrapper);
scrollToLatest();
}
function addErrorMessage(message, requestIdValue = "") {
const wrapper = node("article", "message message-agent message-error");
wrapper.append(node("div", "message-label", "Verbindungsfehler"));
const body = node("div", "message-body");
body.append(badge("Anfrage fehlgeschlagen", "error"));
body.append(node("div", "answer-text", message));
if (requestIdValue) body.append(node("div", "answer-meta", `Request-ID: ${requestIdValue}`));
wrapper.append(body);
elements.messages.append(wrapper);
scrollToLatest();
}
async function errorDetail(response) {
try {
const payload = await response.json();
if (typeof payload.detail === "string") return payload.detail;
if (Array.isArray(payload.detail)) return "Die Anfrage entspricht nicht dem API-Vertrag.";
} catch (_) {
// Absichtlich neutral: keine unstrukturierte Serverantwort in die UI übernehmen.
}
return `HTTP ${response.status}`;
}
function setBusy(busy) {
elements.submit.disabled = busy;
elements.cancel.hidden = !busy;
elements.question.disabled = busy;
}
async function ask(question) {
if (activeController) return;
addUserMessage(question);
addLoadingMessage();
setBusy(true);
activeController = new AbortController();
const topK = elements.topK.value ? Number(elements.topK.value) : undefined;
const payload = { question, mode: "knowledge" };
if (topK) payload.top_k = topK;
try {
const response = await fetch("/v1/ask", {
method: "POST",
headers: apiHeaders(),
body: JSON.stringify(payload),
signal: activeController.signal,
});
const responseRequestId = response.headers.get("X-Request-ID") || "";
removeLoadingMessage();
if (!response.ok) {
const detail = await errorDetail(response);
if (response.status === 401) {
addErrorMessage("Service-Key fehlt oder ist ungültig. Bitte die Verbindungseinstellungen prüfen.", responseRequestId);
elements.key.focus();
} else {
addErrorMessage(detail, responseRequestId);
}
return;
}
addAgentMessage(await response.json());
} catch (error) {
removeLoadingMessage();
if (error.name === "AbortError") {
addErrorMessage("Die Anzeige wurde abgebrochen. Die serverseitige Verarbeitung kann bereits begonnen haben.");
} else {
addErrorMessage("Der Wissensdienst ist nicht erreichbar. Netzwerk und Dienststatus prüfen.");
}
} finally {
activeController = null;
setBusy(false);
elements.question.focus();
}
}
async function checkHealth() {
elements.health.className = "health health-loading";
try {
const response = await fetch("/v1/health", { headers: { "X-Request-ID": requestId() } });
if (!response.ok) throw new Error("health request failed");
const data = await response.json();
const ok = data.status === "ok";
elements.health.className = `health ${ok ? "health-ok" : "health-error"}`;
elements.health.lastElementChild.textContent = ok
? `${data.index?.n_entries ?? ""} Quellen · bereit`
: "Dienst eingeschränkt";
if (data.authentication_enabled && !elements.key.value) {
elements.key.placeholder = "Service-Key erforderlich";
}
} catch (_) {
elements.health.className = "health health-error";
elements.health.lastElementChild.textContent = "Dienst nicht erreichbar";
}
}
elements.form.addEventListener("submit", (event) => {
event.preventDefault();
const question = elements.question.value.trim();
if (question.length < 3) return;
elements.question.value = "";
updateCharacterCount();
ask(question);
});
elements.question.addEventListener("input", updateCharacterCount);
elements.question.addEventListener("keydown", (event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
elements.form.requestSubmit();
}
});
elements.cancel.addEventListener("click", () => activeController?.abort());
document.querySelectorAll("[data-question]").forEach((button) => {
button.addEventListener("click", () => {
elements.question.value = button.dataset.question || "";
updateCharacterCount();
elements.question.focus();
});
});
elements.keyVisibility.addEventListener("click", () => {
const visible = elements.key.type === "text";
elements.key.type = visible ? "password" : "text";
elements.keyVisibility.textContent = visible ? "Anzeigen" : "Verbergen";
elements.keyVisibility.setAttribute("aria-label", visible ? "Service-Key anzeigen" : "Service-Key verbergen");
});
elements.rememberKey.addEventListener("change", () => {
if (elements.rememberKey.checked) {
sessionStorage.setItem("pv-api-key", elements.key.value);
} else {
sessionStorage.removeItem("pv-api-key");
}
});
elements.key.addEventListener("input", () => {
if (elements.rememberKey.checked) sessionStorage.setItem("pv-api-key", elements.key.value);
});
elements.settingsToggle.addEventListener("click", () => {
const expanded = elements.settingsToggle.getAttribute("aria-expanded") === "true";
elements.settingsToggle.setAttribute("aria-expanded", String(!expanded));
elements.settingsBody.hidden = expanded;
});
const savedKey = sessionStorage.getItem("pv-api-key");
if (savedKey) {
elements.key.value = savedKey;
elements.rememberKey.checked = true;
}
updateCharacterCount();
checkHealth();
+95 -63
View File
@@ -1,69 +1,101 @@
<!DOCTYPE html>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PV RAG Agent — Test-Chat</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 780px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.2rem; }
#log { display: flex; flex-direction: column; gap: 0.8rem; margin: 1.5rem 0; }
.msg { border: 1px solid rgba(128,128,128,.35); border-radius: 8px; padding: .7rem .9rem; white-space: pre-wrap; }
.q { background: rgba(128,128,128,.12); }
.src { font-size: .78rem; margin-top: .4rem; display: flex; flex-wrap: wrap; gap: .3rem; }
.chip { border-radius: 999px; padding: .1rem .5rem; background: rgba(128,128,128,.15); }
.warn { border-color: #c08020; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; border-radius: 6px; border: 1px solid rgba(128,128,128,.5); }
button { padding: .5rem 1rem; border-radius: 6px; cursor: pointer; }
.meta { font-size: .75rem; opacity: .7; }
</style>
<meta charset="utf-8">
<meta</head> name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Testoberfläche für den PV RAG Agent">
<title>PV Wissen — Test-Chat</title>
<link rel="stylesheet" href="/assets/styles.css">
<script src="/assets/app.js" defer></script>
</head>
<body>
<h1>PV RAG Agent — Wissensbasis Personalverrechnung</h1>
<p class="meta">Antworten ausschließlich aus der kuratierten Wissensbasis, mit ID- und Stand-Beleg.</p>
<div id="log"></div>
<form id="f">
<input id="q" placeholder="Frage zur österreichischen Personalverrechnung …" autocomplete="off" required>
<button>Senden</button>
</form>
<script>
const log = document.getElementById("log");
function add(cls, html) {
const d = document.createElement("div");
d.className = "msg " + cls;
d.innerHTML = html;
log.appendChild(d);
}
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const q = document.getElementById("q").value.trim();
if (!q) return;
add("q", q);
document.getElementById("q").value = "";
try {
const r = await fetch("/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: q }),
});
if (!r.ok) {
add("warn", "Fehler " + r.status + ": " + (await r.text()));
return;
}
const d = await r.json();
const chips = (d.sources || []).map(s =>
`<span class="chip">${s.id} · ${s.stand || ""}</span>`).join("");
add(d.refused ? "warn" : "",
`${d.answer.replace(/</g, "&lt;")}` +
(d.sources && d.sources.length ? `<div class="src">${chips}</div>` : "") +
`<div class="meta">Modell: ${d.model} · ${d.latency_ms} ms · ` +
`${d.verified ? "zitiergeprüft" : "UNVERIFIZIERT"}${d.refused ? " · verweigert" : ""}</div>`);
} catch (err) {
add("warn", "Netzwerkfehler: " + err);
}
});
</script>
<div class="shell">
<header class="topbar">
<a class="brand" href="/" aria-label="PV Wissen Startseite">
<span class="brand-mark" aria-hidden="true">PV</span>
<span>
<strong>PV Wissen</strong>
<small>Agent für österreichische Personalverrechnung</small>
</span>
</a>
<div id="health" class="health health-loading" role="status" aria-live="polite">
<span class="health-dot" aria-hidden="true"></span>
<span>Verbindung wird geprüft</span>
</div>
</header>
<main>
<section class="hero" aria-labelledby="page-title">
<p class="eyebrow">Belegt. Nachvollziehbar. Lokal.</p>
<h1 id="page-title">Was möchtest du zur Personalverrechnung wissen?</h1>
<p>Antworten stammen ausschließlich aus der kuratierten Wissensbasis und führen ihre Quellen direkt an.</p>
</section>
<section class="workspace">
<aside class="settings" aria-labelledby="settings-title">
<div class="settings-heading">
<h2 id="settings-title">Verbindung</h2>
<button id="settings-toggle" class="icon-button" type="button" aria-expanded="true" aria-controls="settings-body" title="Einstellungen ein- oder ausblenden"></button>
</div>
<div id="settings-body">
<label for="api-key">Service-Key</label>
<div class="key-row">
<input id="api-key" type="password" autocomplete="off" spellcheck="false" placeholder="Bearer-Key, falls aktiviert">
<button id="key-visibility" class="secondary compact" type="button" aria-label="Service-Key anzeigen">Anzeigen</button>
</div>
<label class="check-row" for="remember-key">
<input id="remember-key" type="checkbox">
<span>Nur für diesen Tab merken</span>
</label>
<label for="top-k">Kontextumfang</label>
<select id="top-k">
<option value="">Automatisch</option>
<option value="6">6 Quellenblöcke</option>
<option value="8">8 Quellenblöcke</option>
<option value="12">12 Quellenblöcke</option>
<option value="16">16 Quellenblöcke</option>
</select>
<div class="privacy-note">
<strong>Datenschutzgrenze</strong>
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Diese Testversion verarbeitet nur Fachfragen.</span>
</div>
</div>
</aside>
<section class="chat" aria-label="Chat mit dem PV Agenten">
<div id="empty-state" class="empty-state">
<div class="empty-icon" aria-hidden="true">§</div>
<h2>Mit einer Fachfrage starten</h2>
<p>Zum Beispiel zu Reisekosten, Abgaben, Kollektivverträgen oder arbeitsrechtlichen Ansprüchen.</p>
<div class="suggestions" aria-label="Beispielfragen">
<button type="button" data-question="Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. Was ist die günstigste Lösung?">500 Euro zusätzlich auszahlen</button>
<button type="button" data-question="Wie hoch ist der steuerfreie Tagesgeldsatz bei einer Inlandsdienstreise?">Steuerfreies Tagesgeld</button>
<button type="button" data-question="Welche Voraussetzungen gelten für die Mitarbeiterprämie 2026?">Mitarbeiterprämie 2026</button>
</div>
</div>
<div id="messages" class="messages" aria-live="polite" aria-label="Nachrichtenverlauf"></div>
<form id="ask-form" class="composer">
<label class="sr-only" for="question">Frage</label>
<textarea id="question" rows="3" maxlength="2000" required placeholder="Frage zur österreichischen Personalverrechnung …"></textarea>
<div class="composer-footer">
<span id="char-count" class="character-count">0 / 2000</span>
<div class="composer-actions">
<button id="cancel" class="secondary" type="button" hidden>Abbrechen</button>
<button id="submit" class="primary" type="submit">
<span>Frage senden</span>
<span class="send-icon" aria-hidden="true"></span>
</button>
</div>
</div>
</form>
<p class="footnote">Der Agent kann Fehler machen. Fachliche Entscheidungen anhand der angeführten Quellen prüfen.</p>
</section>
</section>
</main>
</div>
</body>
</html>
+170
View File
@@ -0,0 +1,170 @@
:root {
color-scheme: light;
--ink: #17211b;
--muted: #607066;
--paper: #f4f3ec;
--surface: #fffef9;
--line: #d9ddd6;
--green: #176b4d;
--green-dark: #0f4e38;
--green-soft: #e4f2ea;
--amber: #9a5a08;
--amber-soft: #fff4d9;
--red: #9b2c2c;
--red-soft: #fdeaea;
--shadow: 0 20px 60px rgba(34, 51, 40, 0.09);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at 15% -10%, rgba(63, 137, 103, 0.16), transparent 34rem),
linear-gradient(180deg, #fbfaf5 0%, var(--paper) 100%);
}
button, input, select, textarea { font: inherit; }
button { color: inherit; }
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; }
.topbar {
min-height: 80px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid rgba(23, 33, 27, 0.1);
}
.brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; }
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
color: white;
background: var(--green-dark);
font-family: Georgia, serif;
font-weight: 700;
letter-spacing: -0.04em;
}
.brand strong, .brand small { display: block; }
.brand strong { font-size: 1rem; letter-spacing: 0.01em; }
.brand small { margin-top: 2px; color: var(--muted); font-size: 0.76rem; }
.health { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.82rem; }
.health-dot { width: 9px; height: 9px; border-radius: 50%; background: #a0aaa3; box-shadow: 0 0 0 4px rgba(160, 170, 163, 0.14); }
.health-ok .health-dot { background: #239666; box-shadow: 0 0 0 4px rgba(35, 150, 102, 0.14); }
.health-error .health-dot { background: #c4553e; box-shadow: 0 0 0 4px rgba(196, 85, 62, 0.14); }
.hero { max-width: 780px; padding: 64px 0 38px; }
.eyebrow { margin: 0 0 12px; color: var(--green); font-size: 0.77rem; font-weight: 750; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { max-width: 720px; margin: 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(2.25rem, 5vw, 4.25rem); font-weight: 500; line-height: 1.02; letter-spacing: -0.035em; }
.hero > p:last-child { max-width: 660px; margin: 22px 0 0; color: var(--muted); font-size: 1.04rem; line-height: 1.65; }
.workspace { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 22px; align-items: start; padding-bottom: 64px; }
.settings, .chat { border: 1px solid var(--line); border-radius: 18px; background: rgba(255, 254, 249, 0.94); box-shadow: var(--shadow); }
.settings { padding: 18px; position: sticky; top: 18px; }
.settings-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.settings h2 { margin: 0; font-size: 0.94rem; }
.settings label:not(.check-row) { display: block; margin: 15px 0 6px; color: var(--muted); font-size: 0.74rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
.settings input[type="password"], .settings input[type="text"], .settings select {
width: 100%; min-width: 0; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; color: var(--ink); background: white;
}
.key-row { display: flex; gap: 6px; }
.key-row input { flex: 1; }
.check-row { display: flex; align-items: center; gap: 8px; margin-top: 9px; color: var(--muted); font-size: 0.78rem; cursor: pointer; }
.check-row input { accent-color: var(--green); }
.icon-button { display: none; border: 0; background: transparent; cursor: pointer; }
.privacy-note { display: grid; gap: 5px; margin-top: 20px; padding: 12px; border-radius: 10px; color: #6f541b; background: var(--amber-soft); font-size: 0.76rem; line-height: 1.45; }
.chat { min-height: 580px; overflow: hidden; }
.empty-state { display: grid; justify-items: center; padding: 58px 32px 42px; text-align: center; }
.empty-icon { display: grid; width: 52px; height: 52px; place-items: center; margin-bottom: 18px; border: 1px solid #bed3c6; border-radius: 16px; color: var(--green-dark); background: var(--green-soft); font-family: Georgia, serif; font-size: 1.6rem; }
.empty-state h2 { margin: 0; font-family: Georgia, serif; font-size: 1.55rem; font-weight: 500; }
.empty-state p { max-width: 540px; margin: 10px 0 22px; color: var(--muted); line-height: 1.55; }
.suggestions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.suggestions button { padding: 9px 12px; border: 1px solid var(--line); border-radius: 999px; background: white; cursor: pointer; transition: border-color 0.15s, transform 0.15s; }
.suggestions button:hover { border-color: var(--green); transform: translateY(-1px); }
.messages { display: flex; flex-direction: column; gap: 20px; padding: 30px 28px 8px; }
.message { display: grid; gap: 8px; }
.message-label { color: var(--muted); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; }
.message-user { align-self: end; width: min(82%, 680px); }
.message-user .message-body { padding: 13px 16px; border-radius: 16px 16px 4px 16px; color: white; background: var(--green-dark); white-space: pre-wrap; line-height: 1.55; }
.message-agent .message-body { padding: 18px; border: 1px solid var(--line); border-radius: 4px 16px 16px 16px; background: white; }
.message-agent.is-refused .message-body { border-color: #e2c377; background: #fffbef; }
.message-agent.is-uncertain .message-body, .message-error .message-body { border-color: #e4aaaa; background: var(--red-soft); }
.answer-text { white-space: pre-wrap; line-height: 1.68; overflow-wrap: anywhere; }
.status-line { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 14px; }
.badge { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border-radius: 999px; color: var(--green-dark); background: var(--green-soft); font-size: 0.72rem; font-weight: 750; }
.badge-warning { color: #7b4c0b; background: var(--amber-soft); }
.badge-error { color: var(--red); background: var(--red-soft); }
.answer-meta { margin-top: 14px; color: var(--muted); font-size: 0.72rem; }
.source-panel, .technical-panel { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 13px; }
details summary { color: var(--green-dark); font-size: 0.83rem; font-weight: 700; cursor: pointer; }
.source-list { display: grid; gap: 8px; margin-top: 10px; }
.source-card { padding: 10px 11px; border-radius: 9px; background: #f5f7f3; }
.source-id { color: var(--green-dark); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.75rem; font-weight: 800; }
.source-title { margin-top: 3px; font-size: 0.83rem; font-weight: 650; }
.source-meta { margin-top: 3px; color: var(--muted); font-size: 0.72rem; }
.conflict-box { margin-top: 14px; padding: 12px; border-left: 3px solid #d58a18; border-radius: 7px; background: var(--amber-soft); }
.conflict-box strong { display: block; margin-bottom: 5px; color: #744606; font-size: 0.78rem; }
.conflict-box p { margin: 0; color: #664d23; font-size: 0.82rem; line-height: 1.5; }
.follow-up { margin-top: 14px; padding: 12px; border-radius: 9px; background: var(--green-soft); }
.follow-up p { margin: 0 0 8px; font-size: 0.84rem; }
.query-list { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: 0.76rem; line-height: 1.5; }
.loading-row { display: flex; align-items: center; gap: 10px; color: var(--muted); }
.loader { width: 18px; height: 18px; border: 2px solid #cbd3cd; border-top-color: var(--green); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.composer { margin: 22px 20px 0; padding: 12px; border: 1px solid #cbd2cc; border-radius: 14px; background: white; box-shadow: 0 8px 30px rgba(38, 52, 43, 0.07); }
.composer:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23, 107, 77, 0.1); }
.composer textarea { width: 100%; resize: vertical; border: 0; outline: 0; color: var(--ink); background: transparent; line-height: 1.5; }
.composer textarea::placeholder { color: #909a93; }
.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.character-count { color: var(--muted); font-size: 0.69rem; }
.composer-actions { display: flex; gap: 8px; }
.primary, .secondary { min-height: 38px; padding: 8px 13px; border-radius: 9px; font-weight: 700; cursor: pointer; }
.primary { display: inline-flex; align-items: center; gap: 12px; border: 1px solid var(--green-dark); color: white; background: var(--green-dark); }
.primary:hover { background: var(--green); }
.primary:disabled { cursor: not-allowed; opacity: 0.55; }
.secondary { border: 1px solid var(--line); background: white; }
.secondary:hover { border-color: #9bac9f; }
.compact { min-height: auto; padding: 7px 9px; font-size: 0.72rem; }
.send-icon { font-size: 1.15rem; }
.footnote { margin: 12px 22px 20px; color: var(--muted); font-size: 0.7rem; text-align: center; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
[hidden] { display: none !important; }
@media (max-width: 800px) {
.shell { width: min(100% - 20px, 720px); }
.topbar { min-height: 68px; }
.brand small { display: none; }
.health span:last-child { max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hero { padding: 42px 4px 26px; }
.workspace { grid-template-columns: 1fr; }
.settings { position: static; padding: 13px 16px; }
.settings-heading { margin: 0; }
.icon-button { display: block; }
#settings-body { margin-top: 14px; }
.chat { min-height: 520px; }
.empty-state { padding: 42px 18px 30px; }
.messages { padding: 22px 14px 6px; }
.message-user { width: 92%; }
.composer { margin: 18px 10px 0; }
.footnote { margin-inline: 14px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
}