diff --git a/.env.portainer.example b/.env.portainer.example
index ff576fc..7dc43ea 100644
--- a/.env.portainer.example
+++ b/.env.portainer.example
@@ -42,3 +42,17 @@ WATCHTOWER_ENABLE=false
# Optional fixed iCal 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
diff --git a/README.md b/README.md
index d76b6e6..58ae697 100644
--- a/README.md
+++ b/README.md
@@ -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.
- ✅ **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.
+- ✅ **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.
- ✅ **Mehrsprachigkeit:** UI derzeit auf Deutsch und Englisch lokalisiert.
- ✅ **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_FROM` / `MAIL_TO` | *(leer)* | Absender/Empfänger für Mail-Benachrichtigungen. |
| `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
@@ -124,6 +131,9 @@ Die Sprache der automatisch verschickten Benachrichtigungen (Mail, ntfy) kannst
- `POST /categories` – Neue Kategorie (legt an oder liefert vorhandene).
- `DELETE /categories/:id` – Kategorie entfernen.
- `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 /calendar/feed.ics?token=` – iCal-Feed für Kündigungsfristen.
diff --git a/docker-compose.yml b/docker-compose.yml
index a6f7fa8..53681f8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,15 @@ services:
- MAIL_FROM
- MAIL_TO
- 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:
- contracts-data:/app/data
ports:
diff --git a/docs/portainer-stack.md b/docs/portainer-stack.md
index 2247ca4..a4ea90f 100644
--- a/docs/portainer-stack.md
+++ b/docs/portainer-stack.md
@@ -34,9 +34,25 @@ Set these values in Portainer before deploying:
| `PAPERLESS_EXTERNAL_URL` | unset | Public paperless-ngx URL for browser links. |
| `PAPERLESS_TOKEN` | unset | paperless-ngx API token. |
| `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.
+## 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
- Contract data is stored in the named Docker volume `contracts-data`.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 881ac80..cbe7f68 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,6 +3,8 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
import Layout from "./components/Layout";
import { useAuth } from "./contexts/AuthContext";
+import AiReviewDetail from "./routes/AiReviewDetail";
+import AiReviewList from "./routes/AiReviewList";
import CalendarView from "./routes/CalendarView";
import ContractDetail from "./routes/ContractDetail";
import ContractForm from "./routes/ContractForm";
@@ -45,6 +47,8 @@ export default function App() {
>
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/api/aiReviews.ts b/frontend/src/api/aiReviews.ts
new file mode 100644
index 0000000..32c954a
--- /dev/null
+++ b/frontend/src/api/aiReviews.ts
@@ -0,0 +1,33 @@
+import { AiReview, AiReviewStatus, Contract, ContractPayload } from "../types";
+import { request } from "./client";
+
+export async function fetchAiReviews(status?: AiReviewStatus): Promise {
+ const params = new URLSearchParams();
+ if (status) {
+ params.set("status", status);
+ }
+ const query = params.toString();
+ return request(`/ai/reviews${query ? `?${query}` : ""}`, { method: "GET" });
+}
+
+export async function fetchAiReview(id: number): Promise {
+ return request(`/ai/reviews/${id}`, { method: "GET" });
+}
+
+export async function retryAiReview(id: number): Promise {
+ return request(`/ai/reviews/${id}/retry`, { method: "POST" });
+}
+
+export async function rejectAiReview(id: number): Promise {
+ return request(`/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 }
+ });
+}
diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts
index 02e8ec8..911f3c9 100644
--- a/frontend/src/api/config.ts
+++ b/frontend/src/api/config.ts
@@ -18,6 +18,10 @@ export interface ServerConfig {
ntfyConfigured: boolean;
authEnabled: boolean;
authTokenExpiresInHours: number;
+ aiEnabled: boolean;
+ aiConfigured: boolean;
+ aiProvider: "openai" | "openai-compatible" | "gemini" | null;
+ paperlessWebhookConfigured: boolean;
}
export async function fetchServerConfig(): Promise {
@@ -42,12 +46,21 @@ export interface SettingsResponse {
ntfyTopic: string | null;
ntfyPriority: 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: {
paperlessTokenSet: boolean;
mailPasswordSet: boolean;
ntfyTokenSet: boolean;
authPasswordSet: boolean;
+ aiApiKeySet: boolean;
+ paperlessWebhookSecretSet: boolean;
};
icalSecret: string | null;
}
@@ -74,6 +87,15 @@ export type UpdateSettingsPayload = Partial<{
authUsername: string | null;
authPassword: 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 {
@@ -95,3 +117,7 @@ export async function triggerMailTest(): Promise {
export async function triggerNtfyTest(): Promise {
await request("/settings/test/ntfy", { method: "POST" });
}
+
+export async function triggerAiTest(): Promise {
+ await request("/settings/test/ai", { method: "POST" });
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 56e2e96..647fabb 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -3,6 +3,7 @@ import LogoutIcon from "@mui/icons-material/Logout";
import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
import DashboardIcon from "@mui/icons-material/Dashboard";
import DescriptionIcon from "@mui/icons-material/Description";
+import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import SettingsIcon from "@mui/icons-material/Settings";
import {
AppBar,
@@ -33,6 +34,7 @@ const drawerWidth = 240;
const navItems = [
{ key: "nav.dashboard", icon: , path: "/dashboard" },
+ { key: "nav.aiReviews", icon: , path: "/ai-reviews" },
{ key: "nav.contracts", icon: , path: "/contracts" },
{ key: "nav.calendar", icon: , path: "/calendar" },
{ key: "nav.settings", icon: , path: "/settings" }
diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json
index 0712718..8398058 100644
--- a/frontend/src/locales/de/common.json
+++ b/frontend/src/locales/de/common.json
@@ -1,6 +1,7 @@
{
"nav": {
"dashboard": "Dashboard",
+ "aiReviews": "AI-Prüfung",
"contracts": "Verträge",
"calendar": "Kalender",
"settings": "Einstellungen",
@@ -175,6 +176,29 @@
"scheduler": "Fristen & Benachrichtigungen",
"mail": "E-Mail",
"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",
"icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL",
@@ -261,6 +285,46 @@
"correspondent": "Korrespondent",
"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": {
"title": "Anmeldung",
"welcome": "Willkommen zurück! Bitte melde dich an.",
diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json
index 4729f09..0a1dc48 100644
--- a/frontend/src/locales/en/common.json
+++ b/frontend/src/locales/en/common.json
@@ -1,6 +1,7 @@
{
"nav": {
"dashboard": "Dashboard",
+ "aiReviews": "AI review",
"contracts": "Contracts",
"calendar": "Calendar",
"settings": "Settings",
@@ -175,6 +176,29 @@
"scheduler": "Deadlines & notifications",
"mail": "E-mail",
"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",
"icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL",
@@ -261,6 +285,46 @@
"correspondent": "Correspondent",
"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": {
"title": "Sign in",
"welcome": "Welcome back! Please sign in.",
diff --git a/frontend/src/routes/AiReviewDetail.tsx b/frontend/src/routes/AiReviewDetail.tsx
new file mode 100644
index 0000000..25f84e5
--- /dev/null
+++ b/frontend/src/routes/AiReviewDetail.tsx
@@ -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(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 = (key: K, value: FormState[K]) => {
+ setForm((current) => ({ ...current, [key]: value }));
+ };
+
+ if (isLoading) {
+ return {t("messages.loading")};
+ }
+
+ if (isError || !review) {
+ return {t("aiReviews.loadError")};
+ }
+
+ const canApprove = review.status !== "approved" && review.status !== "rejected" && Boolean(form.title.trim());
+
+ return (
+ <>
+ } variant="outlined">
+ {t("contractDetail.openInPaperless")}
+
+ ) : undefined
+ }
+ />
+
+
+
+
+
+
+
+ {review.confidence !== null && (
+
+ )}
+
+ {review.error && {review.error}}
+ {review.analysis ? (
+ <>
+ {t("aiReviews.summary")}
+ {review.analysis.summary}
+
+ {review.analysis.isContract ? t("aiReviews.detectedContract") : t("aiReviews.detectedNoContract")}
+
+ >
+ ) : (
+ {t("aiReviews.noAnalysis")}
+ )}
+
+ }
+ variant="outlined"
+ onClick={() => retryMutation.mutate()}
+ disabled={retryMutation.isPending || review.status === "approved"}
+ >
+ {t("aiReviews.retry")}
+
+ }
+ color="warning"
+ variant="outlined"
+ onClick={() => rejectMutation.mutate()}
+ disabled={rejectMutation.isPending || review.status === "approved" || review.status === "rejected"}
+ >
+ {t("aiReviews.reject")}
+
+
+
+
+
+
+
+
+
+ {t("aiReviews.contractDraft")}
+ {!review.contractPayload && (
+ {t("aiReviews.noContractDraft")}
+ )}
+
+
+ updateField("title", event.target.value)} fullWidth required />
+
+
+ updateField("provider", event.target.value)} fullWidth />
+
+
+ updateField("category", event.target.value)} fullWidth />
+
+
+ updateField("currency", event.target.value)} fullWidth />
+
+
+ updateField("contractStartDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
+
+
+ updateField("contractEndDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
+
+
+ updateField("terminationNoticeDays", event.target.value)} fullWidth />
+
+
+ updateField("renewalPeriodMonths", event.target.value)} fullWidth />
+
+
+ updateField("price", event.target.value)} fullWidth />
+
+
+ updateField("autoRenew", event.target.checked)} />}
+ label={t("contractForm.fields.autoRenew")}
+ />
+
+
+ updateField("tags", event.target.value)} fullWidth />
+
+
+ updateField("notes", event.target.value)} fullWidth multiline minRows={4} />
+
+
+
+
+ }
+ variant="contained"
+ onClick={() => approveMutation.mutate()}
+ disabled={!canApprove || approveMutation.isPending}
+ >
+ {t("aiReviews.approve")}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/routes/AiReviewList.tsx b/frontend/src/routes/AiReviewList.tsx
new file mode 100644
index 0000000..77bc692
--- /dev/null
+++ b/frontend/src/routes/AiReviewList.tsx
@@ -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 (
+ <>
+ }
+ onClick={() => refetch()}
+ disabled={isFetching}
+ >
+ {t("aiReviews.refresh")}
+
+ }
+ />
+
+ {!serverConfig?.aiConfigured && (
+
+ {t("aiReviews.aiNotConfigured")}
+
+ )}
+ {!serverConfig?.paperlessWebhookConfigured && (
+
+ {t("aiReviews.webhookNotConfigured")}
+
+ )}
+
+
+
+
+
+ {t("aiReviews.columns.document")}
+ {t("aiReviews.columns.status")}
+ {t("aiReviews.columns.confidence")}
+ {t("aiReviews.columns.updated")}
+ {t("aiReviews.columns.action")}
+
+
+
+ {isLoading && (
+
+ {t("messages.loading")}
+
+ )}
+ {isError && (
+
+
+ {t("aiReviews.loadError")}
+
+
+ )}
+ {!isLoading && !isError && (reviews ?? []).length === 0 && (
+
+
+
+
+ {t("aiReviews.empty")}
+
+
+
+ )}
+ {(reviews ?? []).map((review) => (
+ navigate(`/ai-reviews/${review.id}`)}
+ >
+
+
+ {review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
+
+
+ Paperless #{review.paperlessDocumentId}
+
+
+
+
+
+
+ {review.confidence !== null ? `${Math.round(review.confidence * 100)}%` : "-"}
+
+ {formatDate(review.updatedAt, "dd.MM.yyyy HH:mm")}
+
+
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/routes/Settings.tsx b/frontend/src/routes/Settings.tsx
index 63d4709..ac79a8d 100644
--- a/frontend/src/routes/Settings.tsx
+++ b/frontend/src/routes/Settings.tsx
@@ -35,6 +35,7 @@ import {
fetchServerConfig,
fetchSettings,
resetIcalSecret,
+ triggerAiTest,
triggerMailTest,
triggerNtfyTest,
ServerConfig,
@@ -75,6 +76,15 @@ type FormValues = {
ntfyPriority: string;
authUsername: 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 = {
@@ -97,7 +107,16 @@ const defaultValues: FormValues = {
ntfyToken: "",
ntfyPriority: "default",
authUsername: "",
- authPassword: ""
+ authPassword: "",
+ aiEnabled: false,
+ aiProvider: "",
+ aiBaseUrl: "",
+ aiModel: "",
+ aiApiKey: "",
+ aiSystemPrompt: "",
+ aiTimeoutSeconds: 60,
+ aiMaxTokens: 2000,
+ paperlessWebhookSecret: ""
};
export default function SettingsPage() {
@@ -146,6 +165,8 @@ export default function SettingsPage() {
const [removeMailPassword, setRemoveMailPassword] = useState(false);
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
+ const [removeAiApiKey, setRemoveAiApiKey] = useState(false);
+ const [removeWebhookSecret, setRemoveWebhookSecret] = useState(false);
const {
control,
@@ -182,7 +203,16 @@ export default function SettingsPage() {
ntfyToken: "",
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
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;
@@ -190,6 +220,8 @@ export default function SettingsPage() {
setRemoveMailPassword(false);
setRemoveNtfyToken(false);
setRemoveAuthPassword(false);
+ setRemoveAiApiKey(false);
+ setRemoveWebhookSecret(false);
reset(values);
}, [settingsData, reset]);
@@ -225,6 +257,12 @@ export default function SettingsPage() {
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({
mutationFn: (name: string) => apiCreateCategory(name),
onSuccess: async (category) => {
@@ -386,6 +424,38 @@ export default function SettingsPage() {
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) {
showMessage(t("settings.noChanges"), "info");
return;
@@ -425,6 +495,8 @@ export default function SettingsPage() {
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
+ const aiApiKeySet = settings?.secrets.aiApiKeySet ?? false;
+ const paperlessWebhookSecretSet = settings?.secrets.paperlessWebhookSecretSet ?? false;
return (
<>
@@ -675,6 +747,135 @@ export default function SettingsPage() {
+
+
+
+ {t("settings.ai")}
+
+ {loading ? (
+
+ ) : (
+
+ }
+ />
+ }
+ label={t("settings.aiEnabled")}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {aiApiKeySet && !removeAiApiKey && (
+
+ {t("settings.aiApiKeyInfo")}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {paperlessWebhookSecretSet && !removeWebhookSecret && (
+
+ {t("settings.webhookSecretInfo")}
+
+ )}
+
+
+ {aiTestMutation.isPending && }
+
+ {aiTestMutation.isError && (
+ {(aiTestMutation.error as Error).message ?? t("settings.aiTestError")}
+ )}
+
+ )}
+
+
+
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 420f7fd..351e0b0 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -54,3 +54,47 @@ export interface PaperlessSearchResponse {
previous?: string | null;
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;
+}
diff --git a/src/aiProviders.ts b/src/aiProviders.ts
new file mode 100644
index 0000000..12d148a
--- /dev/null
+++ b/src/aiProviders.ts
@@ -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 | 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,
+ document: AiDocumentInput
+): Promise {
+ 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,
+ document: AiDocumentInput
+): Promise {
+ 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 {
+ 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 {
+ 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: {}
+ });
+}
diff --git a/src/aiReviewProcessor.ts b/src/aiReviewProcessor.ts
new file mode 100644
index 0000000..09630f7
--- /dev/null
+++ b/src/aiReviewProcessor.ts
@@ -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();
+
+function readString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+function extractDocumentTitle(document: Record, fallbackId: number): string {
+ return readString(document.title) ?? `Paperless document #${fallbackId}`;
+}
+
+function extractCorrespondent(document: Record): string | null {
+ return (
+ readString(document.correspondent_name) ??
+ readString(document.correspondent__name) ??
+ (document.metadata && typeof document.metadata === "object"
+ ? readString((document.metadata as Record).correspondent_name)
+ : null)
+ );
+}
+
+function extractTags(document: Record): 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;
+ 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;
+ 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 {
+ 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
+ : {}
+ });
+
+ 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);
+ }
+}
diff --git a/src/aiReviewStore.ts b/src/aiReviewStore.ts
new file mode 100644
index 0000000..7770d29
--- /dev/null
+++ b/src/aiReviewStore.ts
@@ -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(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(row.analysis_json),
+ contractPayload: parseJson(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);
+}
diff --git a/src/config.ts b/src/config.ts
index 7bbcf75..b6cc2f3 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -28,7 +28,16 @@ const configSchema = z.object({
ntfyTopic: z.string().min(1).optional(),
ntfyToken: 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 {
@@ -72,7 +81,16 @@ const rawConfig = {
ntfyTopic: readEnv("NTFY_TOPIC"),
ntfyToken: readEnv("NTFY_TOKEN"),
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;
diff --git a/src/db.ts b/src/db.ts
index 15484c0..b2f30de 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -50,6 +50,24 @@ CREATE TABLE IF NOT EXISTS categories (
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
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;
};
+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;
diff --git a/src/index.ts b/src/index.ts
index c0c08c3..c3cb0a0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,24 @@
+import crypto from "node:crypto";
+
import express, { NextFunction, Request, Response } from "express";
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 {
createContract,
@@ -89,7 +107,16 @@ const settingsUpdateSchema = z.object({
ntfyPriority: z.string().nullable().optional(),
authUsername: 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({
@@ -114,13 +141,22 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
ntfyServerUrl: runtime.ntfyServerUrl,
ntfyTopic: runtime.ntfyTopic,
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: {
paperlessTokenSet: Boolean(runtime.paperlessToken),
mailPasswordSet: Boolean(runtime.mailPassword),
ntfyTokenSet: Boolean(runtime.ntfyToken),
- authPasswordSet: Boolean(runtime.authPassword)
+ authPasswordSet: Boolean(runtime.authPassword),
+ aiApiKeySet: Boolean(runtime.aiApiKey),
+ paperlessWebhookSecretSet: Boolean(runtime.paperlessWebhookSecret)
},
icalSecret: runtime.icalSecret
};
@@ -179,6 +215,50 @@ function buildIcsFeed(
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;
+ 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) => {
res.json({ status: "ok" });
});
@@ -256,6 +336,30 @@ app.get("/calendar/feed.ics", (req, res) => {
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).title === "string"
+ ? (req.body as Record).title
+ : null;
+ const review = enqueuePaperlessDocumentAnalysis(documentId, documentTitle);
+ res.status(202).json({ status: review.status, reviewId: review.id, paperlessDocumentId: documentId });
+});
+
app.use(authenticateRequest);
app.get("/config", (_req, res) => {
@@ -263,6 +367,7 @@ app.get("/config", (_req, res) => {
const paperlessConfigured = Boolean(runtime.paperlessBaseUrl && runtime.paperlessToken);
const mailConfigured = Boolean(runtime.mailServer && runtime.mailFrom && runtime.mailTo);
const ntfyConfigured = Boolean(runtime.ntfyServerUrl && runtime.ntfyTopic);
+ const aiConfigured = Boolean(runtime.aiEnabled && runtime.aiProvider && runtime.aiApiKey && runtime.aiModel);
res.json({
port: config.port,
@@ -281,7 +386,11 @@ app.get("/config", (_req, res) => {
mailUseTls: runtime.mailUseTls,
ntfyConfigured,
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")) {
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);
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) => {
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);
@@ -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) => {
const skip = Number(req.query.skip ?? 0);
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.");
}
deadlineMonitor.start();
+ processPendingAiReviews();
});
process.on("SIGTERM", () => {
diff --git a/src/paperlessClient.ts b/src/paperlessClient.ts
index 702dd8a..519cb84 100644
--- a/src/paperlessClient.ts
+++ b/src/paperlessClient.ts
@@ -1,6 +1,7 @@
import { config } from "./config.js";
import { createLogger } from "./logger.js";
import { getRuntimeSettings } from "./runtimeSettings.js";
+import { ContractAnalysisResult, ContractPayload } from "./types.js";
const logger = createLogger(config.logLevel);
@@ -26,10 +27,13 @@ export class PaperlessClient {
return `${trimmedBase}/${trimmedPath}`;
}
- private getHeaders(): HeadersInit {
+ private getHeaders(json = false): HeadersInit {
const headers: Record = {
Accept: "application/json"
};
+ if (json) {
+ headers["Content-Type"] = "application/json";
+ }
const { paperlessToken } = getRuntimeSettings();
if (paperlessToken) {
headers.Authorization = `Token ${paperlessToken}`;
@@ -37,8 +41,8 @@ export class PaperlessClient {
return headers;
}
- private async fetchJson(url: URL): Promise {
- const response = await fetch(url, { headers: this.getHeaders() });
+ private async fetchJson(url: URL, init: RequestInit = {}): Promise {
+ const response = await fetch(url, { ...init, headers: init.headers ?? this.getHeaders() });
if (!response.ok) {
const text = await response.text();
logger.error(`Paperless API error ${response.status}: ${text}`);
@@ -89,6 +93,138 @@ export class PaperlessClient {
return payload;
}
+ async ensureTag(name: string): Promise {
+ 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>>(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>(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 {
+ 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>>(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>(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 {
+ 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();
+ 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).id === "number") {
+ tagIds.add((tag as Record).id);
+ }
+ }
+ tagIds.add(await this.ensureTag("contract"));
+ tagIds.add(await this.ensureTag("contract-ai-reviewed"));
+
+ const fieldValues: Record = {};
+ 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>
+ : [];
+ 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>(updateUrl, {
+ method: "PATCH",
+ headers: this.getHeaders(true),
+ body: JSON.stringify({
+ tags: Array.from(tagIds),
+ custom_fields: fieldValues
+ })
+ });
+ }
+
async enrichDocuments(documents: PaperlessDocument[]): Promise {
if (!documents.length) return;
diff --git a/src/runtimeSettings.ts b/src/runtimeSettings.ts
index 6f0754c..2d36c59 100644
--- a/src/runtimeSettings.ts
+++ b/src/runtimeSettings.ts
@@ -31,10 +31,25 @@ export interface RuntimeSettings {
authUsername: string | null;
authPassword: 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(["schedulerIntervalMinutes", "alertDaysBefore", "mailPort"]);
-const booleanKeys = new Set(["mailUseTls"]);
+const numericKeys = new Set([
+ "schedulerIntervalMinutes",
+ "alertDaysBefore",
+ "mailPort",
+ "aiTimeoutSeconds",
+ "aiMaxTokens"
+]);
+const booleanKeys = new Set(["mailUseTls", "aiEnabled"]);
function coerceNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -80,6 +95,13 @@ function normalizeLocale(value: unknown, fallback: string): string {
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 {
const stored = listSettings();
@@ -89,6 +111,12 @@ export function getRuntimeSettings(): RuntimeSettings {
);
const alertDaysBefore = coerceNumber(stored.alertDaysBefore, config.alertDaysBefore);
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 {
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
@@ -114,7 +142,19 @@ export function getRuntimeSettings(): RuntimeSettings {
ntfyPriority: coerceString(stored.ntfyPriority, config.ntfyPriority ?? null),
authUsername: coerceString(stored.authUsername, config.authUsername ?? 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): Runtime
value = normalizeLocale(value, config.appLocale);
}
+ if (key === "aiProvider") {
+ value = normalizeAiProvider(value, null);
+ if (!value) {
+ removeSetting(key);
+ continue;
+ }
+ }
+
if (numericKeys.has(key)) {
const numericValue = coerceNumber(value, 0);
setSetting(key, numericValue);
diff --git a/src/settingsStore.ts b/src/settingsStore.ts
index dd81ba8..b8348c9 100644
--- a/src/settingsStore.ts
+++ b/src/settingsStore.ts
@@ -28,7 +28,16 @@ export type SettingKey =
| "ntfyPriority"
| "authUsername"
| "authPassword"
- | "icalSecret";
+ | "icalSecret"
+ | "aiEnabled"
+ | "aiProvider"
+ | "aiBaseUrl"
+ | "aiModel"
+ | "aiApiKey"
+ | "aiSystemPrompt"
+ | "aiTimeoutSeconds"
+ | "aiMaxTokens"
+ | "paperlessWebhookSecret";
export type StoredSettings = Partial>;
diff --git a/src/types.ts b/src/types.ts
index 7436899..6c67e17 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -14,6 +14,24 @@ export interface ContractPayload {
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 {
id: number;
createdAt: string;