Add AI review workflow for Paperless documents

This commit is contained in:
2026-05-07 20:04:30 +02:00
parent 210b77876d
commit f913bc0ba6
24 changed files with 2169 additions and 15 deletions

View File

@@ -42,3 +42,17 @@ WATCHTOWER_ENABLE=false
# Optional fixed iCal token. # Optional fixed iCal token.
# ICAL_SECRET=replace-with-long-random-token # ICAL_SECRET=replace-with-long-random-token
# Optional AI document analysis.
# AI_ENABLED=false
# AI_PROVIDER=openai
# AI_BASE_URL=https://api.openai.com/v1
# AI_MODEL=gpt-4o-mini
# AI_API_KEY=replace-with-ai-api-key
# AI_SYSTEM_PROMPT=
# AI_TIMEOUT_SECONDS=60
# AI_MAX_TOKENS=2000
# Optional paperless workflow webhook secret.
# Paperless should send this value as x-contract-companion-secret.
# PAPERLESS_WEBHOOK_SECRET=replace-with-long-random-webhook-secret

View File

@@ -11,6 +11,7 @@ Begleitdienst zur Verwaltung von Vertragsmetadaten (Laufzeiten, Kündigungsfrist
-**Kategorie-Verwaltung:** Dropdown mit Vorschlägen, Inline-Neuanlage im Formular & Verwaltung unter Einstellungen. -**Kategorie-Verwaltung:** Dropdown mit Vorschlägen, Inline-Neuanlage im Formular & Verwaltung unter Einstellungen.
-**Modernes React-Frontend** (Vite + MUI) mit Dashboard, Vertragsliste, Kalender, Detailansicht und umfangreichen Einstellungen. -**Modernes React-Frontend** (Vite + MUI) mit Dashboard, Vertragsliste, Kalender, Detailansicht und umfangreichen Einstellungen.
-**Benachrichtigungen & Automatisierung:** Scheduler prüft Deadlines, optionaler Mailversand, ntfy-Push, iCal-Feed zum Abonnieren. -**Benachrichtigungen & Automatisierung:** Scheduler prüft Deadlines, optionaler Mailversand, ntfy-Push, iCal-Feed zum Abonnieren.
-**AI-gestützte Dokumentprüfung:** Paperless-Webhooks können neue Dokumente zur Analyse einreihen; Verträge werden nach manueller Freigabe angelegt und nach Paperless zurückgeschrieben.
-**Konfigurierbare Authentifizierung:** Optionales Login mit JWT, Benutzername/Passwort verwaltbar in den Settings. -**Konfigurierbare Authentifizierung:** Optionales Login mit JWT, Benutzername/Passwort verwaltbar in den Settings.
-**Mehrsprachigkeit:** UI derzeit auf Deutsch und Englisch lokalisiert. -**Mehrsprachigkeit:** UI derzeit auf Deutsch und Englisch lokalisiert.
-**Docker-ready:** Backend und Frontend als Container, Konfiguration via `docker-compose.yml`. -**Docker-ready:** Backend und Frontend als Container, Konfiguration via `docker-compose.yml`.
@@ -99,6 +100,12 @@ Für Portainer-Deployments ist `docker-compose.yml` als Git-basierter Stack vorb
| `MAIL_SERVER` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` | *(leer)* | SMTP-Settings (TLS optional über `MAIL_USE_TLS`). | | `MAIL_SERVER` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` | *(leer)* | SMTP-Settings (TLS optional über `MAIL_USE_TLS`). |
| `MAIL_FROM` / `MAIL_TO` | *(leer)* | Absender/Empfänger für Mail-Benachrichtigungen. | | `MAIL_FROM` / `MAIL_TO` | *(leer)* | Absender/Empfänger für Mail-Benachrichtigungen. |
| `ICAL_SECRET` | *(leer)* | Optional vorgegebenes Token für den iCal-Feed. | | `ICAL_SECRET` | *(leer)* | Optional vorgegebenes Token für den iCal-Feed. |
| `AI_ENABLED` | `false` | Aktiviert AI-Analyse, wenn Provider, Modell und API-Key gesetzt sind. |
| `AI_PROVIDER` | *(leer)* | `openai`, `openai-compatible` oder `gemini`. |
| `AI_BASE_URL` | *(Provider-Standard)* | Optionaler Endpoint für OpenAI-kompatible Anbieter oder eigene Gateways. |
| `AI_MODEL` | *(leer)* | Modellname für die Vertragsanalyse. |
| `AI_API_KEY` | *(leer)* | API-Key des AI-Anbieters. |
| `PAPERLESS_WEBHOOK_SECRET` | *(leer)* | Shared Secret für Paperless-Webhooks an `/api/integrations/paperless/webhook`. |
### Paperless optional nutzen ### Paperless optional nutzen
@@ -124,6 +131,9 @@ Die Sprache der automatisch verschickten Benachrichtigungen (Mail, ntfy) kannst
- `POST /categories` Neue Kategorie (legt an oder liefert vorhandene). - `POST /categories` Neue Kategorie (legt an oder liefert vorhandene).
- `DELETE /categories/:id` Kategorie entfernen. - `DELETE /categories/:id` Kategorie entfernen.
- `GET /integrations/paperless/search?q=` Paperless-Dokumente per Text oder ID finden. - `GET /integrations/paperless/search?q=` Paperless-Dokumente per Text oder ID finden.
- `POST /integrations/paperless/webhook` Neues Paperless-Dokument zur AI-Prüfung einreihen.
- `GET /ai/reviews` AI-Prüfwarteschlange abrufen.
- `POST /ai/reviews/:id/approve` Vertragsentwurf freigeben, Vertrag anlegen und Paperless aktualisieren.
- `GET /reports/upcoming?days=` Deadlines innerhalb der nächsten `days` Tage. - `GET /reports/upcoming?days=` Deadlines innerhalb der nächsten `days` Tage.
- `GET /calendar/feed.ics?token=` iCal-Feed für Kündigungsfristen. - `GET /calendar/feed.ics?token=` iCal-Feed für Kündigungsfristen.

View File

@@ -32,6 +32,15 @@ services:
- MAIL_FROM - MAIL_FROM
- MAIL_TO - MAIL_TO
- ICAL_SECRET - ICAL_SECRET
- AI_ENABLED
- AI_PROVIDER
- AI_BASE_URL
- AI_MODEL
- AI_API_KEY
- AI_SYSTEM_PROMPT
- AI_TIMEOUT_SECONDS
- AI_MAX_TOKENS
- PAPERLESS_WEBHOOK_SECRET
volumes: volumes:
- contracts-data:/app/data - contracts-data:/app/data
ports: ports:

View File

@@ -34,9 +34,25 @@ Set these values in Portainer before deploying:
| `PAPERLESS_EXTERNAL_URL` | unset | Public paperless-ngx URL for browser links. | | `PAPERLESS_EXTERNAL_URL` | unset | Public paperless-ngx URL for browser links. |
| `PAPERLESS_TOKEN` | unset | paperless-ngx API token. | | `PAPERLESS_TOKEN` | unset | paperless-ngx API token. |
| `WATCHTOWER_ENABLE` | `false` | Enables Watchtower updates when set to `true`. | | `WATCHTOWER_ENABLE` | `false` | Enables Watchtower updates when set to `true`. |
| `AI_ENABLED` | `false` | Enables AI document analysis when provider settings are complete. |
| `AI_PROVIDER` | unset | `openai`, `openai-compatible`, or `gemini`. |
| `AI_BASE_URL` | provider default | Optional endpoint for OpenAI-compatible providers or custom gateways. |
| `AI_MODEL` | unset | Model name used for extraction. |
| `AI_API_KEY` | unset | API key for the selected AI provider. |
| `PAPERLESS_WEBHOOK_SECRET` | unset | Shared secret Paperless sends to `/api/integrations/paperless/webhook`. |
Leave optional variables unset when they are not used. The backend treats blank optional variables as absent, which keeps Portainer edits from breaking URL validation. Leave optional variables unset when they are not used. The backend treats blank optional variables as absent, which keeps Portainer edits from breaking URL validation.
## Paperless Webhook
Create a Paperless workflow for newly consumed documents and add an HTTP webhook action pointing to:
```text
https://your-contract-app.example.com/api/integrations/paperless/webhook
```
Send the document id in the JSON body as `document_id` and include the shared secret as header `x-contract-companion-secret`. The app will enqueue the document, analyze it with the configured AI provider, and wait for manual approval before writing tags/custom fields back to Paperless.
## Data and Networking ## Data and Networking
- Contract data is stored in the named Docker volume `contracts-data`. - Contract data is stored in the named Docker volume `contracts-data`.

View File

@@ -3,6 +3,8 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import { useAuth } from "./contexts/AuthContext"; import { useAuth } from "./contexts/AuthContext";
import AiReviewDetail from "./routes/AiReviewDetail";
import AiReviewList from "./routes/AiReviewList";
import CalendarView from "./routes/CalendarView"; import CalendarView from "./routes/CalendarView";
import ContractDetail from "./routes/ContractDetail"; import ContractDetail from "./routes/ContractDetail";
import ContractForm from "./routes/ContractForm"; import ContractForm from "./routes/ContractForm";
@@ -45,6 +47,8 @@ export default function App() {
> >
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="ai-reviews" element={<AiReviewList />} />
<Route path="ai-reviews/:reviewId" element={<AiReviewDetail />} />
<Route path="contracts" element={<ContractsList />} /> <Route path="contracts" element={<ContractsList />} />
<Route path="contracts/new" element={<ContractForm mode="create" />} /> <Route path="contracts/new" element={<ContractForm mode="create" />} />
<Route path="contracts/:contractId" element={<ContractDetail />} /> <Route path="contracts/:contractId" element={<ContractDetail />} />

View File

@@ -0,0 +1,33 @@
import { AiReview, AiReviewStatus, Contract, ContractPayload } from "../types";
import { request } from "./client";
export async function fetchAiReviews(status?: AiReviewStatus): Promise<AiReview[]> {
const params = new URLSearchParams();
if (status) {
params.set("status", status);
}
const query = params.toString();
return request<AiReview[]>(`/ai/reviews${query ? `?${query}` : ""}`, { method: "GET" });
}
export async function fetchAiReview(id: number): Promise<AiReview> {
return request<AiReview>(`/ai/reviews/${id}`, { method: "GET" });
}
export async function retryAiReview(id: number): Promise<AiReview> {
return request<AiReview>(`/ai/reviews/${id}/retry`, { method: "POST" });
}
export async function rejectAiReview(id: number): Promise<AiReview> {
return request<AiReview>(`/ai/reviews/${id}/reject`, { method: "POST" });
}
export async function approveAiReview(
id: number,
contract: ContractPayload
): Promise<{ review: AiReview; contract: Contract }> {
return request<{ review: AiReview; contract: Contract }>(`/ai/reviews/${id}/approve`, {
method: "POST",
body: { contract }
});
}

View File

@@ -18,6 +18,10 @@ export interface ServerConfig {
ntfyConfigured: boolean; ntfyConfigured: boolean;
authEnabled: boolean; authEnabled: boolean;
authTokenExpiresInHours: number; authTokenExpiresInHours: number;
aiEnabled: boolean;
aiConfigured: boolean;
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
paperlessWebhookConfigured: boolean;
} }
export async function fetchServerConfig(): Promise<ServerConfig> { export async function fetchServerConfig(): Promise<ServerConfig> {
@@ -42,12 +46,21 @@ export interface SettingsResponse {
ntfyTopic: string | null; ntfyTopic: string | null;
ntfyPriority: string | null; ntfyPriority: string | null;
authUsername: string | null; authUsername: string | null;
aiEnabled: boolean;
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
aiBaseUrl: string | null;
aiModel: string | null;
aiSystemPrompt: string | null;
aiTimeoutSeconds: number;
aiMaxTokens: number;
}; };
secrets: { secrets: {
paperlessTokenSet: boolean; paperlessTokenSet: boolean;
mailPasswordSet: boolean; mailPasswordSet: boolean;
ntfyTokenSet: boolean; ntfyTokenSet: boolean;
authPasswordSet: boolean; authPasswordSet: boolean;
aiApiKeySet: boolean;
paperlessWebhookSecretSet: boolean;
}; };
icalSecret: string | null; icalSecret: string | null;
} }
@@ -74,6 +87,15 @@ export type UpdateSettingsPayload = Partial<{
authUsername: string | null; authUsername: string | null;
authPassword: string | null; authPassword: string | null;
icalSecret: string | null; icalSecret: string | null;
aiEnabled: boolean;
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
aiBaseUrl: string | null;
aiModel: string | null;
aiApiKey: string | null;
aiSystemPrompt: string | null;
aiTimeoutSeconds: number;
aiMaxTokens: number;
paperlessWebhookSecret: string | null;
}>; }>;
export async function fetchSettings(): Promise<SettingsResponse> { export async function fetchSettings(): Promise<SettingsResponse> {
@@ -95,3 +117,7 @@ export async function triggerMailTest(): Promise<void> {
export async function triggerNtfyTest(): Promise<void> { export async function triggerNtfyTest(): Promise<void> {
await request("/settings/test/ntfy", { method: "POST" }); await request("/settings/test/ntfy", { method: "POST" });
} }
export async function triggerAiTest(): Promise<void> {
await request("/settings/test/ai", { method: "POST" });
}

View File

@@ -3,6 +3,7 @@ import LogoutIcon from "@mui/icons-material/Logout";
import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
import DashboardIcon from "@mui/icons-material/Dashboard"; import DashboardIcon from "@mui/icons-material/Dashboard";
import DescriptionIcon from "@mui/icons-material/Description"; import DescriptionIcon from "@mui/icons-material/Description";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import { import {
AppBar, AppBar,
@@ -33,6 +34,7 @@ const drawerWidth = 240;
const navItems = [ const navItems = [
{ key: "nav.dashboard", icon: <DashboardIcon />, path: "/dashboard" }, { key: "nav.dashboard", icon: <DashboardIcon />, path: "/dashboard" },
{ key: "nav.aiReviews", icon: <AutoAwesomeIcon />, path: "/ai-reviews" },
{ key: "nav.contracts", icon: <DescriptionIcon />, path: "/contracts" }, { key: "nav.contracts", icon: <DescriptionIcon />, path: "/contracts" },
{ key: "nav.calendar", icon: <CalendarMonthIcon />, path: "/calendar" }, { key: "nav.calendar", icon: <CalendarMonthIcon />, path: "/calendar" },
{ key: "nav.settings", icon: <SettingsIcon />, path: "/settings" } { key: "nav.settings", icon: <SettingsIcon />, path: "/settings" }

View File

@@ -1,6 +1,7 @@
{ {
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"aiReviews": "AI-Prüfung",
"contracts": "Verträge", "contracts": "Verträge",
"calendar": "Kalender", "calendar": "Kalender",
"settings": "Einstellungen", "settings": "Einstellungen",
@@ -175,6 +176,29 @@
"scheduler": "Fristen & Benachrichtigungen", "scheduler": "Fristen & Benachrichtigungen",
"mail": "E-Mail", "mail": "E-Mail",
"ntfy": "ntfy Push", "ntfy": "ntfy Push",
"ai": "AI-Analyse",
"aiEnabled": "AI-Analyse aktivieren",
"aiProvider": "AI-Anbieter",
"aiProviderNone": "Kein Anbieter",
"aiBaseUrl": "AI Base URL",
"aiBaseUrlPlaceholder": "Leer lassen für Anbieter-Standard",
"aiBaseUrlHelp": "Für OpenAI-kompatible Anbieter oder eigene Gateways.",
"aiModel": "Modell",
"aiApiKey": "API-Key",
"aiApiKeyNew": "Neuen API-Key hinterlegen",
"aiApiKeyRemove": "API-Key löschen",
"aiApiKeyInfo": "Ein API-Key ist hinterlegt. Lasse das Feld leer, um ihn unverändert zu lassen.",
"aiSystemPrompt": "Systemprompt",
"aiTimeout": "Timeout (Sekunden)",
"aiMaxTokens": "Max Tokens",
"aiTest": "AI testen",
"aiTestSuccess": "AI-Test erfolgreich",
"aiTestError": "AI-Test fehlgeschlagen",
"webhookSecret": "Paperless Webhook-Secret",
"webhookSecretNew": "Neues Webhook-Secret hinterlegen",
"webhookSecretRemove": "Webhook-Secret löschen",
"webhookSecretInfo": "Ein Webhook-Secret ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.",
"webhookSecretHelp": "Paperless sendet diesen Wert im Header x-contract-companion-secret.",
"ical": "iCal-Abo", "ical": "iCal-Abo",
"icalFeedUrl": "Feed-URL", "icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL", "paperlessApiUrl": "Paperless API URL",
@@ -261,6 +285,46 @@
"correspondent": "Korrespondent", "correspondent": "Korrespondent",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"aiReviews": {
"title": "AI-Prüfung",
"subtitle": "Neue Paperless-Dokumente prüfen, Vertragsentwürfe freigeben und nach Paperless zurückschreiben.",
"refresh": "Aktualisieren",
"aiNotConfigured": "AI ist noch nicht vollständig konfiguriert.",
"webhookNotConfigured": "Das Paperless Webhook-Secret ist noch nicht gesetzt.",
"document": "Dokument",
"review": "Prüfen",
"loadError": "AI-Prüfungen konnten nicht geladen werden.",
"empty": "Noch keine AI-Prüfungen vorhanden.",
"detailSubtitle": "Paperless-Dokument #{{id}}",
"summary": "AI-Zusammenfassung",
"detectedContract": "Die AI stuft das Dokument als Vertrag ein.",
"detectedNoContract": "Die AI stuft das Dokument nicht als Vertrag ein.",
"noAnalysis": "Noch keine Analyse vorhanden.",
"contractDraft": "Vertragsentwurf",
"noContractDraft": "Die Analyse enthält keinen Vertragsentwurf. Du kannst manuell Daten ergänzen oder den Fall ablehnen.",
"approve": "Freigeben und Vertrag anlegen",
"approved": "Vertrag angelegt und Paperless aktualisiert",
"reject": "Ablehnen",
"rejected": "Prüfung abgelehnt",
"retry": "Erneut analysieren",
"retryStarted": "Analyse erneut gestartet",
"actionFailed": "Aktion fehlgeschlagen",
"columns": {
"document": "Dokument",
"status": "Status",
"confidence": "Sicherheit",
"updated": "Aktualisiert",
"action": "Aktion"
},
"status": {
"pending": "Wartet",
"analyzing": "Analyse läuft",
"needs_review": "Prüfung nötig",
"approved": "Freigegeben",
"rejected": "Abgelehnt",
"failed": "Fehler"
}
},
"login": { "login": {
"title": "Anmeldung", "title": "Anmeldung",
"welcome": "Willkommen zurück! Bitte melde dich an.", "welcome": "Willkommen zurück! Bitte melde dich an.",

View File

@@ -1,6 +1,7 @@
{ {
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"aiReviews": "AI review",
"contracts": "Contracts", "contracts": "Contracts",
"calendar": "Calendar", "calendar": "Calendar",
"settings": "Settings", "settings": "Settings",
@@ -175,6 +176,29 @@
"scheduler": "Deadlines & notifications", "scheduler": "Deadlines & notifications",
"mail": "E-mail", "mail": "E-mail",
"ntfy": "ntfy push", "ntfy": "ntfy push",
"ai": "AI analysis",
"aiEnabled": "Enable AI analysis",
"aiProvider": "AI provider",
"aiProviderNone": "No provider",
"aiBaseUrl": "AI base URL",
"aiBaseUrlPlaceholder": "Leave empty for provider default",
"aiBaseUrlHelp": "For OpenAI-compatible providers or custom gateways.",
"aiModel": "Model",
"aiApiKey": "API key",
"aiApiKeyNew": "Provide new API key",
"aiApiKeyRemove": "Remove API key",
"aiApiKeyInfo": "An API key is stored. Leave empty to keep it.",
"aiSystemPrompt": "System prompt",
"aiTimeout": "Timeout (seconds)",
"aiMaxTokens": "Max tokens",
"aiTest": "Test AI",
"aiTestSuccess": "AI test successful",
"aiTestError": "AI test failed",
"webhookSecret": "Paperless webhook secret",
"webhookSecretNew": "Provide new webhook secret",
"webhookSecretRemove": "Remove webhook secret",
"webhookSecretInfo": "A webhook secret is stored. Leave empty to keep it.",
"webhookSecretHelp": "Paperless sends this value in the x-contract-companion-secret header.",
"ical": "iCal subscription", "ical": "iCal subscription",
"icalFeedUrl": "Feed URL", "icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL", "paperlessApiUrl": "Paperless API URL",
@@ -261,6 +285,46 @@
"correspondent": "Correspondent", "correspondent": "Correspondent",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"aiReviews": {
"title": "AI review",
"subtitle": "Review new Paperless documents, approve contract drafts, and write metadata back to Paperless.",
"refresh": "Refresh",
"aiNotConfigured": "AI is not fully configured yet.",
"webhookNotConfigured": "The Paperless webhook secret is not set yet.",
"document": "Document",
"review": "Review",
"loadError": "Unable to load AI reviews.",
"empty": "No AI reviews yet.",
"detailSubtitle": "Paperless document #{{id}}",
"summary": "AI summary",
"detectedContract": "AI classified this document as a contract.",
"detectedNoContract": "AI did not classify this document as a contract.",
"noAnalysis": "No analysis available yet.",
"contractDraft": "Contract draft",
"noContractDraft": "The analysis contains no contract draft. You can add data manually or reject the review.",
"approve": "Approve and create contract",
"approved": "Contract created and Paperless updated",
"reject": "Reject",
"rejected": "Review rejected",
"retry": "Analyze again",
"retryStarted": "Analysis restarted",
"actionFailed": "Action failed",
"columns": {
"document": "Document",
"status": "Status",
"confidence": "Confidence",
"updated": "Updated",
"action": "Action"
},
"status": {
"pending": "Pending",
"analyzing": "Analyzing",
"needs_review": "Needs review",
"approved": "Approved",
"rejected": "Rejected",
"failed": "Failed"
}
},
"login": { "login": {
"title": "Sign in", "title": "Sign in",
"welcome": "Welcome back! Please sign in.", "welcome": "Welcome back! Please sign in.",

View File

@@ -0,0 +1,328 @@
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import LaunchIcon from "@mui/icons-material/Launch";
import ReplayIcon from "@mui/icons-material/Replay";
import {
Alert,
Box,
Button,
Chip,
FormControlLabel,
Grid,
Paper,
Stack,
Switch,
TextField,
Typography
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { approveAiReview, fetchAiReview, rejectAiReview, retryAiReview } from "../api/aiReviews";
import { fetchServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
import { ContractPayload } from "../types";
type FormState = {
title: string;
provider: string;
category: string;
contractStartDate: string;
contractEndDate: string;
terminationNoticeDays: string;
renewalPeriodMonths: string;
autoRenew: boolean;
price: string;
currency: string;
notes: string;
tags: string;
};
const emptyForm: FormState = {
title: "",
provider: "",
category: "",
contractStartDate: "",
contractEndDate: "",
terminationNoticeDays: "",
renewalPeriodMonths: "",
autoRenew: false,
price: "",
currency: "EUR",
notes: "",
tags: ""
};
function toStringValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
function parseNumber(value: string, integer = false): number | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number(trimmed.replace(",", "."));
if (!Number.isFinite(parsed)) {
throw new Error("Invalid number");
}
return integer ? Math.round(parsed) : parsed;
}
export default function AiReviewDetail() {
const { reviewId } = useParams<{ reviewId: string }>();
const id = reviewId ? Number(reviewId) : null;
const navigate = useNavigate();
const { t } = useTranslation();
const { showMessage } = useSnackbar();
const queryClient = useQueryClient();
const [form, setForm] = useState<FormState>(emptyForm);
const {
data: review,
isLoading,
isError
} = useQuery({
queryKey: ["ai-review", id],
queryFn: () => fetchAiReview(id ?? 0),
enabled: id !== null
});
const { data: serverConfig } = useQuery({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
useEffect(() => {
if (!review?.contractPayload) {
return;
}
const payload = review.contractPayload;
setForm({
title: payload.title ?? "",
provider: payload.provider ?? "",
category: payload.category ?? "",
contractStartDate: payload.contractStartDate ?? "",
contractEndDate: payload.contractEndDate ?? "",
terminationNoticeDays: toStringValue(payload.terminationNoticeDays),
renewalPeriodMonths: toStringValue(payload.renewalPeriodMonths),
autoRenew: payload.autoRenew ?? false,
price: toStringValue(payload.price),
currency: payload.currency ?? "EUR",
notes: payload.notes ?? "",
tags: payload.tags?.join(", ") ?? ""
});
}, [review]);
const paperlessUrl = useMemo(() => {
if (!review || !serverConfig) {
return null;
}
const base = serverConfig.paperlessExternalUrl ?? serverConfig.paperlessBaseUrl;
return base ? `${base.replace(/\/$/, "")}/documents/${review.paperlessDocumentId}` : null;
}, [review, serverConfig]);
const refreshQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["ai-reviews"] }),
queryClient.invalidateQueries({ queryKey: ["ai-review", id] }),
queryClient.invalidateQueries({ queryKey: ["contracts"] })
]);
};
const retryMutation = useMutation({
mutationFn: () => retryAiReview(id ?? 0),
onSuccess: async () => {
showMessage(t("aiReviews.retryStarted"), "success");
await refreshQueries();
},
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
});
const rejectMutation = useMutation({
mutationFn: () => rejectAiReview(id ?? 0),
onSuccess: async () => {
showMessage(t("aiReviews.rejected"), "success");
await refreshQueries();
navigate("/ai-reviews");
},
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
});
const approveMutation = useMutation({
mutationFn: () => {
const contract: ContractPayload = {
title: form.title.trim(),
provider: form.provider.trim() || null,
category: form.category.trim() || null,
paperlessDocumentId: review?.paperlessDocumentId ?? null,
contractStartDate: form.contractStartDate || null,
contractEndDate: form.contractEndDate || null,
terminationNoticeDays: parseNumber(form.terminationNoticeDays, true),
renewalPeriodMonths: parseNumber(form.renewalPeriodMonths, true),
autoRenew: form.autoRenew,
price: parseNumber(form.price),
currency: form.currency.trim() || "EUR",
notes: form.notes.trim() || null,
tags: form.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
};
return approveAiReview(id ?? 0, contract);
},
onSuccess: async (result) => {
showMessage(t("aiReviews.approved"), "success");
await refreshQueries();
navigate(`/contracts/${result.contract.id}`);
},
onError: (error: Error) => {
const message = error.message === "Invalid number" ? t("contractForm.invalidNumber") : error.message;
showMessage(message ?? t("aiReviews.actionFailed"), "error");
}
});
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
if (isLoading) {
return <Typography>{t("messages.loading")}</Typography>;
}
if (isError || !review) {
return <Alert severity="error">{t("aiReviews.loadError")}</Alert>;
}
const canApprove = review.status !== "approved" && review.status !== "rejected" && Boolean(form.title.trim());
return (
<>
<PageHeader
title={review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
subtitle={t("aiReviews.detailSubtitle", { id: review.paperlessDocumentId })}
action={
paperlessUrl ? (
<Button href={paperlessUrl} target="_blank" rel="noreferrer" startIcon={<LaunchIcon />} variant="outlined">
{t("contractDetail.openInPaperless")}
</Button>
) : undefined
}
/>
<Grid container spacing={3}>
<Grid item xs={12} md={5}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<Chip label={t(`aiReviews.status.${review.status}`)} />
{review.confidence !== null && (
<Chip label={`${Math.round(review.confidence * 100)}%`} color="primary" variant="outlined" />
)}
</Box>
{review.error && <Alert severity="error">{review.error}</Alert>}
{review.analysis ? (
<>
<Typography variant="h6">{t("aiReviews.summary")}</Typography>
<Typography whiteSpace="pre-wrap">{review.analysis.summary}</Typography>
<Typography variant="body2" color="text.secondary">
{review.analysis.isContract ? t("aiReviews.detectedContract") : t("aiReviews.detectedNoContract")}
</Typography>
</>
) : (
<Alert severity="info">{t("aiReviews.noAnalysis")}</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<Button
startIcon={<ReplayIcon />}
variant="outlined"
onClick={() => retryMutation.mutate()}
disabled={retryMutation.isPending || review.status === "approved"}
>
{t("aiReviews.retry")}
</Button>
<Button
startIcon={<CloseIcon />}
color="warning"
variant="outlined"
onClick={() => rejectMutation.mutate()}
disabled={rejectMutation.isPending || review.status === "approved" || review.status === "rejected"}
>
{t("aiReviews.reject")}
</Button>
</Stack>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={7}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Stack spacing={2}>
<Typography variant="h6">{t("aiReviews.contractDraft")}</Typography>
{!review.contractPayload && (
<Alert severity="warning">{t("aiReviews.noContractDraft")}</Alert>
)}
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.title")} value={form.title} onChange={(event) => updateField("title", event.target.value)} fullWidth required />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.provider")} value={form.provider} onChange={(event) => updateField("provider", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.category")} value={form.category} onChange={(event) => updateField("category", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.currency")} value={form.currency} onChange={(event) => updateField("currency", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField type="date" label={t("contractForm.fields.contractStart")} value={form.contractStartDate} onChange={(event) => updateField("contractStartDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField type="date" label={t("contractForm.fields.contractEnd")} value={form.contractEndDate} onChange={(event) => updateField("contractEndDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
</Grid>
<Grid item xs={12} md={4}>
<TextField label={t("contractForm.fields.terminationNotice")} value={form.terminationNoticeDays} onChange={(event) => updateField("terminationNoticeDays", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={4}>
<TextField label={t("contractForm.fields.renewalPeriod")} value={form.renewalPeriodMonths} onChange={(event) => updateField("renewalPeriodMonths", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={4}>
<TextField label={t("contractForm.fields.price")} value={form.price} onChange={(event) => updateField("price", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={<Switch checked={form.autoRenew} onChange={(event) => updateField("autoRenew", event.target.checked)} />}
label={t("contractForm.fields.autoRenew")}
/>
</Grid>
<Grid item xs={12}>
<TextField label={t("contractForm.fields.tags")} value={form.tags} onChange={(event) => updateField("tags", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12}>
<TextField label={t("contractForm.fields.notes")} value={form.notes} onChange={(event) => updateField("notes", event.target.value)} fullWidth multiline minRows={4} />
</Grid>
</Grid>
<Box display="flex" justifyContent="flex-end">
<Button
startIcon={<CheckIcon />}
variant="contained"
onClick={() => approveMutation.mutate()}
disabled={!canApprove || approveMutation.isPending}
>
{t("aiReviews.approve")}
</Button>
</Box>
</Stack>
</Paper>
</Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,154 @@
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Alert,
Box,
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { fetchAiReviews } from "../api/aiReviews";
import { fetchServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { formatDate } from "../utils/date";
function statusColor(status: string): "default" | "primary" | "success" | "warning" | "error" {
if (status === "approved") return "success";
if (status === "needs_review") return "primary";
if (status === "failed") return "error";
if (status === "analyzing" || status === "pending") return "warning";
return "default";
}
export default function AiReviewList() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
data: reviews,
isLoading,
isError,
refetch,
isFetching
} = useQuery({
queryKey: ["ai-reviews"],
queryFn: () => fetchAiReviews()
});
const { data: serverConfig } = useQuery({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
return (
<>
<PageHeader
title={t("aiReviews.title")}
subtitle={t("aiReviews.subtitle")}
action={
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => refetch()}
disabled={isFetching}
>
{t("aiReviews.refresh")}
</Button>
}
/>
{!serverConfig?.aiConfigured && (
<Alert severity="warning" sx={{ mb: 2 }}>
{t("aiReviews.aiNotConfigured")}
</Alert>
)}
{!serverConfig?.paperlessWebhookConfigured && (
<Alert severity="info" sx={{ mb: 2 }}>
{t("aiReviews.webhookNotConfigured")}
</Alert>
)}
<Paper variant="outlined" sx={{ borderRadius: 3, p: 2.5 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("aiReviews.columns.document")}</TableCell>
<TableCell>{t("aiReviews.columns.status")}</TableCell>
<TableCell>{t("aiReviews.columns.confidence")}</TableCell>
<TableCell>{t("aiReviews.columns.updated")}</TableCell>
<TableCell align="right">{t("aiReviews.columns.action")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={5}>{t("messages.loading")}</TableCell>
</TableRow>
)}
{isError && (
<TableRow>
<TableCell colSpan={5}>
<Typography color="error">{t("aiReviews.loadError")}</Typography>
</TableCell>
</TableRow>
)}
{!isLoading && !isError && (reviews ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5}>
<Box display="flex" alignItems="center" gap={1}>
<AutoAwesomeIcon color="disabled" />
<Typography color="text.secondary">{t("aiReviews.empty")}</Typography>
</Box>
</TableCell>
</TableRow>
)}
{(reviews ?? []).map((review) => (
<TableRow
hover
key={review.id}
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/ai-reviews/${review.id}`)}
>
<TableCell>
<Typography fontWeight={600}>
{review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
</Typography>
<Typography variant="caption" color="text.secondary">
Paperless #{review.paperlessDocumentId}
</Typography>
</TableCell>
<TableCell>
<Chip
size="small"
color={statusColor(review.status)}
label={t(`aiReviews.status.${review.status}`)}
/>
</TableCell>
<TableCell>
{review.confidence !== null ? `${Math.round(review.confidence * 100)}%` : "-"}
</TableCell>
<TableCell>{formatDate(review.updatedAt, "dd.MM.yyyy HH:mm")}</TableCell>
<TableCell align="right">
<Button size="small" onClick={(event) => {
event.stopPropagation();
navigate(`/ai-reviews/${review.id}`);
}}>
{t("aiReviews.review")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</>
);
}

View File

@@ -35,6 +35,7 @@ import {
fetchServerConfig, fetchServerConfig,
fetchSettings, fetchSettings,
resetIcalSecret, resetIcalSecret,
triggerAiTest,
triggerMailTest, triggerMailTest,
triggerNtfyTest, triggerNtfyTest,
ServerConfig, ServerConfig,
@@ -75,6 +76,15 @@ type FormValues = {
ntfyPriority: string; ntfyPriority: string;
authUsername: string; authUsername: string;
authPassword: string; authPassword: string;
aiEnabled: boolean;
aiProvider: "openai" | "openai-compatible" | "gemini" | "";
aiBaseUrl: string;
aiModel: string;
aiApiKey: string;
aiSystemPrompt: string;
aiTimeoutSeconds: number;
aiMaxTokens: number;
paperlessWebhookSecret: string;
}; };
const defaultValues: FormValues = { const defaultValues: FormValues = {
@@ -97,7 +107,16 @@ const defaultValues: FormValues = {
ntfyToken: "", ntfyToken: "",
ntfyPriority: "default", ntfyPriority: "default",
authUsername: "", authUsername: "",
authPassword: "" authPassword: "",
aiEnabled: false,
aiProvider: "",
aiBaseUrl: "",
aiModel: "",
aiApiKey: "",
aiSystemPrompt: "",
aiTimeoutSeconds: 60,
aiMaxTokens: 2000,
paperlessWebhookSecret: ""
}; };
export default function SettingsPage() { export default function SettingsPage() {
@@ -146,6 +165,8 @@ export default function SettingsPage() {
const [removeMailPassword, setRemoveMailPassword] = useState(false); const [removeMailPassword, setRemoveMailPassword] = useState(false);
const [removeNtfyToken, setRemoveNtfyToken] = useState(false); const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
const [removeAuthPassword, setRemoveAuthPassword] = useState(false); const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
const [removeAiApiKey, setRemoveAiApiKey] = useState(false);
const [removeWebhookSecret, setRemoveWebhookSecret] = useState(false);
const { const {
control, control,
@@ -182,7 +203,16 @@ export default function SettingsPage() {
ntfyToken: "", ntfyToken: "",
ntfyPriority: settingsData.values.ntfyPriority ?? "default", ntfyPriority: settingsData.values.ntfyPriority ?? "default",
authUsername: settingsData.values.authUsername ?? "", authUsername: settingsData.values.authUsername ?? "",
authPassword: "" authPassword: "",
aiEnabled: settingsData.values.aiEnabled ?? false,
aiProvider: settingsData.values.aiProvider ?? "",
aiBaseUrl: settingsData.values.aiBaseUrl ?? "",
aiModel: settingsData.values.aiModel ?? "",
aiApiKey: "",
aiSystemPrompt: settingsData.values.aiSystemPrompt ?? "",
aiTimeoutSeconds: settingsData.values.aiTimeoutSeconds ?? 60,
aiMaxTokens: settingsData.values.aiMaxTokens ?? 2000,
paperlessWebhookSecret: ""
}; };
initialValuesRef.current = values; initialValuesRef.current = values;
@@ -190,6 +220,8 @@ export default function SettingsPage() {
setRemoveMailPassword(false); setRemoveMailPassword(false);
setRemoveNtfyToken(false); setRemoveNtfyToken(false);
setRemoveAuthPassword(false); setRemoveAuthPassword(false);
setRemoveAiApiKey(false);
setRemoveWebhookSecret(false);
reset(values); reset(values);
}, [settingsData, reset]); }, [settingsData, reset]);
@@ -225,6 +257,12 @@ export default function SettingsPage() {
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error") onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
}); });
const aiTestMutation = useMutation({
mutationFn: () => triggerAiTest(),
onSuccess: () => showMessage(t("settings.aiTestSuccess"), "success"),
onError: (error: Error) => showMessage(error.message ?? t("settings.aiTestError"), "error")
});
const createCategoryMutation = useMutation({ const createCategoryMutation = useMutation({
mutationFn: (name: string) => apiCreateCategory(name), mutationFn: (name: string) => apiCreateCategory(name),
onSuccess: async (category) => { onSuccess: async (category) => {
@@ -386,6 +424,38 @@ export default function SettingsPage() {
payload.authPassword = formValues.authPassword.trim(); payload.authPassword = formValues.authPassword.trim();
} }
if (formValues.aiEnabled !== initial.aiEnabled) {
payload.aiEnabled = formValues.aiEnabled;
}
if (formValues.aiProvider !== initial.aiProvider) {
payload.aiProvider = formValues.aiProvider ? formValues.aiProvider : null;
}
if (formValues.aiBaseUrl !== initial.aiBaseUrl) {
payload.aiBaseUrl = trimOrNull(formValues.aiBaseUrl);
}
if (formValues.aiModel !== initial.aiModel) {
payload.aiModel = trimOrNull(formValues.aiModel);
}
if (removeAiApiKey) {
payload.aiApiKey = null;
} else if (formValues.aiApiKey.trim().length > 0) {
payload.aiApiKey = formValues.aiApiKey.trim();
}
if (formValues.aiSystemPrompt !== initial.aiSystemPrompt) {
payload.aiSystemPrompt = trimOrNull(formValues.aiSystemPrompt);
}
if (formValues.aiTimeoutSeconds !== initial.aiTimeoutSeconds) {
payload.aiTimeoutSeconds = formValues.aiTimeoutSeconds;
}
if (formValues.aiMaxTokens !== initial.aiMaxTokens) {
payload.aiMaxTokens = formValues.aiMaxTokens;
}
if (removeWebhookSecret) {
payload.paperlessWebhookSecret = null;
} else if (formValues.paperlessWebhookSecret.trim().length > 0) {
payload.paperlessWebhookSecret = formValues.paperlessWebhookSecret.trim();
}
if (Object.keys(payload).length === 0) { if (Object.keys(payload).length === 0) {
showMessage(t("settings.noChanges"), "info"); showMessage(t("settings.noChanges"), "info");
return; return;
@@ -425,6 +495,8 @@ export default function SettingsPage() {
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false; const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false; const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
const authPasswordSet = settings?.secrets.authPasswordSet ?? false; const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
const aiApiKeySet = settings?.secrets.aiApiKeySet ?? false;
const paperlessWebhookSecretSet = settings?.secrets.paperlessWebhookSecretSet ?? false;
return ( return (
<> <>
@@ -675,6 +747,135 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.ai")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<FormControlLabel
control={
<Controller
name="aiEnabled"
control={control}
render={({ field }) => <Switch {...field} checked={field.value} />}
/>
}
label={t("settings.aiEnabled")}
/>
<TextField select label={t("settings.aiProvider")} {...register("aiProvider")} fullWidth>
<MenuItem value="">{t("settings.aiProviderNone")}</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="openai-compatible">OpenAI compatible</MenuItem>
<MenuItem value="gemini">Gemini</MenuItem>
</TextField>
<TextField
label={t("settings.aiBaseUrl")}
{...register("aiBaseUrl")}
placeholder={t("settings.aiBaseUrlPlaceholder")}
helperText={t("settings.aiBaseUrlHelp")}
fullWidth
/>
<TextField label={t("settings.aiModel")} {...register("aiModel")} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={aiApiKeySet && !removeAiApiKey ? t("settings.aiApiKeyNew") : t("settings.aiApiKey")}
type="password"
{...register("aiApiKey")}
fullWidth
/>
<Button
variant="outlined"
color={removeAiApiKey ? "inherit" : "warning"}
onClick={() => {
if (removeAiApiKey) {
setRemoveAiApiKey(false);
} else {
setRemoveAiApiKey(true);
setValue("aiApiKey", "", { shouldDirty: true });
}
}}
>
{removeAiApiKey ? t("settings.paperlessTokenKeep") : t("settings.aiApiKeyRemove")}
</Button>
</Stack>
{aiApiKeySet && !removeAiApiKey && (
<Typography variant="caption" color="text.secondary">
{t("settings.aiApiKeyInfo")}
</Typography>
)}
<TextField
label={t("settings.aiSystemPrompt")}
{...register("aiSystemPrompt")}
fullWidth
multiline
minRows={3}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label={t("settings.aiTimeout")}
type="number"
inputProps={{ min: 5, max: 300 }}
{...register("aiTimeoutSeconds", { valueAsNumber: true })}
fullWidth
/>
<TextField
label={t("settings.aiMaxTokens")}
type="number"
inputProps={{ min: 256, max: 16000 }}
{...register("aiMaxTokens", { valueAsNumber: true })}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={paperlessWebhookSecretSet && !removeWebhookSecret ? t("settings.webhookSecretNew") : t("settings.webhookSecret")}
type="password"
{...register("paperlessWebhookSecret")}
fullWidth
helperText={t("settings.webhookSecretHelp")}
/>
<Button
variant="outlined"
color={removeWebhookSecret ? "inherit" : "warning"}
onClick={() => {
if (removeWebhookSecret) {
setRemoveWebhookSecret(false);
} else {
setRemoveWebhookSecret(true);
setValue("paperlessWebhookSecret", "", { shouldDirty: true });
}
}}
>
{removeWebhookSecret ? t("settings.paperlessTokenKeep") : t("settings.webhookSecretRemove")}
</Button>
</Stack>
{paperlessWebhookSecretSet && !removeWebhookSecret && (
<Typography variant="caption" color="text.secondary">
{t("settings.webhookSecretInfo")}
</Typography>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="outlined"
onClick={() => aiTestMutation.mutate()}
disabled={aiTestMutation.isPending || !serverConfig?.aiConfigured}
>
{t("settings.aiTest")}
</Button>
{aiTestMutation.isPending && <CircularProgress size={24} />}
</Stack>
{aiTestMutation.isError && (
<Alert severity="error">{(aiTestMutation.error as Error).message ?? t("settings.aiTestError")}</Alert>
)}
</Stack>
)}
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}> <Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>

View File

@@ -54,3 +54,47 @@ export interface PaperlessSearchResponse {
previous?: string | null; previous?: string | null;
results: PaperlessDocument[]; results: PaperlessDocument[];
} }
export type AiReviewStatus =
| "pending"
| "analyzing"
| "needs_review"
| "approved"
| "rejected"
| "failed";
export interface ContractAnalysisResult {
isContract: boolean;
title: string | null;
provider: string | null;
category: string | null;
contractStartDate: string | null;
contractEndDate: string | null;
terminationNoticeDays: number | null;
renewalPeriodMonths: number | null;
autoRenew: boolean;
price: number | null;
currency: string | null;
notes: string | null;
tags: string[];
summary: string;
confidence: number;
}
export interface AiReview {
id: number;
paperlessDocumentId: number;
status: AiReviewStatus;
documentTitle: string | null;
analysis: ContractAnalysisResult | null;
contractPayload: ContractPayload | null;
confidence: number | null;
error: string | null;
rawAiResponse: string | null;
contractId: number | null;
createdAt: string;
updatedAt: string;
analyzedAt: string | null;
approvedAt: string | null;
rejectedAt: string | null;
}

296
src/aiProviders.ts Normal file
View File

@@ -0,0 +1,296 @@
import { z } from "zod";
import { config } from "./config.js";
import { createLogger } from "./logger.js";
import type { RuntimeSettings } from "./runtimeSettings.js";
import { ContractAnalysisResult } from "./types.js";
const logger = createLogger(config.logLevel);
type AiProvider = "openai" | "openai-compatible" | "gemini";
export type AiDocumentInput = {
id: number;
title?: string | null;
correspondent?: string | null;
tags?: string[];
content?: string | null;
metadata?: Record<string, unknown> | null;
};
export type AiAnalysisResponse = {
analysis: ContractAnalysisResult;
rawResponse: string;
};
const analysisSchema = z.object({
isContract: z.boolean().default(false),
title: z.string().trim().min(1).nullable().default(null),
provider: z.string().trim().min(1).nullable().default(null),
category: z.string().trim().min(1).nullable().default(null),
contractStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null),
contractEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null),
terminationNoticeDays: z.number().int().nonnegative().nullable().default(null),
renewalPeriodMonths: z.number().int().nonnegative().nullable().default(null),
autoRenew: z.boolean().default(false),
price: z.number().nonnegative().nullable().default(null),
currency: z.string().trim().min(1).max(8).nullable().default("EUR"),
notes: z.string().trim().nullable().default(null),
tags: z.array(z.string().trim().min(1).max(100)).default([]),
summary: z.string().trim().min(1).default("No summary returned."),
confidence: z.number().min(0).max(1).default(0)
});
const analysisJsonSchema = {
type: "object",
additionalProperties: false,
required: [
"isContract",
"title",
"provider",
"category",
"contractStartDate",
"contractEndDate",
"terminationNoticeDays",
"renewalPeriodMonths",
"autoRenew",
"price",
"currency",
"notes",
"tags",
"summary",
"confidence"
],
properties: {
isContract: { type: "boolean" },
title: { type: ["string", "null"] },
provider: { type: ["string", "null"] },
category: { type: ["string", "null"] },
contractStartDate: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
contractEndDate: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
terminationNoticeDays: { type: ["integer", "null"], minimum: 0 },
renewalPeriodMonths: { type: ["integer", "null"], minimum: 0 },
autoRenew: { type: "boolean" },
price: { type: ["number", "null"], minimum: 0 },
currency: { type: ["string", "null"] },
notes: { type: ["string", "null"] },
tags: { type: "array", items: { type: "string" } },
summary: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 }
}
};
const defaultPrompt = [
"Analyze the OCR text and metadata of a paperless-ngx document.",
"Decide whether it is a contract or a contract-related document.",
"Extract contract data only when it is explicitly present or strongly implied.",
"Use ISO dates in YYYY-MM-DD format. Use null for unknown values.",
"The confidence value must be between 0 and 1.",
"Return only JSON that matches the requested schema."
].join(" ");
function requireAiConfig(settings: RuntimeSettings): {
provider: AiProvider;
apiKey: string;
model: string;
baseUrl: string;
systemPrompt: string;
timeoutMs: number;
maxTokens: number;
} {
if (!settings.aiEnabled) {
throw new Error("AI analysis is disabled.");
}
if (!settings.aiProvider || !settings.aiApiKey || !settings.aiModel) {
throw new Error("AI provider, model, and API key must be configured.");
}
const provider = settings.aiProvider;
const baseUrl =
settings.aiBaseUrl ??
(provider === "gemini"
? "https://generativelanguage.googleapis.com/v1beta"
: "https://api.openai.com/v1");
return {
provider,
apiKey: settings.aiApiKey,
model: settings.aiModel,
baseUrl: baseUrl.replace(/\/+$/, ""),
systemPrompt: settings.aiSystemPrompt?.trim() || defaultPrompt,
timeoutMs: Math.max(settings.aiTimeoutSeconds, 5) * 1000,
maxTokens: settings.aiMaxTokens
};
}
function buildUserPrompt(document: AiDocumentInput): string {
const payload = {
paperlessDocumentId: document.id,
title: document.title ?? null,
correspondent: document.correspondent ?? null,
tags: document.tags ?? [],
metadata: document.metadata ?? {},
content: document.content?.slice(0, 60000) ?? ""
};
return `Extract contract metadata from this paperless-ngx document:\n${JSON.stringify(payload, null, 2)}`;
}
function withTimeout(timeoutMs: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs).unref?.();
return controller.signal;
}
function extractJson(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith("{")) {
return JSON.parse(trimmed);
}
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) {
return JSON.parse(fenced[1].trim());
}
const first = trimmed.indexOf("{");
const last = trimmed.lastIndexOf("}");
if (first >= 0 && last > first) {
return JSON.parse(trimmed.slice(first, last + 1));
}
throw new Error("AI response did not contain JSON.");
}
function parseAnalysis(text: string): ContractAnalysisResult {
const parsed = analysisSchema.parse(extractJson(text));
return {
...parsed,
title: parsed.title || null,
provider: parsed.provider || null,
category: parsed.category || null,
currency: parsed.currency || "EUR",
notes: parsed.notes || null,
tags: Array.from(new Set(parsed.tags.map((tag) => tag.trim()).filter(Boolean)))
};
}
async function requestOpenAiCompatible(
settings: ReturnType<typeof requireAiConfig>,
document: AiDocumentInput
): Promise<AiAnalysisResponse> {
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
method: "POST",
signal: withTimeout(settings.timeoutMs),
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({
model: settings.model,
temperature: 0,
max_tokens: settings.maxTokens,
messages: [
{ role: "system", content: settings.systemPrompt },
{ role: "user", content: buildUserPrompt(document) }
],
response_format: {
type: "json_schema",
json_schema: {
name: "contract_analysis",
strict: true,
schema: analysisJsonSchema
}
}
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`AI provider responded with ${response.status}: ${text}`);
}
const payload = await response.json() as {
choices?: Array<{ message?: { content?: string | Array<{ text?: string }> } }>;
};
const content = payload.choices?.[0]?.message?.content;
const raw = Array.isArray(content)
? content.map((part) => part.text ?? "").join("")
: content;
if (!raw) {
throw new Error("AI provider returned an empty response.");
}
return { analysis: parseAnalysis(raw), rawResponse: raw };
}
async function requestGemini(
settings: ReturnType<typeof requireAiConfig>,
document: AiDocumentInput
): Promise<AiAnalysisResponse> {
const url = new URL(`${settings.baseUrl}/models/${encodeURIComponent(settings.model)}:generateContent`);
url.searchParams.set("key", settings.apiKey);
const response = await fetch(url, {
method: "POST",
signal: withTimeout(settings.timeoutMs),
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({
systemInstruction: {
parts: [{ text: settings.systemPrompt }]
},
contents: [
{
role: "user",
parts: [{ text: buildUserPrompt(document) }]
}
],
generationConfig: {
temperature: 0,
maxOutputTokens: settings.maxTokens,
responseMimeType: "application/json",
responseSchema: analysisJsonSchema
}
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Gemini responded with ${response.status}: ${text}`);
}
const payload = await response.json() as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
};
const raw = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("") ?? "";
if (!raw) {
throw new Error("Gemini returned an empty response.");
}
return { analysis: parseAnalysis(raw), rawResponse: raw };
}
export async function analyzeDocumentWithAi(
runtimeSettings: RuntimeSettings,
document: AiDocumentInput
): Promise<AiAnalysisResponse> {
const aiSettings = requireAiConfig(runtimeSettings);
logger.info("Analyzing paperless document %s with AI provider %s", document.id, aiSettings.provider);
if (aiSettings.provider === "gemini") {
return requestGemini(aiSettings, document);
}
return requestOpenAiCompatible(aiSettings, document);
}
export async function testAiConfiguration(runtimeSettings: RuntimeSettings): Promise<void> {
await analyzeDocumentWithAi(runtimeSettings, {
id: 0,
title: "AI configuration test",
correspondent: "Example Provider",
tags: ["test"],
content:
"Example contract. Provider: Example Provider. Start: 2026-01-01. End: 2027-01-01. Cancellation notice: 30 days. Price: EUR 9.99 per month.",
metadata: {}
});
}

161
src/aiReviewProcessor.ts Normal file
View File

@@ -0,0 +1,161 @@
import {
AiReview,
createAiReview,
getAiReview,
listAiReviews,
markAiReviewAnalyzing,
markAiReviewFailed,
saveAiReviewAnalysis
} from "./aiReviewStore.js";
import { analyzeDocumentWithAi } from "./aiProviders.js";
import { createLogger } from "./logger.js";
import { paperlessClient } from "./paperlessClient.js";
import { getRuntimeSettings } from "./runtimeSettings.js";
import { ContractAnalysisResult, ContractPayload } from "./types.js";
import { config } from "./config.js";
const logger = createLogger(config.logLevel);
const processing = new Set<number>();
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function extractDocumentTitle(document: Record<string, unknown>, fallbackId: number): string {
return readString(document.title) ?? `Paperless document #${fallbackId}`;
}
function extractCorrespondent(document: Record<string, unknown>): string | null {
return (
readString(document.correspondent_name) ??
readString(document.correspondent__name) ??
(document.metadata && typeof document.metadata === "object"
? readString((document.metadata as Record<string, unknown>).correspondent_name)
: null)
);
}
function extractTags(document: Record<string, unknown>): string[] {
const values: string[] = [];
const add = (value: unknown) => {
if (typeof value === "string" && value.trim()) {
values.push(value.trim());
} else if (value && typeof value === "object") {
const item = value as Record<string, unknown>;
const label = readString(item.name) ?? readString(item.slug) ?? readString(item.label);
if (label) {
values.push(label);
}
}
};
if (Array.isArray(document.tags__name)) {
document.tags__name.forEach(add);
}
if (Array.isArray(document.tag_names)) {
document.tag_names.forEach(add);
}
if (Array.isArray(document.tags)) {
document.tags.forEach(add);
}
if (document.metadata && typeof document.metadata === "object") {
const metadata = document.metadata as Record<string, unknown>;
if (Array.isArray(metadata.tag_names)) {
metadata.tag_names.forEach(add);
}
}
return Array.from(new Set(values));
}
function normalizeContractPayload(
documentId: number,
documentTitle: string,
analysis: ContractAnalysisResult
): ContractPayload | null {
if (!analysis.isContract) {
return null;
}
return {
title: analysis.title ?? documentTitle,
paperlessDocumentId: documentId,
provider: analysis.provider,
category: analysis.category,
contractStartDate: analysis.contractStartDate,
contractEndDate: analysis.contractEndDate,
terminationNoticeDays: analysis.terminationNoticeDays,
renewalPeriodMonths: analysis.renewalPeriodMonths,
autoRenew: analysis.autoRenew,
price: analysis.price,
currency: analysis.currency ?? "EUR",
notes: analysis.notes ?? analysis.summary,
tags: analysis.tags
};
}
export function enqueuePaperlessDocumentAnalysis(documentId: number, documentTitle?: string | null): AiReview {
const review = createAiReview(documentId, documentTitle);
void processAiReview(review.id);
return review;
}
export async function processAiReview(reviewId: number): Promise<AiReview | null> {
if (processing.has(reviewId)) {
return getAiReview(reviewId);
}
processing.add(reviewId);
try {
const review = getAiReview(reviewId);
if (!review || review.status === "approved" || review.status === "rejected") {
return review;
}
markAiReviewAnalyzing(reviewId);
const runtime = getRuntimeSettings();
if (!paperlessClient.isConfigured) {
return markAiReviewFailed(reviewId, "Paperless integration is not configured.");
}
const document = await paperlessClient.getDocument(review.paperlessDocumentId);
if (!document) {
return markAiReviewFailed(reviewId, "Paperless document not found.");
}
const documentTitle = extractDocumentTitle(document, review.paperlessDocumentId);
const result = await analyzeDocumentWithAi(runtime, {
id: review.paperlessDocumentId,
title: documentTitle,
correspondent: extractCorrespondent(document),
tags: extractTags(document),
content: readString(document.content),
metadata: document.metadata && typeof document.metadata === "object"
? document.metadata as Record<string, unknown>
: {}
});
return saveAiReviewAnalysis(reviewId, {
documentTitle,
analysis: result.analysis,
contractPayload: normalizeContractPayload(review.paperlessDocumentId, documentTitle, result.analysis),
rawAiResponse: result.rawResponse
});
} catch (error) {
logger.error("AI review processing failed", error);
return markAiReviewFailed(reviewId, error instanceof Error ? error.message : "AI analysis failed.");
} finally {
processing.delete(reviewId);
}
}
export function processPendingAiReviews(): void {
const candidates = [
...listAiReviews("pending"),
...listAiReviews("analyzing")
];
for (const review of candidates) {
void processAiReview(review.id);
}
}

226
src/aiReviewStore.ts Normal file
View File

@@ -0,0 +1,226 @@
import db, { AiReviewDbRow } from "./db.js";
import { ContractAnalysisResult, ContractPayload } from "./types.js";
export type AiReviewStatus =
| "pending"
| "analyzing"
| "needs_review"
| "approved"
| "rejected"
| "failed";
export interface AiReview {
id: number;
paperlessDocumentId: number;
status: AiReviewStatus;
documentTitle: string | null;
analysis: ContractAnalysisResult | null;
contractPayload: ContractPayload | null;
confidence: number | null;
error: string | null;
rawAiResponse: string | null;
contractId: number | null;
createdAt: string;
updatedAt: string;
analyzedAt: string | null;
approvedAt: string | null;
rejectedAt: string | null;
}
function parseJson<T>(value: string | null): T | null {
if (!value) {
return null;
}
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
function stringifyJson(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
return JSON.stringify(value);
}
function normalizeStatus(value: string): AiReviewStatus {
if (
value === "pending" ||
value === "analyzing" ||
value === "needs_review" ||
value === "approved" ||
value === "rejected" ||
value === "failed"
) {
return value;
}
return "failed";
}
function mapRow(row: AiReviewDbRow): AiReview {
return {
id: row.id,
paperlessDocumentId: row.paperless_document_id,
status: normalizeStatus(row.status),
documentTitle: row.document_title,
analysis: parseJson<ContractAnalysisResult>(row.analysis_json),
contractPayload: parseJson<ContractPayload>(row.contract_payload_json),
confidence: row.confidence,
error: row.error,
rawAiResponse: row.raw_ai_response,
contractId: row.contract_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
analyzedAt: row.analyzed_at,
approvedAt: row.approved_at,
rejectedAt: row.rejected_at
};
}
export function listAiReviews(status?: AiReviewStatus): AiReview[] {
const rows = status
? db
.prepare<[string], AiReviewDbRow>(
`SELECT * FROM ai_review_queue WHERE status = ? ORDER BY updated_at DESC`
)
.all(status)
: db
.prepare<[], AiReviewDbRow>(
`SELECT * FROM ai_review_queue ORDER BY updated_at DESC LIMIT 200`
)
.all();
return rows.map(mapRow);
}
export function getAiReview(id: number): AiReview | null {
const row = db
.prepare<[number], AiReviewDbRow>(`SELECT * FROM ai_review_queue WHERE id = ?`)
.get(id);
return row ? mapRow(row) : null;
}
export function getAiReviewByDocumentId(documentId: number): AiReview | null {
const row = db
.prepare<[number], AiReviewDbRow>(`SELECT * FROM ai_review_queue WHERE paperless_document_id = ?`)
.get(documentId);
return row ? mapRow(row) : null;
}
export function createAiReview(documentId: number, documentTitle?: string | null): AiReview {
const existing = getAiReviewByDocumentId(documentId);
if (existing) {
return existing;
}
const now = new Date().toISOString();
const result = db
.prepare(
`INSERT INTO ai_review_queue (
paperless_document_id,
status,
document_title,
created_at,
updated_at
) VALUES (?, 'pending', ?, ?, ?)`
)
.run(documentId, documentTitle ?? null, now, now);
return getAiReview(Number(result.lastInsertRowid))!;
}
export function markAiReviewAnalyzing(id: number): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET status = 'analyzing', error = NULL, updated_at = ?
WHERE id = ?`
).run(now, id);
return getAiReview(id);
}
export function saveAiReviewAnalysis(
id: number,
data: {
documentTitle?: string | null;
analysis: ContractAnalysisResult;
contractPayload: ContractPayload | null;
rawAiResponse?: string | null;
}
): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET status = 'needs_review',
document_title = COALESCE(?, document_title),
analysis_json = ?,
contract_payload_json = ?,
confidence = ?,
error = NULL,
raw_ai_response = ?,
analyzed_at = ?,
updated_at = ?
WHERE id = ?`
).run(
data.documentTitle ?? null,
stringifyJson(data.analysis),
stringifyJson(data.contractPayload),
data.analysis.confidence,
data.rawAiResponse ?? null,
now,
now,
id
);
return getAiReview(id);
}
export function markAiReviewFailed(id: number, error: string): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET status = 'failed', error = ?, updated_at = ?
WHERE id = ?`
).run(error, now, id);
return getAiReview(id);
}
export function resetAiReviewForRetry(id: number): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET status = 'pending', error = NULL, updated_at = ?
WHERE id = ? AND status IN ('failed', 'needs_review', 'pending')`
).run(now, id);
return getAiReview(id);
}
export function rejectAiReview(id: number): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET status = 'rejected', rejected_at = ?, updated_at = ?
WHERE id = ?`
).run(now, now, id);
return getAiReview(id);
}
export function setAiReviewContract(id: number, contractId: number): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET contract_id = ?, updated_at = ?
WHERE id = ?`
).run(contractId, now, id);
return getAiReview(id);
}
export function approveAiReview(id: number, contractId: number): AiReview | null {
const now = new Date().toISOString();
db.prepare(
`UPDATE ai_review_queue
SET status = 'approved', contract_id = ?, approved_at = ?, error = NULL, updated_at = ?
WHERE id = ?`
).run(contractId, now, now, id);
return getAiReview(id);
}

View File

@@ -28,7 +28,16 @@ const configSchema = z.object({
ntfyTopic: z.string().min(1).optional(), ntfyTopic: z.string().min(1).optional(),
ntfyToken: z.string().optional(), ntfyToken: z.string().optional(),
ntfyPriority: z.string().optional(), ntfyPriority: z.string().optional(),
icalSecret: z.string().optional() icalSecret: z.string().optional(),
aiEnabled: z.coerce.boolean().default(false),
aiProvider: z.enum(["openai", "openai-compatible", "gemini"]).optional(),
aiBaseUrl: z.string().url().optional(),
aiModel: z.string().min(1).optional(),
aiApiKey: z.string().min(1).optional(),
aiSystemPrompt: z.string().optional(),
aiTimeoutSeconds: z.coerce.number().min(5).max(300).default(60),
aiMaxTokens: z.coerce.number().min(256).max(16000).default(2000),
paperlessWebhookSecret: z.string().min(10).optional()
}); });
function parseBoolean(value: string | undefined, fallback: boolean): boolean { function parseBoolean(value: string | undefined, fallback: boolean): boolean {
@@ -72,7 +81,16 @@ const rawConfig = {
ntfyTopic: readEnv("NTFY_TOPIC"), ntfyTopic: readEnv("NTFY_TOPIC"),
ntfyToken: readEnv("NTFY_TOKEN"), ntfyToken: readEnv("NTFY_TOKEN"),
ntfyPriority: readEnv("NTFY_PRIORITY"), ntfyPriority: readEnv("NTFY_PRIORITY"),
icalSecret: readEnv("ICAL_SECRET") icalSecret: readEnv("ICAL_SECRET"),
aiEnabled: parseBoolean(readEnv("AI_ENABLED"), false),
aiProvider: readEnv("AI_PROVIDER"),
aiBaseUrl: readEnv("AI_BASE_URL"),
aiModel: readEnv("AI_MODEL"),
aiApiKey: readEnv("AI_API_KEY"),
aiSystemPrompt: readEnv("AI_SYSTEM_PROMPT"),
aiTimeoutSeconds: readEnv("AI_TIMEOUT_SECONDS"),
aiMaxTokens: readEnv("AI_MAX_TOKENS"),
paperlessWebhookSecret: readEnv("PAPERLESS_WEBHOOK_SECRET")
}; };
export type AppConfig = z.infer<typeof configSchema>; export type AppConfig = z.infer<typeof configSchema>;

View File

@@ -50,6 +50,24 @@ CREATE TABLE IF NOT EXISTS categories (
name TEXT NOT NULL UNIQUE COLLATE NOCASE, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
); );
CREATE TABLE IF NOT EXISTS ai_review_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
paperless_document_id INTEGER NOT NULL UNIQUE,
status TEXT NOT NULL,
document_title TEXT,
analysis_json TEXT,
contract_payload_json TEXT,
confidence REAL,
error TEXT,
raw_ai_response TEXT,
contract_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
analyzed_at TEXT,
approved_at TEXT,
rejected_at TEXT
);
`); `);
@@ -81,4 +99,22 @@ export type ContractDbRow = {
updated_at: string; updated_at: string;
}; };
export type AiReviewDbRow = {
id: number;
paperless_document_id: number;
status: string;
document_title: string | null;
analysis_json: string | null;
contract_payload_json: string | null;
confidence: number | null;
error: string | null;
raw_ai_response: string | null;
contract_id: number | null;
created_at: string;
updated_at: string;
analyzed_at: string | null;
approved_at: string | null;
rejected_at: string | null;
};
export default db; export default db;

View File

@@ -1,6 +1,24 @@
import crypto from "node:crypto";
import express, { NextFunction, Request, Response } from "express"; import express, { NextFunction, Request, Response } from "express";
import { z } from "zod"; import { z } from "zod";
import {
approveAiReview,
getAiReview,
listAiReviews,
markAiReviewFailed,
rejectAiReview,
resetAiReviewForRetry,
setAiReviewContract
} from "./aiReviewStore.js";
import type { AiReviewStatus } from "./aiReviewStore.js";
import {
enqueuePaperlessDocumentAnalysis,
processAiReview,
processPendingAiReviews
} from "./aiReviewProcessor.js";
import { testAiConfiguration } from "./aiProviders.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { import {
createContract, createContract,
@@ -89,7 +107,16 @@ const settingsUpdateSchema = z.object({
ntfyPriority: z.string().nullable().optional(), ntfyPriority: z.string().nullable().optional(),
authUsername: z.string().nullable().optional(), authUsername: z.string().nullable().optional(),
authPassword: z.string().nullable().optional(), authPassword: z.string().nullable().optional(),
icalSecret: z.string().min(10).nullable().optional() icalSecret: z.string().min(10).nullable().optional(),
aiEnabled: z.boolean().optional(),
aiProvider: z.enum(["openai", "openai-compatible", "gemini"]).nullable().optional(),
aiBaseUrl: z.string().url().nullable().optional(),
aiModel: z.string().nullable().optional(),
aiApiKey: z.string().min(1).nullable().optional(),
aiSystemPrompt: z.string().nullable().optional(),
aiTimeoutSeconds: z.coerce.number().min(5).max(300).optional(),
aiMaxTokens: z.coerce.number().min(256).max(16000).optional(),
paperlessWebhookSecret: z.string().min(10).nullable().optional()
}); });
const categorySchema = z.object({ const categorySchema = z.object({
@@ -114,13 +141,22 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
ntfyServerUrl: runtime.ntfyServerUrl, ntfyServerUrl: runtime.ntfyServerUrl,
ntfyTopic: runtime.ntfyTopic, ntfyTopic: runtime.ntfyTopic,
ntfyPriority: runtime.ntfyPriority ?? "default", ntfyPriority: runtime.ntfyPriority ?? "default",
authUsername: runtime.authUsername authUsername: runtime.authUsername,
aiEnabled: runtime.aiEnabled,
aiProvider: runtime.aiProvider,
aiBaseUrl: runtime.aiBaseUrl,
aiModel: runtime.aiModel,
aiSystemPrompt: runtime.aiSystemPrompt,
aiTimeoutSeconds: runtime.aiTimeoutSeconds,
aiMaxTokens: runtime.aiMaxTokens
}, },
secrets: { secrets: {
paperlessTokenSet: Boolean(runtime.paperlessToken), paperlessTokenSet: Boolean(runtime.paperlessToken),
mailPasswordSet: Boolean(runtime.mailPassword), mailPasswordSet: Boolean(runtime.mailPassword),
ntfyTokenSet: Boolean(runtime.ntfyToken), ntfyTokenSet: Boolean(runtime.ntfyToken),
authPasswordSet: Boolean(runtime.authPassword) authPasswordSet: Boolean(runtime.authPassword),
aiApiKeySet: Boolean(runtime.aiApiKey),
paperlessWebhookSecretSet: Boolean(runtime.paperlessWebhookSecret)
}, },
icalSecret: runtime.icalSecret icalSecret: runtime.icalSecret
}; };
@@ -179,6 +215,50 @@ function buildIcsFeed(
return lines.join("\r\n") + "\r\n"; return lines.join("\r\n") + "\r\n";
} }
function safeCompare(expected: string | null | undefined, provided: string | null | undefined): boolean {
if (!expected || !provided) {
return false;
}
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}
function extractWebhookDocumentId(body: unknown): number | null {
if (!body || typeof body !== "object") {
return null;
}
const payload = body as Record<string, unknown>;
const candidates = [
payload.document_id,
payload.documentId,
payload.id,
payload.document,
payload.document_pk
];
for (const candidate of candidates) {
const value = typeof candidate === "string" ? Number(candidate) : candidate;
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
return value;
}
}
return null;
}
function readWebhookSecret(req: Request): string | null {
const header =
req.get("x-contract-companion-secret") ??
req.get("x-paperless-webhook-secret") ??
req.get("authorization")?.replace(/^Bearer\s+/i, "");
if (header) {
return header;
}
return typeof req.query.secret === "string" ? req.query.secret : null;
}
app.get("/healthz", (_req, res) => { app.get("/healthz", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });
}); });
@@ -256,6 +336,30 @@ app.get("/calendar/feed.ics", (req, res) => {
res.send(ics); res.send(ics);
}); });
app.post("/integrations/paperless/webhook", (req, res) => {
const runtime = getRuntimeSettings();
if (!runtime.paperlessWebhookSecret) {
return res.status(503).json({ error: "Paperless webhook secret is not configured" });
}
const providedSecret = readWebhookSecret(req);
if (!safeCompare(runtime.paperlessWebhookSecret, providedSecret)) {
return res.status(401).json({ error: "Invalid webhook secret" });
}
const documentId = extractWebhookDocumentId(req.body);
if (!documentId) {
return res.status(400).json({ error: "Webhook payload does not contain a document id" });
}
const documentTitle =
req.body && typeof req.body === "object" && typeof (req.body as Record<string, unknown>).title === "string"
? (req.body as Record<string, string>).title
: null;
const review = enqueuePaperlessDocumentAnalysis(documentId, documentTitle);
res.status(202).json({ status: review.status, reviewId: review.id, paperlessDocumentId: documentId });
});
app.use(authenticateRequest); app.use(authenticateRequest);
app.get("/config", (_req, res) => { app.get("/config", (_req, res) => {
@@ -263,6 +367,7 @@ app.get("/config", (_req, res) => {
const paperlessConfigured = Boolean(runtime.paperlessBaseUrl && runtime.paperlessToken); const paperlessConfigured = Boolean(runtime.paperlessBaseUrl && runtime.paperlessToken);
const mailConfigured = Boolean(runtime.mailServer && runtime.mailFrom && runtime.mailTo); const mailConfigured = Boolean(runtime.mailServer && runtime.mailFrom && runtime.mailTo);
const ntfyConfigured = Boolean(runtime.ntfyServerUrl && runtime.ntfyTopic); const ntfyConfigured = Boolean(runtime.ntfyServerUrl && runtime.ntfyTopic);
const aiConfigured = Boolean(runtime.aiEnabled && runtime.aiProvider && runtime.aiApiKey && runtime.aiModel);
res.json({ res.json({
port: config.port, port: config.port,
@@ -281,7 +386,11 @@ app.get("/config", (_req, res) => {
mailUseTls: runtime.mailUseTls, mailUseTls: runtime.mailUseTls,
ntfyConfigured, ntfyConfigured,
authEnabled: isAuthEnabled(), authEnabled: isAuthEnabled(),
authTokenExpiresInHours: config.authTokenExpiresInHours authTokenExpiresInHours: config.authTokenExpiresInHours,
aiEnabled: runtime.aiEnabled,
aiConfigured,
aiProvider: runtime.aiProvider,
paperlessWebhookConfigured: Boolean(runtime.paperlessWebhookSecret)
}); });
}); });
@@ -363,6 +472,33 @@ app.put("/settings", (req, res) => {
if (Object.prototype.hasOwnProperty.call(payload, "icalSecret")) { if (Object.prototype.hasOwnProperty.call(payload, "icalSecret")) {
update.icalSecret = payload.icalSecret ?? null; update.icalSecret = payload.icalSecret ?? null;
} }
if (Object.prototype.hasOwnProperty.call(payload, "aiEnabled")) {
update.aiEnabled = payload.aiEnabled ?? false;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiProvider")) {
update.aiProvider = payload.aiProvider ?? null;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiBaseUrl")) {
update.aiBaseUrl = payload.aiBaseUrl ?? null;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiModel")) {
update.aiModel = payload.aiModel ?? null;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiApiKey")) {
update.aiApiKey = payload.aiApiKey ?? null;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiSystemPrompt")) {
update.aiSystemPrompt = payload.aiSystemPrompt ?? null;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiTimeoutSeconds")) {
update.aiTimeoutSeconds = payload.aiTimeoutSeconds;
}
if (Object.prototype.hasOwnProperty.call(payload, "aiMaxTokens")) {
update.aiMaxTokens = payload.aiMaxTokens;
}
if (Object.prototype.hasOwnProperty.call(payload, "paperlessWebhookSecret")) {
update.paperlessWebhookSecret = payload.paperlessWebhookSecret ?? null;
}
let runtime = updateRuntimeSettings(update); let runtime = updateRuntimeSettings(update);
if (!runtime.icalSecret) { if (!runtime.icalSecret) {
@@ -406,6 +542,16 @@ app.post("/settings/test/ntfy", async (_req, res, next) => {
} }
}); });
app.post("/settings/test/ai", async (_req, res, next) => {
try {
const runtime = getRuntimeSettings();
await testAiConfiguration(runtime);
res.json({ status: "ok" });
} catch (error) {
next(error);
}
});
app.get("/integrations/paperless/search", async (req, res, next) => { app.get("/integrations/paperless/search", async (req, res, next) => {
const query = (typeof req.query.q === "string" ? req.query.q : typeof req.query.query === "string" ? req.query.query : "").trim(); const query = (typeof req.query.q === "string" ? req.query.q : typeof req.query.query === "string" ? req.query.query : "").trim();
const page = Number(req.query.page ?? 1); const page = Number(req.query.page ?? 1);
@@ -461,6 +607,96 @@ app.get("/integrations/paperless/documents/:documentId", async (req, res, next)
} }
}); });
app.get("/ai/reviews", (req, res) => {
const status = typeof req.query.status === "string" ? req.query.status : undefined;
const allowedStatuses: AiReviewStatus[] = ["pending", "analyzing", "needs_review", "approved", "rejected", "failed"];
if (status && !allowedStatuses.includes(status as AiReviewStatus)) {
return res.status(400).json({ error: "Invalid review status" });
}
res.json(listAiReviews(status as AiReviewStatus | undefined));
});
app.get("/ai/reviews/:id", (req, res) => {
const id = parseId(req.params.id);
if (!id) {
return res.status(400).json({ error: "Invalid review id" });
}
const review = getAiReview(id);
if (!review) {
return res.status(404).json({ error: "Review not found" });
}
res.json(review);
});
app.post("/ai/reviews/:id/retry", async (req, res, next) => {
const id = parseId(req.params.id);
if (!id) {
return res.status(400).json({ error: "Invalid review id" });
}
const review = resetAiReviewForRetry(id);
if (!review) {
return res.status(404).json({ error: "Review not found" });
}
try {
const updated = await processAiReview(id);
res.json(updated ?? review);
} catch (error) {
next(error);
}
});
app.post("/ai/reviews/:id/reject", (req, res) => {
const id = parseId(req.params.id);
if (!id) {
return res.status(400).json({ error: "Invalid review id" });
}
const review = rejectAiReview(id);
if (!review) {
return res.status(404).json({ error: "Review not found" });
}
res.json(review);
});
app.post("/ai/reviews/:id/approve", async (req, res, next) => {
const id = parseId(req.params.id);
if (!id) {
return res.status(400).json({ error: "Invalid review id" });
}
const review = getAiReview(id);
if (!review) {
return res.status(404).json({ error: "Review not found" });
}
if (review.status === "rejected") {
return res.status(400).json({ error: "Rejected reviews cannot be approved" });
}
const rawContract = req.body && typeof req.body === "object" && "contract" in req.body
? (req.body as { contract?: unknown }).contract
: review.contractPayload;
if (!rawContract) {
return res.status(400).json({ error: "Review does not contain a contract draft" });
}
const payload = validatePayload(contractCreateSchema, rawContract) as ContractPayload;
try {
let contractId = review.contractId;
let contract = contractId ? getContract(contractId) : null;
if (!contract) {
contract = createContract({ ...payload, paperlessDocumentId: review.paperlessDocumentId });
contractId = contract.id;
setAiReviewContract(id, contract.id);
}
await paperlessClient.writeContractMetadata(review.paperlessDocumentId, contract.id, contract, review.analysis);
const approved = approveAiReview(id, contract.id);
res.json({ review: approved, contract });
} catch (error) {
markAiReviewFailed(id, error instanceof Error ? error.message : "Approval failed");
next(error);
}
});
app.get("/contracts", (req, res) => { app.get("/contracts", (req, res) => {
const skip = Number(req.query.skip ?? 0); const skip = Number(req.query.skip ?? 0);
const limit = Math.min(Number(req.query.limit ?? 100), 500); const limit = Math.min(Number(req.query.limit ?? 100), 500);
@@ -585,6 +821,7 @@ const server = app.listen(config.port, () => {
logger.warn("Authentication is disabled; consider setting AUTH_USERNAME and AUTH_PASSWORD."); logger.warn("Authentication is disabled; consider setting AUTH_USERNAME and AUTH_PASSWORD.");
} }
deadlineMonitor.start(); deadlineMonitor.start();
processPendingAiReviews();
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {

View File

@@ -1,6 +1,7 @@
import { config } from "./config.js"; import { config } from "./config.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import { getRuntimeSettings } from "./runtimeSettings.js"; import { getRuntimeSettings } from "./runtimeSettings.js";
import { ContractAnalysisResult, ContractPayload } from "./types.js";
const logger = createLogger(config.logLevel); const logger = createLogger(config.logLevel);
@@ -26,10 +27,13 @@ export class PaperlessClient {
return `${trimmedBase}/${trimmedPath}`; return `${trimmedBase}/${trimmedPath}`;
} }
private getHeaders(): HeadersInit { private getHeaders(json = false): HeadersInit {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/json" Accept: "application/json"
}; };
if (json) {
headers["Content-Type"] = "application/json";
}
const { paperlessToken } = getRuntimeSettings(); const { paperlessToken } = getRuntimeSettings();
if (paperlessToken) { if (paperlessToken) {
headers.Authorization = `Token ${paperlessToken}`; headers.Authorization = `Token ${paperlessToken}`;
@@ -37,8 +41,8 @@ export class PaperlessClient {
return headers; return headers;
} }
private async fetchJson<T>(url: URL): Promise<T> { private async fetchJson<T>(url: URL, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, { headers: this.getHeaders() }); const response = await fetch(url, { ...init, headers: init.headers ?? this.getHeaders() });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
logger.error(`Paperless API error ${response.status}: ${text}`); logger.error(`Paperless API error ${response.status}: ${text}`);
@@ -89,6 +93,138 @@ export class PaperlessClient {
return payload; return payload;
} }
async ensureTag(name: string): Promise<number> {
if (!this.isConfigured) {
throw new Error("Paperless integration is not configured");
}
const normalized = name.trim();
if (!normalized) {
throw new Error("Tag name must not be empty");
}
const listUrl = new URL(this.buildUrl("/api/tags/"));
listUrl.searchParams.set("page_size", "100");
const payload = await this.fetchJson<PaperlessCollectionResponse<Record<string, unknown>>>(listUrl);
const existing = (payload.results ?? []).find(
(tag) => typeof tag.name === "string" && tag.name.toLowerCase() === normalized.toLowerCase()
);
if (typeof existing?.id === "number") {
return existing.id;
}
const createUrl = new URL(this.buildUrl("/api/tags/"));
const created = await this.fetchJson<Record<string, unknown>>(createUrl, {
method: "POST",
headers: this.getHeaders(true),
body: JSON.stringify({ name: normalized })
});
if (typeof created.id !== "number") {
throw new Error(`Paperless did not return an id for tag ${normalized}`);
}
return created.id;
}
async ensureCustomField(name: string, dataType: string): Promise<number> {
if (!this.isConfigured) {
throw new Error("Paperless integration is not configured");
}
const normalized = name.trim();
if (!normalized) {
throw new Error("Custom field name must not be empty");
}
const listUrl = new URL(this.buildUrl("/api/custom_fields/"));
listUrl.searchParams.set("page_size", "100");
const payload = await this.fetchJson<PaperlessCollectionResponse<Record<string, unknown>>>(listUrl);
const existing = (payload.results ?? []).find(
(field) => typeof field.name === "string" && field.name.toLowerCase() === normalized.toLowerCase()
);
if (typeof existing?.id === "number") {
return existing.id;
}
const createUrl = new URL(this.buildUrl("/api/custom_fields/"));
const created = await this.fetchJson<Record<string, unknown>>(createUrl, {
method: "POST",
headers: this.getHeaders(true),
body: JSON.stringify({ name: normalized, data_type: dataType })
});
if (typeof created.id !== "number") {
throw new Error(`Paperless did not return an id for custom field ${normalized}`);
}
return created.id;
}
async writeContractMetadata(
documentId: number,
contractId: number,
contract: ContractPayload,
analysis: ContractAnalysisResult | null
): Promise<void> {
if (!this.isConfigured) {
throw new Error("Paperless integration is not configured");
}
const document = await this.getDocument(documentId);
if (!document) {
throw new Error("Document not found in paperless");
}
const tagIds = new Set<number>();
const existingTags = Array.isArray(document.tags) ? document.tags : [];
for (const tag of existingTags) {
if (typeof tag === "number") {
tagIds.add(tag);
} else if (tag && typeof tag === "object" && typeof (tag as Record<string, unknown>).id === "number") {
tagIds.add((tag as Record<string, number>).id);
}
}
tagIds.add(await this.ensureTag("contract"));
tagIds.add(await this.ensureTag("contract-ai-reviewed"));
const fieldValues: Record<string, string | number | boolean | null> = {};
const addField = async (name: string, dataType: string, value: string | number | boolean | null | undefined) => {
if (value === undefined || value === null || value === "") {
return;
}
const id = await this.ensureCustomField(name, dataType);
fieldValues[String(id)] = value;
};
await addField("Contract Companion ID", "integer", contractId);
await addField("Contract Title", "string", contract.title);
await addField("Contract Provider", "string", contract.provider ?? null);
await addField("Contract Category", "string", contract.category ?? null);
await addField("Contract Start Date", "date", contract.contractStartDate ?? null);
await addField("Contract End Date", "date", contract.contractEndDate ?? null);
await addField("Termination Notice Days", "integer", contract.terminationNoticeDays ?? null);
await addField("Renewal Period Months", "integer", contract.renewalPeriodMonths ?? null);
await addField("Auto Renewal", "boolean", contract.autoRenew ?? false);
await addField("Contract Price", "float", contract.price ?? null);
await addField("Contract Currency", "string", contract.currency ?? "EUR");
await addField("Contract Summary", "string", analysis?.summary ?? contract.notes ?? null);
const existingCustomFields = Array.isArray(document.custom_fields)
? document.custom_fields as Array<Record<string, unknown>>
: [];
for (const item of existingCustomFields) {
const fieldId = typeof item.field === "number" ? item.field : typeof item.id === "number" ? item.id : null;
if (fieldId !== null && !Object.prototype.hasOwnProperty.call(fieldValues, String(fieldId))) {
fieldValues[String(fieldId)] = (item.value as string | number | boolean | null | undefined) ?? null;
}
}
const updateUrl = new URL(this.buildUrl(`/api/documents/${documentId}/`));
await this.fetchJson<Record<string, unknown>>(updateUrl, {
method: "PATCH",
headers: this.getHeaders(true),
body: JSON.stringify({
tags: Array.from(tagIds),
custom_fields: fieldValues
})
});
}
async enrichDocuments(documents: PaperlessDocument[]): Promise<void> { async enrichDocuments(documents: PaperlessDocument[]): Promise<void> {
if (!documents.length) return; if (!documents.length) return;

View File

@@ -31,10 +31,25 @@ export interface RuntimeSettings {
authUsername: string | null; authUsername: string | null;
authPassword: string | null; authPassword: string | null;
icalSecret: string | null; icalSecret: string | null;
aiEnabled: boolean;
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
aiBaseUrl: string | null;
aiModel: string | null;
aiApiKey: string | null;
aiSystemPrompt: string | null;
aiTimeoutSeconds: number;
aiMaxTokens: number;
paperlessWebhookSecret: string | null;
} }
const numericKeys = new Set<SettingKey>(["schedulerIntervalMinutes", "alertDaysBefore", "mailPort"]); const numericKeys = new Set<SettingKey>([
const booleanKeys = new Set<SettingKey>(["mailUseTls"]); "schedulerIntervalMinutes",
"alertDaysBefore",
"mailPort",
"aiTimeoutSeconds",
"aiMaxTokens"
]);
const booleanKeys = new Set<SettingKey>(["mailUseTls", "aiEnabled"]);
function coerceNumber(value: unknown, fallback: number): number { function coerceNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
@@ -80,6 +95,13 @@ function normalizeLocale(value: unknown, fallback: string): string {
return fallback; return fallback;
} }
function normalizeAiProvider(value: unknown, fallback: RuntimeSettings["aiProvider"]): RuntimeSettings["aiProvider"] {
if (value === "openai" || value === "openai-compatible" || value === "gemini") {
return value;
}
return fallback;
}
export function getRuntimeSettings(): RuntimeSettings { export function getRuntimeSettings(): RuntimeSettings {
const stored = listSettings(); const stored = listSettings();
@@ -89,6 +111,12 @@ export function getRuntimeSettings(): RuntimeSettings {
); );
const alertDaysBefore = coerceNumber(stored.alertDaysBefore, config.alertDaysBefore); const alertDaysBefore = coerceNumber(stored.alertDaysBefore, config.alertDaysBefore);
const mailPort = stored.mailPort !== undefined ? coerceNumber(stored.mailPort, config.mailPort) : config.mailPort; const mailPort = stored.mailPort !== undefined ? coerceNumber(stored.mailPort, config.mailPort) : config.mailPort;
const aiTimeoutSeconds = stored.aiTimeoutSeconds !== undefined
? coerceNumber(stored.aiTimeoutSeconds, config.aiTimeoutSeconds)
: config.aiTimeoutSeconds;
const aiMaxTokens = stored.aiMaxTokens !== undefined
? coerceNumber(stored.aiMaxTokens, config.aiMaxTokens)
: config.aiMaxTokens;
return { return {
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null), paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
@@ -114,7 +142,19 @@ export function getRuntimeSettings(): RuntimeSettings {
ntfyPriority: coerceString(stored.ntfyPriority, config.ntfyPriority ?? null), ntfyPriority: coerceString(stored.ntfyPriority, config.ntfyPriority ?? null),
authUsername: coerceString(stored.authUsername, config.authUsername ?? null), authUsername: coerceString(stored.authUsername, config.authUsername ?? null),
authPassword: coerceString(stored.authPassword, config.authPassword ?? null), authPassword: coerceString(stored.authPassword, config.authPassword ?? null),
icalSecret: coerceString(stored.icalSecret, config.icalSecret ?? null) icalSecret: coerceString(stored.icalSecret, config.icalSecret ?? null),
aiEnabled:
stored.aiEnabled !== undefined
? coerceBoolean(stored.aiEnabled, config.aiEnabled)
: config.aiEnabled,
aiProvider: normalizeAiProvider(stored.aiProvider, config.aiProvider ?? null),
aiBaseUrl: coerceString(stored.aiBaseUrl, config.aiBaseUrl ?? null),
aiModel: coerceString(stored.aiModel, config.aiModel ?? null),
aiApiKey: coerceString(stored.aiApiKey, config.aiApiKey ?? null),
aiSystemPrompt: coerceString(stored.aiSystemPrompt, config.aiSystemPrompt ?? null),
aiTimeoutSeconds,
aiMaxTokens,
paperlessWebhookSecret: coerceString(stored.paperlessWebhookSecret, config.paperlessWebhookSecret ?? null)
}; };
} }
@@ -131,6 +171,14 @@ export function updateRuntimeSettings(update: Partial<RuntimeSettings>): Runtime
value = normalizeLocale(value, config.appLocale); value = normalizeLocale(value, config.appLocale);
} }
if (key === "aiProvider") {
value = normalizeAiProvider(value, null);
if (!value) {
removeSetting(key);
continue;
}
}
if (numericKeys.has(key)) { if (numericKeys.has(key)) {
const numericValue = coerceNumber(value, 0); const numericValue = coerceNumber(value, 0);
setSetting(key, numericValue); setSetting(key, numericValue);

View File

@@ -28,7 +28,16 @@ export type SettingKey =
| "ntfyPriority" | "ntfyPriority"
| "authUsername" | "authUsername"
| "authPassword" | "authPassword"
| "icalSecret"; | "icalSecret"
| "aiEnabled"
| "aiProvider"
| "aiBaseUrl"
| "aiModel"
| "aiApiKey"
| "aiSystemPrompt"
| "aiTimeoutSeconds"
| "aiMaxTokens"
| "paperlessWebhookSecret";
export type StoredSettings = Partial<Record<SettingKey, unknown>>; export type StoredSettings = Partial<Record<SettingKey, unknown>>;

View File

@@ -14,6 +14,24 @@ export interface ContractPayload {
tags?: string[]; tags?: string[];
} }
export interface ContractAnalysisResult {
isContract: boolean;
title: string | null;
provider: string | null;
category: string | null;
contractStartDate: string | null;
contractEndDate: string | null;
terminationNoticeDays: number | null;
renewalPeriodMonths: number | null;
autoRenew: boolean;
price: number | null;
currency: string | null;
notes: string | null;
tags: string[];
summary: string;
confidence: number;
}
export interface Contract extends ContractPayload { export interface Contract extends ContractPayload {
id: number; id: number;
createdAt: string; createdAt: string;