From f913bc0ba678221e4695acaba676d5b15a3904e1 Mon Sep 17 00:00:00 2001 From: Meik Date: Thu, 7 May 2026 20:04:30 +0200 Subject: [PATCH] Add AI review workflow for Paperless documents --- .env.portainer.example | 14 ++ README.md | 10 + docker-compose.yml | 9 + docs/portainer-stack.md | 16 ++ frontend/src/App.tsx | 4 + frontend/src/api/aiReviews.ts | 33 +++ frontend/src/api/config.ts | 26 ++ frontend/src/components/Layout.tsx | 2 + frontend/src/locales/de/common.json | 64 +++++ frontend/src/locales/en/common.json | 64 +++++ frontend/src/routes/AiReviewDetail.tsx | 328 +++++++++++++++++++++++++ frontend/src/routes/AiReviewList.tsx | 154 ++++++++++++ frontend/src/routes/Settings.tsx | 205 +++++++++++++++- frontend/src/types.ts | 44 ++++ src/aiProviders.ts | 296 ++++++++++++++++++++++ src/aiReviewProcessor.ts | 161 ++++++++++++ src/aiReviewStore.ts | 226 +++++++++++++++++ src/config.ts | 22 +- src/db.ts | 36 +++ src/index.ts | 245 +++++++++++++++++- src/paperlessClient.ts | 142 ++++++++++- src/runtimeSettings.ts | 54 +++- src/settingsStore.ts | 11 +- src/types.ts | 18 ++ 24 files changed, 2169 insertions(+), 15 deletions(-) create mode 100644 frontend/src/api/aiReviews.ts create mode 100644 frontend/src/routes/AiReviewDetail.tsx create mode 100644 frontend/src/routes/AiReviewList.tsx create mode 100644 src/aiProviders.ts create mode 100644 src/aiReviewProcessor.ts create mode 100644 src/aiReviewStore.ts 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")} + )} + + + + + + + + + + + + {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} /> + + + + + + + + + + + + ); +} 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")} + /> + + {t("settings.aiProviderNone")} + OpenAI + OpenAI compatible + Gemini + + + + + + + + {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;