Add AI review workflow for Paperless documents
This commit is contained in:
@@ -42,3 +42,17 @@ WATCHTOWER_ENABLE=false
|
|||||||
|
|
||||||
# Optional fixed iCal token.
|
# Optional fixed iCal token.
|
||||||
# ICAL_SECRET=replace-with-long-random-token
|
# ICAL_SECRET=replace-with-long-random-token
|
||||||
|
|
||||||
|
# Optional AI document analysis.
|
||||||
|
# AI_ENABLED=false
|
||||||
|
# AI_PROVIDER=openai
|
||||||
|
# AI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# AI_MODEL=gpt-4o-mini
|
||||||
|
# AI_API_KEY=replace-with-ai-api-key
|
||||||
|
# AI_SYSTEM_PROMPT=
|
||||||
|
# AI_TIMEOUT_SECONDS=60
|
||||||
|
# AI_MAX_TOKENS=2000
|
||||||
|
|
||||||
|
# Optional paperless workflow webhook secret.
|
||||||
|
# Paperless should send this value as x-contract-companion-secret.
|
||||||
|
# PAPERLESS_WEBHOOK_SECRET=replace-with-long-random-webhook-secret
|
||||||
|
|||||||
10
README.md
10
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.
|
- ✅ **Kategorie-Verwaltung:** Dropdown mit Vorschlägen, Inline-Neuanlage im Formular & Verwaltung unter Einstellungen.
|
||||||
- ✅ **Modernes React-Frontend** (Vite + MUI) mit Dashboard, Vertragsliste, Kalender, Detailansicht und umfangreichen Einstellungen.
|
- ✅ **Modernes React-Frontend** (Vite + MUI) mit Dashboard, Vertragsliste, Kalender, Detailansicht und umfangreichen Einstellungen.
|
||||||
- ✅ **Benachrichtigungen & Automatisierung:** Scheduler prüft Deadlines, optionaler Mailversand, ntfy-Push, iCal-Feed zum Abonnieren.
|
- ✅ **Benachrichtigungen & Automatisierung:** Scheduler prüft Deadlines, optionaler Mailversand, ntfy-Push, iCal-Feed zum Abonnieren.
|
||||||
|
- ✅ **AI-gestützte Dokumentprüfung:** Paperless-Webhooks können neue Dokumente zur Analyse einreihen; Verträge werden nach manueller Freigabe angelegt und nach Paperless zurückgeschrieben.
|
||||||
- ✅ **Konfigurierbare Authentifizierung:** Optionales Login mit JWT, Benutzername/Passwort verwaltbar in den Settings.
|
- ✅ **Konfigurierbare Authentifizierung:** Optionales Login mit JWT, Benutzername/Passwort verwaltbar in den Settings.
|
||||||
- ✅ **Mehrsprachigkeit:** UI derzeit auf Deutsch und Englisch lokalisiert.
|
- ✅ **Mehrsprachigkeit:** UI derzeit auf Deutsch und Englisch lokalisiert.
|
||||||
- ✅ **Docker-ready:** Backend und Frontend als Container, Konfiguration via `docker-compose.yml`.
|
- ✅ **Docker-ready:** Backend und Frontend als Container, Konfiguration via `docker-compose.yml`.
|
||||||
@@ -99,6 +100,12 @@ Für Portainer-Deployments ist `docker-compose.yml` als Git-basierter Stack vorb
|
|||||||
| `MAIL_SERVER` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` | *(leer)* | SMTP-Settings (TLS optional über `MAIL_USE_TLS`). |
|
| `MAIL_SERVER` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` | *(leer)* | SMTP-Settings (TLS optional über `MAIL_USE_TLS`). |
|
||||||
| `MAIL_FROM` / `MAIL_TO` | *(leer)* | Absender/Empfänger für Mail-Benachrichtigungen. |
|
| `MAIL_FROM` / `MAIL_TO` | *(leer)* | Absender/Empfänger für Mail-Benachrichtigungen. |
|
||||||
| `ICAL_SECRET` | *(leer)* | Optional vorgegebenes Token für den iCal-Feed. |
|
| `ICAL_SECRET` | *(leer)* | Optional vorgegebenes Token für den iCal-Feed. |
|
||||||
|
| `AI_ENABLED` | `false` | Aktiviert AI-Analyse, wenn Provider, Modell und API-Key gesetzt sind. |
|
||||||
|
| `AI_PROVIDER` | *(leer)* | `openai`, `openai-compatible` oder `gemini`. |
|
||||||
|
| `AI_BASE_URL` | *(Provider-Standard)* | Optionaler Endpoint für OpenAI-kompatible Anbieter oder eigene Gateways. |
|
||||||
|
| `AI_MODEL` | *(leer)* | Modellname für die Vertragsanalyse. |
|
||||||
|
| `AI_API_KEY` | *(leer)* | API-Key des AI-Anbieters. |
|
||||||
|
| `PAPERLESS_WEBHOOK_SECRET` | *(leer)* | Shared Secret für Paperless-Webhooks an `/api/integrations/paperless/webhook`. |
|
||||||
|
|
||||||
### Paperless optional nutzen
|
### Paperless optional nutzen
|
||||||
|
|
||||||
@@ -124,6 +131,9 @@ Die Sprache der automatisch verschickten Benachrichtigungen (Mail, ntfy) kannst
|
|||||||
- `POST /categories` – Neue Kategorie (legt an oder liefert vorhandene).
|
- `POST /categories` – Neue Kategorie (legt an oder liefert vorhandene).
|
||||||
- `DELETE /categories/:id` – Kategorie entfernen.
|
- `DELETE /categories/:id` – Kategorie entfernen.
|
||||||
- `GET /integrations/paperless/search?q=` – Paperless-Dokumente per Text oder ID finden.
|
- `GET /integrations/paperless/search?q=` – Paperless-Dokumente per Text oder ID finden.
|
||||||
|
- `POST /integrations/paperless/webhook` – Neues Paperless-Dokument zur AI-Prüfung einreihen.
|
||||||
|
- `GET /ai/reviews` – AI-Prüfwarteschlange abrufen.
|
||||||
|
- `POST /ai/reviews/:id/approve` – Vertragsentwurf freigeben, Vertrag anlegen und Paperless aktualisieren.
|
||||||
- `GET /reports/upcoming?days=` – Deadlines innerhalb der nächsten `days` Tage.
|
- `GET /reports/upcoming?days=` – Deadlines innerhalb der nächsten `days` Tage.
|
||||||
- `GET /calendar/feed.ics?token=` – iCal-Feed für Kündigungsfristen.
|
- `GET /calendar/feed.ics?token=` – iCal-Feed für Kündigungsfristen.
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ services:
|
|||||||
- MAIL_FROM
|
- MAIL_FROM
|
||||||
- MAIL_TO
|
- MAIL_TO
|
||||||
- ICAL_SECRET
|
- ICAL_SECRET
|
||||||
|
- AI_ENABLED
|
||||||
|
- AI_PROVIDER
|
||||||
|
- AI_BASE_URL
|
||||||
|
- AI_MODEL
|
||||||
|
- AI_API_KEY
|
||||||
|
- AI_SYSTEM_PROMPT
|
||||||
|
- AI_TIMEOUT_SECONDS
|
||||||
|
- AI_MAX_TOKENS
|
||||||
|
- PAPERLESS_WEBHOOK_SECRET
|
||||||
volumes:
|
volumes:
|
||||||
- contracts-data:/app/data
|
- contracts-data:/app/data
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -34,9 +34,25 @@ Set these values in Portainer before deploying:
|
|||||||
| `PAPERLESS_EXTERNAL_URL` | unset | Public paperless-ngx URL for browser links. |
|
| `PAPERLESS_EXTERNAL_URL` | unset | Public paperless-ngx URL for browser links. |
|
||||||
| `PAPERLESS_TOKEN` | unset | paperless-ngx API token. |
|
| `PAPERLESS_TOKEN` | unset | paperless-ngx API token. |
|
||||||
| `WATCHTOWER_ENABLE` | `false` | Enables Watchtower updates when set to `true`. |
|
| `WATCHTOWER_ENABLE` | `false` | Enables Watchtower updates when set to `true`. |
|
||||||
|
| `AI_ENABLED` | `false` | Enables AI document analysis when provider settings are complete. |
|
||||||
|
| `AI_PROVIDER` | unset | `openai`, `openai-compatible`, or `gemini`. |
|
||||||
|
| `AI_BASE_URL` | provider default | Optional endpoint for OpenAI-compatible providers or custom gateways. |
|
||||||
|
| `AI_MODEL` | unset | Model name used for extraction. |
|
||||||
|
| `AI_API_KEY` | unset | API key for the selected AI provider. |
|
||||||
|
| `PAPERLESS_WEBHOOK_SECRET` | unset | Shared secret Paperless sends to `/api/integrations/paperless/webhook`. |
|
||||||
|
|
||||||
Leave optional variables unset when they are not used. The backend treats blank optional variables as absent, which keeps Portainer edits from breaking URL validation.
|
Leave optional variables unset when they are not used. The backend treats blank optional variables as absent, which keeps Portainer edits from breaking URL validation.
|
||||||
|
|
||||||
|
## Paperless Webhook
|
||||||
|
|
||||||
|
Create a Paperless workflow for newly consumed documents and add an HTTP webhook action pointing to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://your-contract-app.example.com/api/integrations/paperless/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
Send the document id in the JSON body as `document_id` and include the shared secret as header `x-contract-companion-secret`. The app will enqueue the document, analyze it with the configured AI provider, and wait for manual approval before writing tags/custom fields back to Paperless.
|
||||||
|
|
||||||
## Data and Networking
|
## Data and Networking
|
||||||
|
|
||||||
- Contract data is stored in the named Docker volume `contracts-data`.
|
- Contract data is stored in the named Docker volume `contracts-data`.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
|
|||||||
|
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import { useAuth } from "./contexts/AuthContext";
|
import { useAuth } from "./contexts/AuthContext";
|
||||||
|
import AiReviewDetail from "./routes/AiReviewDetail";
|
||||||
|
import AiReviewList from "./routes/AiReviewList";
|
||||||
import CalendarView from "./routes/CalendarView";
|
import CalendarView from "./routes/CalendarView";
|
||||||
import ContractDetail from "./routes/ContractDetail";
|
import ContractDetail from "./routes/ContractDetail";
|
||||||
import ContractForm from "./routes/ContractForm";
|
import ContractForm from "./routes/ContractForm";
|
||||||
@@ -45,6 +47,8 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="ai-reviews" element={<AiReviewList />} />
|
||||||
|
<Route path="ai-reviews/:reviewId" element={<AiReviewDetail />} />
|
||||||
<Route path="contracts" element={<ContractsList />} />
|
<Route path="contracts" element={<ContractsList />} />
|
||||||
<Route path="contracts/new" element={<ContractForm mode="create" />} />
|
<Route path="contracts/new" element={<ContractForm mode="create" />} />
|
||||||
<Route path="contracts/:contractId" element={<ContractDetail />} />
|
<Route path="contracts/:contractId" element={<ContractDetail />} />
|
||||||
|
|||||||
33
frontend/src/api/aiReviews.ts
Normal file
33
frontend/src/api/aiReviews.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { AiReview, AiReviewStatus, Contract, ContractPayload } from "../types";
|
||||||
|
import { request } from "./client";
|
||||||
|
|
||||||
|
export async function fetchAiReviews(status?: AiReviewStatus): Promise<AiReview[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status) {
|
||||||
|
params.set("status", status);
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return request<AiReview[]>(`/ai/reviews${query ? `?${query}` : ""}`, { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAiReview(id: number): Promise<AiReview> {
|
||||||
|
return request<AiReview>(`/ai/reviews/${id}`, { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryAiReview(id: number): Promise<AiReview> {
|
||||||
|
return request<AiReview>(`/ai/reviews/${id}/retry`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAiReview(id: number): Promise<AiReview> {
|
||||||
|
return request<AiReview>(`/ai/reviews/${id}/reject`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveAiReview(
|
||||||
|
id: number,
|
||||||
|
contract: ContractPayload
|
||||||
|
): Promise<{ review: AiReview; contract: Contract }> {
|
||||||
|
return request<{ review: AiReview; contract: Contract }>(`/ai/reviews/${id}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { contract }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ export interface ServerConfig {
|
|||||||
ntfyConfigured: boolean;
|
ntfyConfigured: boolean;
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
authTokenExpiresInHours: number;
|
authTokenExpiresInHours: number;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiConfigured: boolean;
|
||||||
|
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||||
|
paperlessWebhookConfigured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchServerConfig(): Promise<ServerConfig> {
|
export async function fetchServerConfig(): Promise<ServerConfig> {
|
||||||
@@ -42,12 +46,21 @@ export interface SettingsResponse {
|
|||||||
ntfyTopic: string | null;
|
ntfyTopic: string | null;
|
||||||
ntfyPriority: string | null;
|
ntfyPriority: string | null;
|
||||||
authUsername: string | null;
|
authUsername: string | null;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||||
|
aiBaseUrl: string | null;
|
||||||
|
aiModel: string | null;
|
||||||
|
aiSystemPrompt: string | null;
|
||||||
|
aiTimeoutSeconds: number;
|
||||||
|
aiMaxTokens: number;
|
||||||
};
|
};
|
||||||
secrets: {
|
secrets: {
|
||||||
paperlessTokenSet: boolean;
|
paperlessTokenSet: boolean;
|
||||||
mailPasswordSet: boolean;
|
mailPasswordSet: boolean;
|
||||||
ntfyTokenSet: boolean;
|
ntfyTokenSet: boolean;
|
||||||
authPasswordSet: boolean;
|
authPasswordSet: boolean;
|
||||||
|
aiApiKeySet: boolean;
|
||||||
|
paperlessWebhookSecretSet: boolean;
|
||||||
};
|
};
|
||||||
icalSecret: string | null;
|
icalSecret: string | null;
|
||||||
}
|
}
|
||||||
@@ -74,6 +87,15 @@ export type UpdateSettingsPayload = Partial<{
|
|||||||
authUsername: string | null;
|
authUsername: string | null;
|
||||||
authPassword: string | null;
|
authPassword: string | null;
|
||||||
icalSecret: string | null;
|
icalSecret: string | null;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||||
|
aiBaseUrl: string | null;
|
||||||
|
aiModel: string | null;
|
||||||
|
aiApiKey: string | null;
|
||||||
|
aiSystemPrompt: string | null;
|
||||||
|
aiTimeoutSeconds: number;
|
||||||
|
aiMaxTokens: number;
|
||||||
|
paperlessWebhookSecret: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function fetchSettings(): Promise<SettingsResponse> {
|
export async function fetchSettings(): Promise<SettingsResponse> {
|
||||||
@@ -95,3 +117,7 @@ export async function triggerMailTest(): Promise<void> {
|
|||||||
export async function triggerNtfyTest(): Promise<void> {
|
export async function triggerNtfyTest(): Promise<void> {
|
||||||
await request("/settings/test/ntfy", { method: "POST" });
|
await request("/settings/test/ntfy", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function triggerAiTest(): Promise<void> {
|
||||||
|
await request("/settings/test/ai", { method: "POST" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import LogoutIcon from "@mui/icons-material/Logout";
|
|||||||
import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
|
import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
|
||||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||||
import DescriptionIcon from "@mui/icons-material/Description";
|
import DescriptionIcon from "@mui/icons-material/Description";
|
||||||
|
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
@@ -33,6 +34,7 @@ const drawerWidth = 240;
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ key: "nav.dashboard", icon: <DashboardIcon />, path: "/dashboard" },
|
{ key: "nav.dashboard", icon: <DashboardIcon />, path: "/dashboard" },
|
||||||
|
{ key: "nav.aiReviews", icon: <AutoAwesomeIcon />, path: "/ai-reviews" },
|
||||||
{ key: "nav.contracts", icon: <DescriptionIcon />, path: "/contracts" },
|
{ key: "nav.contracts", icon: <DescriptionIcon />, path: "/contracts" },
|
||||||
{ key: "nav.calendar", icon: <CalendarMonthIcon />, path: "/calendar" },
|
{ key: "nav.calendar", icon: <CalendarMonthIcon />, path: "/calendar" },
|
||||||
{ key: "nav.settings", icon: <SettingsIcon />, path: "/settings" }
|
{ key: "nav.settings", icon: <SettingsIcon />, path: "/settings" }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
"aiReviews": "AI-Prüfung",
|
||||||
"contracts": "Verträge",
|
"contracts": "Verträge",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
@@ -175,6 +176,29 @@
|
|||||||
"scheduler": "Fristen & Benachrichtigungen",
|
"scheduler": "Fristen & Benachrichtigungen",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"ntfy": "ntfy Push",
|
"ntfy": "ntfy Push",
|
||||||
|
"ai": "AI-Analyse",
|
||||||
|
"aiEnabled": "AI-Analyse aktivieren",
|
||||||
|
"aiProvider": "AI-Anbieter",
|
||||||
|
"aiProviderNone": "Kein Anbieter",
|
||||||
|
"aiBaseUrl": "AI Base URL",
|
||||||
|
"aiBaseUrlPlaceholder": "Leer lassen für Anbieter-Standard",
|
||||||
|
"aiBaseUrlHelp": "Für OpenAI-kompatible Anbieter oder eigene Gateways.",
|
||||||
|
"aiModel": "Modell",
|
||||||
|
"aiApiKey": "API-Key",
|
||||||
|
"aiApiKeyNew": "Neuen API-Key hinterlegen",
|
||||||
|
"aiApiKeyRemove": "API-Key löschen",
|
||||||
|
"aiApiKeyInfo": "Ein API-Key ist hinterlegt. Lasse das Feld leer, um ihn unverändert zu lassen.",
|
||||||
|
"aiSystemPrompt": "Systemprompt",
|
||||||
|
"aiTimeout": "Timeout (Sekunden)",
|
||||||
|
"aiMaxTokens": "Max Tokens",
|
||||||
|
"aiTest": "AI testen",
|
||||||
|
"aiTestSuccess": "AI-Test erfolgreich",
|
||||||
|
"aiTestError": "AI-Test fehlgeschlagen",
|
||||||
|
"webhookSecret": "Paperless Webhook-Secret",
|
||||||
|
"webhookSecretNew": "Neues Webhook-Secret hinterlegen",
|
||||||
|
"webhookSecretRemove": "Webhook-Secret löschen",
|
||||||
|
"webhookSecretInfo": "Ein Webhook-Secret ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.",
|
||||||
|
"webhookSecretHelp": "Paperless sendet diesen Wert im Header x-contract-companion-secret.",
|
||||||
"ical": "iCal-Abo",
|
"ical": "iCal-Abo",
|
||||||
"icalFeedUrl": "Feed-URL",
|
"icalFeedUrl": "Feed-URL",
|
||||||
"paperlessApiUrl": "Paperless API URL",
|
"paperlessApiUrl": "Paperless API URL",
|
||||||
@@ -261,6 +285,46 @@
|
|||||||
"correspondent": "Korrespondent",
|
"correspondent": "Korrespondent",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen"
|
||||||
},
|
},
|
||||||
|
"aiReviews": {
|
||||||
|
"title": "AI-Prüfung",
|
||||||
|
"subtitle": "Neue Paperless-Dokumente prüfen, Vertragsentwürfe freigeben und nach Paperless zurückschreiben.",
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
"aiNotConfigured": "AI ist noch nicht vollständig konfiguriert.",
|
||||||
|
"webhookNotConfigured": "Das Paperless Webhook-Secret ist noch nicht gesetzt.",
|
||||||
|
"document": "Dokument",
|
||||||
|
"review": "Prüfen",
|
||||||
|
"loadError": "AI-Prüfungen konnten nicht geladen werden.",
|
||||||
|
"empty": "Noch keine AI-Prüfungen vorhanden.",
|
||||||
|
"detailSubtitle": "Paperless-Dokument #{{id}}",
|
||||||
|
"summary": "AI-Zusammenfassung",
|
||||||
|
"detectedContract": "Die AI stuft das Dokument als Vertrag ein.",
|
||||||
|
"detectedNoContract": "Die AI stuft das Dokument nicht als Vertrag ein.",
|
||||||
|
"noAnalysis": "Noch keine Analyse vorhanden.",
|
||||||
|
"contractDraft": "Vertragsentwurf",
|
||||||
|
"noContractDraft": "Die Analyse enthält keinen Vertragsentwurf. Du kannst manuell Daten ergänzen oder den Fall ablehnen.",
|
||||||
|
"approve": "Freigeben und Vertrag anlegen",
|
||||||
|
"approved": "Vertrag angelegt und Paperless aktualisiert",
|
||||||
|
"reject": "Ablehnen",
|
||||||
|
"rejected": "Prüfung abgelehnt",
|
||||||
|
"retry": "Erneut analysieren",
|
||||||
|
"retryStarted": "Analyse erneut gestartet",
|
||||||
|
"actionFailed": "Aktion fehlgeschlagen",
|
||||||
|
"columns": {
|
||||||
|
"document": "Dokument",
|
||||||
|
"status": "Status",
|
||||||
|
"confidence": "Sicherheit",
|
||||||
|
"updated": "Aktualisiert",
|
||||||
|
"action": "Aktion"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"pending": "Wartet",
|
||||||
|
"analyzing": "Analyse läuft",
|
||||||
|
"needs_review": "Prüfung nötig",
|
||||||
|
"approved": "Freigegeben",
|
||||||
|
"rejected": "Abgelehnt",
|
||||||
|
"failed": "Fehler"
|
||||||
|
}
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Anmeldung",
|
"title": "Anmeldung",
|
||||||
"welcome": "Willkommen zurück! Bitte melde dich an.",
|
"welcome": "Willkommen zurück! Bitte melde dich an.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
"aiReviews": "AI review",
|
||||||
"contracts": "Contracts",
|
"contracts": "Contracts",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -175,6 +176,29 @@
|
|||||||
"scheduler": "Deadlines & notifications",
|
"scheduler": "Deadlines & notifications",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"ntfy": "ntfy push",
|
"ntfy": "ntfy push",
|
||||||
|
"ai": "AI analysis",
|
||||||
|
"aiEnabled": "Enable AI analysis",
|
||||||
|
"aiProvider": "AI provider",
|
||||||
|
"aiProviderNone": "No provider",
|
||||||
|
"aiBaseUrl": "AI base URL",
|
||||||
|
"aiBaseUrlPlaceholder": "Leave empty for provider default",
|
||||||
|
"aiBaseUrlHelp": "For OpenAI-compatible providers or custom gateways.",
|
||||||
|
"aiModel": "Model",
|
||||||
|
"aiApiKey": "API key",
|
||||||
|
"aiApiKeyNew": "Provide new API key",
|
||||||
|
"aiApiKeyRemove": "Remove API key",
|
||||||
|
"aiApiKeyInfo": "An API key is stored. Leave empty to keep it.",
|
||||||
|
"aiSystemPrompt": "System prompt",
|
||||||
|
"aiTimeout": "Timeout (seconds)",
|
||||||
|
"aiMaxTokens": "Max tokens",
|
||||||
|
"aiTest": "Test AI",
|
||||||
|
"aiTestSuccess": "AI test successful",
|
||||||
|
"aiTestError": "AI test failed",
|
||||||
|
"webhookSecret": "Paperless webhook secret",
|
||||||
|
"webhookSecretNew": "Provide new webhook secret",
|
||||||
|
"webhookSecretRemove": "Remove webhook secret",
|
||||||
|
"webhookSecretInfo": "A webhook secret is stored. Leave empty to keep it.",
|
||||||
|
"webhookSecretHelp": "Paperless sends this value in the x-contract-companion-secret header.",
|
||||||
"ical": "iCal subscription",
|
"ical": "iCal subscription",
|
||||||
"icalFeedUrl": "Feed URL",
|
"icalFeedUrl": "Feed URL",
|
||||||
"paperlessApiUrl": "Paperless API URL",
|
"paperlessApiUrl": "Paperless API URL",
|
||||||
@@ -261,6 +285,46 @@
|
|||||||
"correspondent": "Correspondent",
|
"correspondent": "Correspondent",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
|
"aiReviews": {
|
||||||
|
"title": "AI review",
|
||||||
|
"subtitle": "Review new Paperless documents, approve contract drafts, and write metadata back to Paperless.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"aiNotConfigured": "AI is not fully configured yet.",
|
||||||
|
"webhookNotConfigured": "The Paperless webhook secret is not set yet.",
|
||||||
|
"document": "Document",
|
||||||
|
"review": "Review",
|
||||||
|
"loadError": "Unable to load AI reviews.",
|
||||||
|
"empty": "No AI reviews yet.",
|
||||||
|
"detailSubtitle": "Paperless document #{{id}}",
|
||||||
|
"summary": "AI summary",
|
||||||
|
"detectedContract": "AI classified this document as a contract.",
|
||||||
|
"detectedNoContract": "AI did not classify this document as a contract.",
|
||||||
|
"noAnalysis": "No analysis available yet.",
|
||||||
|
"contractDraft": "Contract draft",
|
||||||
|
"noContractDraft": "The analysis contains no contract draft. You can add data manually or reject the review.",
|
||||||
|
"approve": "Approve and create contract",
|
||||||
|
"approved": "Contract created and Paperless updated",
|
||||||
|
"reject": "Reject",
|
||||||
|
"rejected": "Review rejected",
|
||||||
|
"retry": "Analyze again",
|
||||||
|
"retryStarted": "Analysis restarted",
|
||||||
|
"actionFailed": "Action failed",
|
||||||
|
"columns": {
|
||||||
|
"document": "Document",
|
||||||
|
"status": "Status",
|
||||||
|
"confidence": "Confidence",
|
||||||
|
"updated": "Updated",
|
||||||
|
"action": "Action"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"analyzing": "Analyzing",
|
||||||
|
"needs_review": "Needs review",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"failed": "Failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Sign in",
|
"title": "Sign in",
|
||||||
"welcome": "Welcome back! Please sign in.",
|
"welcome": "Welcome back! Please sign in.",
|
||||||
|
|||||||
328
frontend/src/routes/AiReviewDetail.tsx
Normal file
328
frontend/src/routes/AiReviewDetail.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
import ReplayIcon from "@mui/icons-material/Replay";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { approveAiReview, fetchAiReview, rejectAiReview, retryAiReview } from "../api/aiReviews";
|
||||||
|
import { fetchServerConfig } from "../api/config";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import { useSnackbar } from "../hooks/useSnackbar";
|
||||||
|
import { ContractPayload } from "../types";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
title: string;
|
||||||
|
provider: string;
|
||||||
|
category: string;
|
||||||
|
contractStartDate: string;
|
||||||
|
contractEndDate: string;
|
||||||
|
terminationNoticeDays: string;
|
||||||
|
renewalPeriodMonths: string;
|
||||||
|
autoRenew: boolean;
|
||||||
|
price: string;
|
||||||
|
currency: string;
|
||||||
|
notes: string;
|
||||||
|
tags: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: FormState = {
|
||||||
|
title: "",
|
||||||
|
provider: "",
|
||||||
|
category: "",
|
||||||
|
contractStartDate: "",
|
||||||
|
contractEndDate: "",
|
||||||
|
terminationNoticeDays: "",
|
||||||
|
renewalPeriodMonths: "",
|
||||||
|
autoRenew: false,
|
||||||
|
price: "",
|
||||||
|
currency: "EUR",
|
||||||
|
notes: "",
|
||||||
|
tags: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
function toStringValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, integer = false): number | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(trimmed.replace(",", "."));
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error("Invalid number");
|
||||||
|
}
|
||||||
|
return integer ? Math.round(parsed) : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiReviewDetail() {
|
||||||
|
const { reviewId } = useParams<{ reviewId: string }>();
|
||||||
|
const id = reviewId ? Number(reviewId) : null;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: review,
|
||||||
|
isLoading,
|
||||||
|
isError
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["ai-review", id],
|
||||||
|
queryFn: () => fetchAiReview(id ?? 0),
|
||||||
|
enabled: id !== null
|
||||||
|
});
|
||||||
|
const { data: serverConfig } = useQuery({
|
||||||
|
queryKey: ["server-config"],
|
||||||
|
queryFn: fetchServerConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!review?.contractPayload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = review.contractPayload;
|
||||||
|
setForm({
|
||||||
|
title: payload.title ?? "",
|
||||||
|
provider: payload.provider ?? "",
|
||||||
|
category: payload.category ?? "",
|
||||||
|
contractStartDate: payload.contractStartDate ?? "",
|
||||||
|
contractEndDate: payload.contractEndDate ?? "",
|
||||||
|
terminationNoticeDays: toStringValue(payload.terminationNoticeDays),
|
||||||
|
renewalPeriodMonths: toStringValue(payload.renewalPeriodMonths),
|
||||||
|
autoRenew: payload.autoRenew ?? false,
|
||||||
|
price: toStringValue(payload.price),
|
||||||
|
currency: payload.currency ?? "EUR",
|
||||||
|
notes: payload.notes ?? "",
|
||||||
|
tags: payload.tags?.join(", ") ?? ""
|
||||||
|
});
|
||||||
|
}, [review]);
|
||||||
|
|
||||||
|
const paperlessUrl = useMemo(() => {
|
||||||
|
if (!review || !serverConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base = serverConfig.paperlessExternalUrl ?? serverConfig.paperlessBaseUrl;
|
||||||
|
return base ? `${base.replace(/\/$/, "")}/documents/${review.paperlessDocumentId}` : null;
|
||||||
|
}, [review, serverConfig]);
|
||||||
|
|
||||||
|
const refreshQueries = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-reviews"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-review", id] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["contracts"] })
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryMutation = useMutation({
|
||||||
|
mutationFn: () => retryAiReview(id ?? 0),
|
||||||
|
onSuccess: async () => {
|
||||||
|
showMessage(t("aiReviews.retryStarted"), "success");
|
||||||
|
await refreshQueries();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: () => rejectAiReview(id ?? 0),
|
||||||
|
onSuccess: async () => {
|
||||||
|
showMessage(t("aiReviews.rejected"), "success");
|
||||||
|
await refreshQueries();
|
||||||
|
navigate("/ai-reviews");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const contract: ContractPayload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
provider: form.provider.trim() || null,
|
||||||
|
category: form.category.trim() || null,
|
||||||
|
paperlessDocumentId: review?.paperlessDocumentId ?? null,
|
||||||
|
contractStartDate: form.contractStartDate || null,
|
||||||
|
contractEndDate: form.contractEndDate || null,
|
||||||
|
terminationNoticeDays: parseNumber(form.terminationNoticeDays, true),
|
||||||
|
renewalPeriodMonths: parseNumber(form.renewalPeriodMonths, true),
|
||||||
|
autoRenew: form.autoRenew,
|
||||||
|
price: parseNumber(form.price),
|
||||||
|
currency: form.currency.trim() || "EUR",
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
tags: form.tags
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
};
|
||||||
|
return approveAiReview(id ?? 0, contract);
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
showMessage(t("aiReviews.approved"), "success");
|
||||||
|
await refreshQueries();
|
||||||
|
navigate(`/contracts/${result.contract.id}`);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
const message = error.message === "Invalid number" ? t("contractForm.invalidNumber") : error.message;
|
||||||
|
showMessage(message ?? t("aiReviews.actionFailed"), "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Typography>{t("messages.loading")}</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !review) {
|
||||||
|
return <Alert severity="error">{t("aiReviews.loadError")}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canApprove = review.status !== "approved" && review.status !== "rejected" && Boolean(form.title.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
|
||||||
|
subtitle={t("aiReviews.detailSubtitle", { id: review.paperlessDocumentId })}
|
||||||
|
action={
|
||||||
|
paperlessUrl ? (
|
||||||
|
<Button href={paperlessUrl} target="_blank" rel="noreferrer" startIcon={<LaunchIcon />} variant="outlined">
|
||||||
|
{t("contractDetail.openInPaperless")}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={5}>
|
||||||
|
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Chip label={t(`aiReviews.status.${review.status}`)} />
|
||||||
|
{review.confidence !== null && (
|
||||||
|
<Chip label={`${Math.round(review.confidence * 100)}%`} color="primary" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{review.error && <Alert severity="error">{review.error}</Alert>}
|
||||||
|
{review.analysis ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6">{t("aiReviews.summary")}</Typography>
|
||||||
|
<Typography whiteSpace="pre-wrap">{review.analysis.summary}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{review.analysis.isContract ? t("aiReviews.detectedContract") : t("aiReviews.detectedNoContract")}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info">{t("aiReviews.noAnalysis")}</Alert>
|
||||||
|
)}
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ReplayIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => retryMutation.mutate()}
|
||||||
|
disabled={retryMutation.isPending || review.status === "approved"}
|
||||||
|
>
|
||||||
|
{t("aiReviews.retry")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<CloseIcon />}
|
||||||
|
color="warning"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => rejectMutation.mutate()}
|
||||||
|
disabled={rejectMutation.isPending || review.status === "approved" || review.status === "rejected"}
|
||||||
|
>
|
||||||
|
{t("aiReviews.reject")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={7}>
|
||||||
|
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="h6">{t("aiReviews.contractDraft")}</Typography>
|
||||||
|
{!review.contractPayload && (
|
||||||
|
<Alert severity="warning">{t("aiReviews.noContractDraft")}</Alert>
|
||||||
|
)}
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField label={t("contractForm.fields.title")} value={form.title} onChange={(event) => updateField("title", event.target.value)} fullWidth required />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField label={t("contractForm.fields.provider")} value={form.provider} onChange={(event) => updateField("provider", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField label={t("contractForm.fields.category")} value={form.category} onChange={(event) => updateField("category", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField label={t("contractForm.fields.currency")} value={form.currency} onChange={(event) => updateField("currency", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField type="date" label={t("contractForm.fields.contractStart")} value={form.contractStartDate} onChange={(event) => updateField("contractStartDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField type="date" label={t("contractForm.fields.contractEnd")} value={form.contractEndDate} onChange={(event) => updateField("contractEndDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField label={t("contractForm.fields.terminationNotice")} value={form.terminationNoticeDays} onChange={(event) => updateField("terminationNoticeDays", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField label={t("contractForm.fields.renewalPeriod")} value={form.renewalPeriodMonths} onChange={(event) => updateField("renewalPeriodMonths", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField label={t("contractForm.fields.price")} value={form.price} onChange={(event) => updateField("price", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.autoRenew} onChange={(event) => updateField("autoRenew", event.target.checked)} />}
|
||||||
|
label={t("contractForm.fields.autoRenew")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField label={t("contractForm.fields.tags")} value={form.tags} onChange={(event) => updateField("tags", event.target.value)} fullWidth />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField label={t("contractForm.fields.notes")} value={form.notes} onChange={(event) => updateField("notes", event.target.value)} fullWidth multiline minRows={4} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
startIcon={<CheckIcon />}
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => approveMutation.mutate()}
|
||||||
|
disabled={!canApprove || approveMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("aiReviews.approve")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/routes/AiReviewList.tsx
Normal file
154
frontend/src/routes/AiReviewList.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { fetchAiReviews } from "../api/aiReviews";
|
||||||
|
import { fetchServerConfig } from "../api/config";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import { formatDate } from "../utils/date";
|
||||||
|
|
||||||
|
function statusColor(status: string): "default" | "primary" | "success" | "warning" | "error" {
|
||||||
|
if (status === "approved") return "success";
|
||||||
|
if (status === "needs_review") return "primary";
|
||||||
|
if (status === "failed") return "error";
|
||||||
|
if (status === "analyzing" || status === "pending") return "warning";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiReviewList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
data: reviews,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
isFetching
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["ai-reviews"],
|
||||||
|
queryFn: () => fetchAiReviews()
|
||||||
|
});
|
||||||
|
const { data: serverConfig } = useQuery({
|
||||||
|
queryKey: ["server-config"],
|
||||||
|
queryFn: fetchServerConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t("aiReviews.title")}
|
||||||
|
subtitle={t("aiReviews.subtitle")}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{t("aiReviews.refresh")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!serverConfig?.aiConfigured && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
{t("aiReviews.aiNotConfigured")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!serverConfig?.paperlessWebhookConfigured && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
{t("aiReviews.webhookNotConfigured")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ borderRadius: 3, p: 2.5 }}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t("aiReviews.columns.document")}</TableCell>
|
||||||
|
<TableCell>{t("aiReviews.columns.status")}</TableCell>
|
||||||
|
<TableCell>{t("aiReviews.columns.confidence")}</TableCell>
|
||||||
|
<TableCell>{t("aiReviews.columns.updated")}</TableCell>
|
||||||
|
<TableCell align="right">{t("aiReviews.columns.action")}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>{t("messages.loading")}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>
|
||||||
|
<Typography color="error">{t("aiReviews.loadError")}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && !isError && (reviews ?? []).length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<AutoAwesomeIcon color="disabled" />
|
||||||
|
<Typography color="text.secondary">{t("aiReviews.empty")}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{(reviews ?? []).map((review) => (
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
key={review.id}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/ai-reviews/${review.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Typography fontWeight={600}>
|
||||||
|
{review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Paperless #{review.paperlessDocumentId}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
color={statusColor(review.status)}
|
||||||
|
label={t(`aiReviews.status.${review.status}`)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{review.confidence !== null ? `${Math.round(review.confidence * 100)}%` : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(review.updatedAt, "dd.MM.yyyy HH:mm")}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button size="small" onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
navigate(`/ai-reviews/${review.id}`);
|
||||||
|
}}>
|
||||||
|
{t("aiReviews.review")}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
fetchServerConfig,
|
fetchServerConfig,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
resetIcalSecret,
|
resetIcalSecret,
|
||||||
|
triggerAiTest,
|
||||||
triggerMailTest,
|
triggerMailTest,
|
||||||
triggerNtfyTest,
|
triggerNtfyTest,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
@@ -75,6 +76,15 @@ type FormValues = {
|
|||||||
ntfyPriority: string;
|
ntfyPriority: string;
|
||||||
authUsername: string;
|
authUsername: string;
|
||||||
authPassword: string;
|
authPassword: string;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: "openai" | "openai-compatible" | "gemini" | "";
|
||||||
|
aiBaseUrl: string;
|
||||||
|
aiModel: string;
|
||||||
|
aiApiKey: string;
|
||||||
|
aiSystemPrompt: string;
|
||||||
|
aiTimeoutSeconds: number;
|
||||||
|
aiMaxTokens: number;
|
||||||
|
paperlessWebhookSecret: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues: FormValues = {
|
const defaultValues: FormValues = {
|
||||||
@@ -97,7 +107,16 @@ const defaultValues: FormValues = {
|
|||||||
ntfyToken: "",
|
ntfyToken: "",
|
||||||
ntfyPriority: "default",
|
ntfyPriority: "default",
|
||||||
authUsername: "",
|
authUsername: "",
|
||||||
authPassword: ""
|
authPassword: "",
|
||||||
|
aiEnabled: false,
|
||||||
|
aiProvider: "",
|
||||||
|
aiBaseUrl: "",
|
||||||
|
aiModel: "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiSystemPrompt: "",
|
||||||
|
aiTimeoutSeconds: 60,
|
||||||
|
aiMaxTokens: 2000,
|
||||||
|
paperlessWebhookSecret: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -146,6 +165,8 @@ export default function SettingsPage() {
|
|||||||
const [removeMailPassword, setRemoveMailPassword] = useState(false);
|
const [removeMailPassword, setRemoveMailPassword] = useState(false);
|
||||||
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
|
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
|
||||||
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
|
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
|
||||||
|
const [removeAiApiKey, setRemoveAiApiKey] = useState(false);
|
||||||
|
const [removeWebhookSecret, setRemoveWebhookSecret] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@@ -182,7 +203,16 @@ export default function SettingsPage() {
|
|||||||
ntfyToken: "",
|
ntfyToken: "",
|
||||||
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
|
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
|
||||||
authUsername: settingsData.values.authUsername ?? "",
|
authUsername: settingsData.values.authUsername ?? "",
|
||||||
authPassword: ""
|
authPassword: "",
|
||||||
|
aiEnabled: settingsData.values.aiEnabled ?? false,
|
||||||
|
aiProvider: settingsData.values.aiProvider ?? "",
|
||||||
|
aiBaseUrl: settingsData.values.aiBaseUrl ?? "",
|
||||||
|
aiModel: settingsData.values.aiModel ?? "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiSystemPrompt: settingsData.values.aiSystemPrompt ?? "",
|
||||||
|
aiTimeoutSeconds: settingsData.values.aiTimeoutSeconds ?? 60,
|
||||||
|
aiMaxTokens: settingsData.values.aiMaxTokens ?? 2000,
|
||||||
|
paperlessWebhookSecret: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
initialValuesRef.current = values;
|
initialValuesRef.current = values;
|
||||||
@@ -190,6 +220,8 @@ export default function SettingsPage() {
|
|||||||
setRemoveMailPassword(false);
|
setRemoveMailPassword(false);
|
||||||
setRemoveNtfyToken(false);
|
setRemoveNtfyToken(false);
|
||||||
setRemoveAuthPassword(false);
|
setRemoveAuthPassword(false);
|
||||||
|
setRemoveAiApiKey(false);
|
||||||
|
setRemoveWebhookSecret(false);
|
||||||
reset(values);
|
reset(values);
|
||||||
}, [settingsData, reset]);
|
}, [settingsData, reset]);
|
||||||
|
|
||||||
@@ -225,6 +257,12 @@ export default function SettingsPage() {
|
|||||||
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
|
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const aiTestMutation = useMutation({
|
||||||
|
mutationFn: () => triggerAiTest(),
|
||||||
|
onSuccess: () => showMessage(t("settings.aiTestSuccess"), "success"),
|
||||||
|
onError: (error: Error) => showMessage(error.message ?? t("settings.aiTestError"), "error")
|
||||||
|
});
|
||||||
|
|
||||||
const createCategoryMutation = useMutation({
|
const createCategoryMutation = useMutation({
|
||||||
mutationFn: (name: string) => apiCreateCategory(name),
|
mutationFn: (name: string) => apiCreateCategory(name),
|
||||||
onSuccess: async (category) => {
|
onSuccess: async (category) => {
|
||||||
@@ -386,6 +424,38 @@ export default function SettingsPage() {
|
|||||||
payload.authPassword = formValues.authPassword.trim();
|
payload.authPassword = formValues.authPassword.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formValues.aiEnabled !== initial.aiEnabled) {
|
||||||
|
payload.aiEnabled = formValues.aiEnabled;
|
||||||
|
}
|
||||||
|
if (formValues.aiProvider !== initial.aiProvider) {
|
||||||
|
payload.aiProvider = formValues.aiProvider ? formValues.aiProvider : null;
|
||||||
|
}
|
||||||
|
if (formValues.aiBaseUrl !== initial.aiBaseUrl) {
|
||||||
|
payload.aiBaseUrl = trimOrNull(formValues.aiBaseUrl);
|
||||||
|
}
|
||||||
|
if (formValues.aiModel !== initial.aiModel) {
|
||||||
|
payload.aiModel = trimOrNull(formValues.aiModel);
|
||||||
|
}
|
||||||
|
if (removeAiApiKey) {
|
||||||
|
payload.aiApiKey = null;
|
||||||
|
} else if (formValues.aiApiKey.trim().length > 0) {
|
||||||
|
payload.aiApiKey = formValues.aiApiKey.trim();
|
||||||
|
}
|
||||||
|
if (formValues.aiSystemPrompt !== initial.aiSystemPrompt) {
|
||||||
|
payload.aiSystemPrompt = trimOrNull(formValues.aiSystemPrompt);
|
||||||
|
}
|
||||||
|
if (formValues.aiTimeoutSeconds !== initial.aiTimeoutSeconds) {
|
||||||
|
payload.aiTimeoutSeconds = formValues.aiTimeoutSeconds;
|
||||||
|
}
|
||||||
|
if (formValues.aiMaxTokens !== initial.aiMaxTokens) {
|
||||||
|
payload.aiMaxTokens = formValues.aiMaxTokens;
|
||||||
|
}
|
||||||
|
if (removeWebhookSecret) {
|
||||||
|
payload.paperlessWebhookSecret = null;
|
||||||
|
} else if (formValues.paperlessWebhookSecret.trim().length > 0) {
|
||||||
|
payload.paperlessWebhookSecret = formValues.paperlessWebhookSecret.trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
if (Object.keys(payload).length === 0) {
|
||||||
showMessage(t("settings.noChanges"), "info");
|
showMessage(t("settings.noChanges"), "info");
|
||||||
return;
|
return;
|
||||||
@@ -425,6 +495,8 @@ export default function SettingsPage() {
|
|||||||
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
|
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
|
||||||
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
|
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
|
||||||
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
|
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
|
||||||
|
const aiApiKeySet = settings?.secrets.aiApiKeySet ?? false;
|
||||||
|
const paperlessWebhookSecretSet = settings?.secrets.paperlessWebhookSecretSet ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -675,6 +747,135 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{t("settings.ai")}
|
||||||
|
</Typography>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Controller
|
||||||
|
name="aiEnabled"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => <Switch {...field} checked={field.value} />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("settings.aiEnabled")}
|
||||||
|
/>
|
||||||
|
<TextField select label={t("settings.aiProvider")} {...register("aiProvider")} fullWidth>
|
||||||
|
<MenuItem value="">{t("settings.aiProviderNone")}</MenuItem>
|
||||||
|
<MenuItem value="openai">OpenAI</MenuItem>
|
||||||
|
<MenuItem value="openai-compatible">OpenAI compatible</MenuItem>
|
||||||
|
<MenuItem value="gemini">Gemini</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label={t("settings.aiBaseUrl")}
|
||||||
|
{...register("aiBaseUrl")}
|
||||||
|
placeholder={t("settings.aiBaseUrlPlaceholder")}
|
||||||
|
helperText={t("settings.aiBaseUrlHelp")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField label={t("settings.aiModel")} {...register("aiModel")} fullWidth />
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||||
|
<TextField
|
||||||
|
label={aiApiKeySet && !removeAiApiKey ? t("settings.aiApiKeyNew") : t("settings.aiApiKey")}
|
||||||
|
type="password"
|
||||||
|
{...register("aiApiKey")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color={removeAiApiKey ? "inherit" : "warning"}
|
||||||
|
onClick={() => {
|
||||||
|
if (removeAiApiKey) {
|
||||||
|
setRemoveAiApiKey(false);
|
||||||
|
} else {
|
||||||
|
setRemoveAiApiKey(true);
|
||||||
|
setValue("aiApiKey", "", { shouldDirty: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{removeAiApiKey ? t("settings.paperlessTokenKeep") : t("settings.aiApiKeyRemove")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{aiApiKeySet && !removeAiApiKey && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t("settings.aiApiKeyInfo")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
label={t("settings.aiSystemPrompt")}
|
||||||
|
{...register("aiSystemPrompt")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label={t("settings.aiTimeout")}
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 5, max: 300 }}
|
||||||
|
{...register("aiTimeoutSeconds", { valueAsNumber: true })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t("settings.aiMaxTokens")}
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 256, max: 16000 }}
|
||||||
|
{...register("aiMaxTokens", { valueAsNumber: true })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||||
|
<TextField
|
||||||
|
label={paperlessWebhookSecretSet && !removeWebhookSecret ? t("settings.webhookSecretNew") : t("settings.webhookSecret")}
|
||||||
|
type="password"
|
||||||
|
{...register("paperlessWebhookSecret")}
|
||||||
|
fullWidth
|
||||||
|
helperText={t("settings.webhookSecretHelp")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color={removeWebhookSecret ? "inherit" : "warning"}
|
||||||
|
onClick={() => {
|
||||||
|
if (removeWebhookSecret) {
|
||||||
|
setRemoveWebhookSecret(false);
|
||||||
|
} else {
|
||||||
|
setRemoveWebhookSecret(true);
|
||||||
|
setValue("paperlessWebhookSecret", "", { shouldDirty: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{removeWebhookSecret ? t("settings.paperlessTokenKeep") : t("settings.webhookSecretRemove")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{paperlessWebhookSecretSet && !removeWebhookSecret && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t("settings.webhookSecretInfo")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => aiTestMutation.mutate()}
|
||||||
|
disabled={aiTestMutation.isPending || !serverConfig?.aiConfigured}
|
||||||
|
>
|
||||||
|
{t("settings.aiTest")}
|
||||||
|
</Button>
|
||||||
|
{aiTestMutation.isPending && <CircularProgress size={24} />}
|
||||||
|
</Stack>
|
||||||
|
{aiTestMutation.isError && (
|
||||||
|
<Alert severity="error">{(aiTestMutation.error as Error).message ?? t("settings.aiTestError")}</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
|
|||||||
@@ -54,3 +54,47 @@ export interface PaperlessSearchResponse {
|
|||||||
previous?: string | null;
|
previous?: string | null;
|
||||||
results: PaperlessDocument[];
|
results: PaperlessDocument[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiReviewStatus =
|
||||||
|
| "pending"
|
||||||
|
| "analyzing"
|
||||||
|
| "needs_review"
|
||||||
|
| "approved"
|
||||||
|
| "rejected"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface ContractAnalysisResult {
|
||||||
|
isContract: boolean;
|
||||||
|
title: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
category: string | null;
|
||||||
|
contractStartDate: string | null;
|
||||||
|
contractEndDate: string | null;
|
||||||
|
terminationNoticeDays: number | null;
|
||||||
|
renewalPeriodMonths: number | null;
|
||||||
|
autoRenew: boolean;
|
||||||
|
price: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
|
summary: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiReview {
|
||||||
|
id: number;
|
||||||
|
paperlessDocumentId: number;
|
||||||
|
status: AiReviewStatus;
|
||||||
|
documentTitle: string | null;
|
||||||
|
analysis: ContractAnalysisResult | null;
|
||||||
|
contractPayload: ContractPayload | null;
|
||||||
|
confidence: number | null;
|
||||||
|
error: string | null;
|
||||||
|
rawAiResponse: string | null;
|
||||||
|
contractId: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
analyzedAt: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
rejectedAt: string | null;
|
||||||
|
}
|
||||||
|
|||||||
296
src/aiProviders.ts
Normal file
296
src/aiProviders.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { createLogger } from "./logger.js";
|
||||||
|
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||||
|
import { ContractAnalysisResult } from "./types.js";
|
||||||
|
|
||||||
|
const logger = createLogger(config.logLevel);
|
||||||
|
|
||||||
|
type AiProvider = "openai" | "openai-compatible" | "gemini";
|
||||||
|
|
||||||
|
export type AiDocumentInput = {
|
||||||
|
id: number;
|
||||||
|
title?: string | null;
|
||||||
|
correspondent?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
content?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiAnalysisResponse = {
|
||||||
|
analysis: ContractAnalysisResult;
|
||||||
|
rawResponse: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const analysisSchema = z.object({
|
||||||
|
isContract: z.boolean().default(false),
|
||||||
|
title: z.string().trim().min(1).nullable().default(null),
|
||||||
|
provider: z.string().trim().min(1).nullable().default(null),
|
||||||
|
category: z.string().trim().min(1).nullable().default(null),
|
||||||
|
contractStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null),
|
||||||
|
contractEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null),
|
||||||
|
terminationNoticeDays: z.number().int().nonnegative().nullable().default(null),
|
||||||
|
renewalPeriodMonths: z.number().int().nonnegative().nullable().default(null),
|
||||||
|
autoRenew: z.boolean().default(false),
|
||||||
|
price: z.number().nonnegative().nullable().default(null),
|
||||||
|
currency: z.string().trim().min(1).max(8).nullable().default("EUR"),
|
||||||
|
notes: z.string().trim().nullable().default(null),
|
||||||
|
tags: z.array(z.string().trim().min(1).max(100)).default([]),
|
||||||
|
summary: z.string().trim().min(1).default("No summary returned."),
|
||||||
|
confidence: z.number().min(0).max(1).default(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
const analysisJsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [
|
||||||
|
"isContract",
|
||||||
|
"title",
|
||||||
|
"provider",
|
||||||
|
"category",
|
||||||
|
"contractStartDate",
|
||||||
|
"contractEndDate",
|
||||||
|
"terminationNoticeDays",
|
||||||
|
"renewalPeriodMonths",
|
||||||
|
"autoRenew",
|
||||||
|
"price",
|
||||||
|
"currency",
|
||||||
|
"notes",
|
||||||
|
"tags",
|
||||||
|
"summary",
|
||||||
|
"confidence"
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
isContract: { type: "boolean" },
|
||||||
|
title: { type: ["string", "null"] },
|
||||||
|
provider: { type: ["string", "null"] },
|
||||||
|
category: { type: ["string", "null"] },
|
||||||
|
contractStartDate: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
|
||||||
|
contractEndDate: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
|
||||||
|
terminationNoticeDays: { type: ["integer", "null"], minimum: 0 },
|
||||||
|
renewalPeriodMonths: { type: ["integer", "null"], minimum: 0 },
|
||||||
|
autoRenew: { type: "boolean" },
|
||||||
|
price: { type: ["number", "null"], minimum: 0 },
|
||||||
|
currency: { type: ["string", "null"] },
|
||||||
|
notes: { type: ["string", "null"] },
|
||||||
|
tags: { type: "array", items: { type: "string" } },
|
||||||
|
summary: { type: "string" },
|
||||||
|
confidence: { type: "number", minimum: 0, maximum: 1 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPrompt = [
|
||||||
|
"Analyze the OCR text and metadata of a paperless-ngx document.",
|
||||||
|
"Decide whether it is a contract or a contract-related document.",
|
||||||
|
"Extract contract data only when it is explicitly present or strongly implied.",
|
||||||
|
"Use ISO dates in YYYY-MM-DD format. Use null for unknown values.",
|
||||||
|
"The confidence value must be between 0 and 1.",
|
||||||
|
"Return only JSON that matches the requested schema."
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
function requireAiConfig(settings: RuntimeSettings): {
|
||||||
|
provider: AiProvider;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
baseUrl: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
maxTokens: number;
|
||||||
|
} {
|
||||||
|
if (!settings.aiEnabled) {
|
||||||
|
throw new Error("AI analysis is disabled.");
|
||||||
|
}
|
||||||
|
if (!settings.aiProvider || !settings.aiApiKey || !settings.aiModel) {
|
||||||
|
throw new Error("AI provider, model, and API key must be configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = settings.aiProvider;
|
||||||
|
const baseUrl =
|
||||||
|
settings.aiBaseUrl ??
|
||||||
|
(provider === "gemini"
|
||||||
|
? "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
: "https://api.openai.com/v1");
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
apiKey: settings.aiApiKey,
|
||||||
|
model: settings.aiModel,
|
||||||
|
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||||
|
systemPrompt: settings.aiSystemPrompt?.trim() || defaultPrompt,
|
||||||
|
timeoutMs: Math.max(settings.aiTimeoutSeconds, 5) * 1000,
|
||||||
|
maxTokens: settings.aiMaxTokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserPrompt(document: AiDocumentInput): string {
|
||||||
|
const payload = {
|
||||||
|
paperlessDocumentId: document.id,
|
||||||
|
title: document.title ?? null,
|
||||||
|
correspondent: document.correspondent ?? null,
|
||||||
|
tags: document.tags ?? [],
|
||||||
|
metadata: document.metadata ?? {},
|
||||||
|
content: document.content?.slice(0, 60000) ?? ""
|
||||||
|
};
|
||||||
|
return `Extract contract metadata from this paperless-ngx document:\n${JSON.stringify(payload, null, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(timeoutMs: number): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), timeoutMs).unref?.();
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(text: string): unknown {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
}
|
||||||
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fenced) {
|
||||||
|
return JSON.parse(fenced[1].trim());
|
||||||
|
}
|
||||||
|
const first = trimmed.indexOf("{");
|
||||||
|
const last = trimmed.lastIndexOf("}");
|
||||||
|
if (first >= 0 && last > first) {
|
||||||
|
return JSON.parse(trimmed.slice(first, last + 1));
|
||||||
|
}
|
||||||
|
throw new Error("AI response did not contain JSON.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnalysis(text: string): ContractAnalysisResult {
|
||||||
|
const parsed = analysisSchema.parse(extractJson(text));
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
title: parsed.title || null,
|
||||||
|
provider: parsed.provider || null,
|
||||||
|
category: parsed.category || null,
|
||||||
|
currency: parsed.currency || "EUR",
|
||||||
|
notes: parsed.notes || null,
|
||||||
|
tags: Array.from(new Set(parsed.tags.map((tag) => tag.trim()).filter(Boolean)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOpenAiCompatible(
|
||||||
|
settings: ReturnType<typeof requireAiConfig>,
|
||||||
|
document: AiDocumentInput
|
||||||
|
): Promise<AiAnalysisResponse> {
|
||||||
|
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
signal: withTimeout(settings.timeoutMs),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${settings.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.model,
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: settings.maxTokens,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: settings.systemPrompt },
|
||||||
|
{ role: "user", content: buildUserPrompt(document) }
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: "contract_analysis",
|
||||||
|
strict: true,
|
||||||
|
schema: analysisJsonSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`AI provider responded with ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json() as {
|
||||||
|
choices?: Array<{ message?: { content?: string | Array<{ text?: string }> } }>;
|
||||||
|
};
|
||||||
|
const content = payload.choices?.[0]?.message?.content;
|
||||||
|
const raw = Array.isArray(content)
|
||||||
|
? content.map((part) => part.text ?? "").join("")
|
||||||
|
: content;
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("AI provider returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { analysis: parseAnalysis(raw), rawResponse: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestGemini(
|
||||||
|
settings: ReturnType<typeof requireAiConfig>,
|
||||||
|
document: AiDocumentInput
|
||||||
|
): Promise<AiAnalysisResponse> {
|
||||||
|
const url = new URL(`${settings.baseUrl}/models/${encodeURIComponent(settings.model)}:generateContent`);
|
||||||
|
url.searchParams.set("key", settings.apiKey);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
signal: withTimeout(settings.timeoutMs),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: {
|
||||||
|
parts: [{ text: settings.systemPrompt }]
|
||||||
|
},
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [{ text: buildUserPrompt(document) }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0,
|
||||||
|
maxOutputTokens: settings.maxTokens,
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
responseSchema: analysisJsonSchema
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Gemini responded with ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json() as {
|
||||||
|
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
||||||
|
};
|
||||||
|
const raw = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("") ?? "";
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Gemini returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { analysis: parseAnalysis(raw), rawResponse: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeDocumentWithAi(
|
||||||
|
runtimeSettings: RuntimeSettings,
|
||||||
|
document: AiDocumentInput
|
||||||
|
): Promise<AiAnalysisResponse> {
|
||||||
|
const aiSettings = requireAiConfig(runtimeSettings);
|
||||||
|
logger.info("Analyzing paperless document %s with AI provider %s", document.id, aiSettings.provider);
|
||||||
|
|
||||||
|
if (aiSettings.provider === "gemini") {
|
||||||
|
return requestGemini(aiSettings, document);
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestOpenAiCompatible(aiSettings, document);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testAiConfiguration(runtimeSettings: RuntimeSettings): Promise<void> {
|
||||||
|
await analyzeDocumentWithAi(runtimeSettings, {
|
||||||
|
id: 0,
|
||||||
|
title: "AI configuration test",
|
||||||
|
correspondent: "Example Provider",
|
||||||
|
tags: ["test"],
|
||||||
|
content:
|
||||||
|
"Example contract. Provider: Example Provider. Start: 2026-01-01. End: 2027-01-01. Cancellation notice: 30 days. Price: EUR 9.99 per month.",
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
161
src/aiReviewProcessor.ts
Normal file
161
src/aiReviewProcessor.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
AiReview,
|
||||||
|
createAiReview,
|
||||||
|
getAiReview,
|
||||||
|
listAiReviews,
|
||||||
|
markAiReviewAnalyzing,
|
||||||
|
markAiReviewFailed,
|
||||||
|
saveAiReviewAnalysis
|
||||||
|
} from "./aiReviewStore.js";
|
||||||
|
import { analyzeDocumentWithAi } from "./aiProviders.js";
|
||||||
|
import { createLogger } from "./logger.js";
|
||||||
|
import { paperlessClient } from "./paperlessClient.js";
|
||||||
|
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||||
|
import { ContractAnalysisResult, ContractPayload } from "./types.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
const logger = createLogger(config.logLevel);
|
||||||
|
const processing = new Set<number>();
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDocumentTitle(document: Record<string, unknown>, fallbackId: number): string {
|
||||||
|
return readString(document.title) ?? `Paperless document #${fallbackId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCorrespondent(document: Record<string, unknown>): string | null {
|
||||||
|
return (
|
||||||
|
readString(document.correspondent_name) ??
|
||||||
|
readString(document.correspondent__name) ??
|
||||||
|
(document.metadata && typeof document.metadata === "object"
|
||||||
|
? readString((document.metadata as Record<string, unknown>).correspondent_name)
|
||||||
|
: null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTags(document: Record<string, unknown>): string[] {
|
||||||
|
const values: string[] = [];
|
||||||
|
const add = (value: unknown) => {
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
values.push(value.trim());
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
const item = value as Record<string, unknown>;
|
||||||
|
const label = readString(item.name) ?? readString(item.slug) ?? readString(item.label);
|
||||||
|
if (label) {
|
||||||
|
values.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(document.tags__name)) {
|
||||||
|
document.tags__name.forEach(add);
|
||||||
|
}
|
||||||
|
if (Array.isArray(document.tag_names)) {
|
||||||
|
document.tag_names.forEach(add);
|
||||||
|
}
|
||||||
|
if (Array.isArray(document.tags)) {
|
||||||
|
document.tags.forEach(add);
|
||||||
|
}
|
||||||
|
if (document.metadata && typeof document.metadata === "object") {
|
||||||
|
const metadata = document.metadata as Record<string, unknown>;
|
||||||
|
if (Array.isArray(metadata.tag_names)) {
|
||||||
|
metadata.tag_names.forEach(add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContractPayload(
|
||||||
|
documentId: number,
|
||||||
|
documentTitle: string,
|
||||||
|
analysis: ContractAnalysisResult
|
||||||
|
): ContractPayload | null {
|
||||||
|
if (!analysis.isContract) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: analysis.title ?? documentTitle,
|
||||||
|
paperlessDocumentId: documentId,
|
||||||
|
provider: analysis.provider,
|
||||||
|
category: analysis.category,
|
||||||
|
contractStartDate: analysis.contractStartDate,
|
||||||
|
contractEndDate: analysis.contractEndDate,
|
||||||
|
terminationNoticeDays: analysis.terminationNoticeDays,
|
||||||
|
renewalPeriodMonths: analysis.renewalPeriodMonths,
|
||||||
|
autoRenew: analysis.autoRenew,
|
||||||
|
price: analysis.price,
|
||||||
|
currency: analysis.currency ?? "EUR",
|
||||||
|
notes: analysis.notes ?? analysis.summary,
|
||||||
|
tags: analysis.tags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueuePaperlessDocumentAnalysis(documentId: number, documentTitle?: string | null): AiReview {
|
||||||
|
const review = createAiReview(documentId, documentTitle);
|
||||||
|
void processAiReview(review.id);
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processAiReview(reviewId: number): Promise<AiReview | null> {
|
||||||
|
if (processing.has(reviewId)) {
|
||||||
|
return getAiReview(reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
processing.add(reviewId);
|
||||||
|
try {
|
||||||
|
const review = getAiReview(reviewId);
|
||||||
|
if (!review || review.status === "approved" || review.status === "rejected") {
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
markAiReviewAnalyzing(reviewId);
|
||||||
|
const runtime = getRuntimeSettings();
|
||||||
|
|
||||||
|
if (!paperlessClient.isConfigured) {
|
||||||
|
return markAiReviewFailed(reviewId, "Paperless integration is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await paperlessClient.getDocument(review.paperlessDocumentId);
|
||||||
|
if (!document) {
|
||||||
|
return markAiReviewFailed(reviewId, "Paperless document not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentTitle = extractDocumentTitle(document, review.paperlessDocumentId);
|
||||||
|
const result = await analyzeDocumentWithAi(runtime, {
|
||||||
|
id: review.paperlessDocumentId,
|
||||||
|
title: documentTitle,
|
||||||
|
correspondent: extractCorrespondent(document),
|
||||||
|
tags: extractTags(document),
|
||||||
|
content: readString(document.content),
|
||||||
|
metadata: document.metadata && typeof document.metadata === "object"
|
||||||
|
? document.metadata as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return saveAiReviewAnalysis(reviewId, {
|
||||||
|
documentTitle,
|
||||||
|
analysis: result.analysis,
|
||||||
|
contractPayload: normalizeContractPayload(review.paperlessDocumentId, documentTitle, result.analysis),
|
||||||
|
rawAiResponse: result.rawResponse
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("AI review processing failed", error);
|
||||||
|
return markAiReviewFailed(reviewId, error instanceof Error ? error.message : "AI analysis failed.");
|
||||||
|
} finally {
|
||||||
|
processing.delete(reviewId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processPendingAiReviews(): void {
|
||||||
|
const candidates = [
|
||||||
|
...listAiReviews("pending"),
|
||||||
|
...listAiReviews("analyzing")
|
||||||
|
];
|
||||||
|
for (const review of candidates) {
|
||||||
|
void processAiReview(review.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/aiReviewStore.ts
Normal file
226
src/aiReviewStore.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import db, { AiReviewDbRow } from "./db.js";
|
||||||
|
import { ContractAnalysisResult, ContractPayload } from "./types.js";
|
||||||
|
|
||||||
|
export type AiReviewStatus =
|
||||||
|
| "pending"
|
||||||
|
| "analyzing"
|
||||||
|
| "needs_review"
|
||||||
|
| "approved"
|
||||||
|
| "rejected"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface AiReview {
|
||||||
|
id: number;
|
||||||
|
paperlessDocumentId: number;
|
||||||
|
status: AiReviewStatus;
|
||||||
|
documentTitle: string | null;
|
||||||
|
analysis: ContractAnalysisResult | null;
|
||||||
|
contractPayload: ContractPayload | null;
|
||||||
|
confidence: number | null;
|
||||||
|
error: string | null;
|
||||||
|
rawAiResponse: string | null;
|
||||||
|
contractId: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
analyzedAt: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
rejectedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson<T>(value: string | null): T | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyJson(value: unknown): string | null {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: string): AiReviewStatus {
|
||||||
|
if (
|
||||||
|
value === "pending" ||
|
||||||
|
value === "analyzing" ||
|
||||||
|
value === "needs_review" ||
|
||||||
|
value === "approved" ||
|
||||||
|
value === "rejected" ||
|
||||||
|
value === "failed"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: AiReviewDbRow): AiReview {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
paperlessDocumentId: row.paperless_document_id,
|
||||||
|
status: normalizeStatus(row.status),
|
||||||
|
documentTitle: row.document_title,
|
||||||
|
analysis: parseJson<ContractAnalysisResult>(row.analysis_json),
|
||||||
|
contractPayload: parseJson<ContractPayload>(row.contract_payload_json),
|
||||||
|
confidence: row.confidence,
|
||||||
|
error: row.error,
|
||||||
|
rawAiResponse: row.raw_ai_response,
|
||||||
|
contractId: row.contract_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
analyzedAt: row.analyzed_at,
|
||||||
|
approvedAt: row.approved_at,
|
||||||
|
rejectedAt: row.rejected_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAiReviews(status?: AiReviewStatus): AiReview[] {
|
||||||
|
const rows = status
|
||||||
|
? db
|
||||||
|
.prepare<[string], AiReviewDbRow>(
|
||||||
|
`SELECT * FROM ai_review_queue WHERE status = ? ORDER BY updated_at DESC`
|
||||||
|
)
|
||||||
|
.all(status)
|
||||||
|
: db
|
||||||
|
.prepare<[], AiReviewDbRow>(
|
||||||
|
`SELECT * FROM ai_review_queue ORDER BY updated_at DESC LIMIT 200`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return rows.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiReview(id: number): AiReview | null {
|
||||||
|
const row = db
|
||||||
|
.prepare<[number], AiReviewDbRow>(`SELECT * FROM ai_review_queue WHERE id = ?`)
|
||||||
|
.get(id);
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiReviewByDocumentId(documentId: number): AiReview | null {
|
||||||
|
const row = db
|
||||||
|
.prepare<[number], AiReviewDbRow>(`SELECT * FROM ai_review_queue WHERE paperless_document_id = ?`)
|
||||||
|
.get(documentId);
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAiReview(documentId: number, documentTitle?: string | null): AiReview {
|
||||||
|
const existing = getAiReviewByDocumentId(documentId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO ai_review_queue (
|
||||||
|
paperless_document_id,
|
||||||
|
status,
|
||||||
|
document_title,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, 'pending', ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(documentId, documentTitle ?? null, now, now);
|
||||||
|
|
||||||
|
return getAiReview(Number(result.lastInsertRowid))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAiReviewAnalyzing(id: number): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET status = 'analyzing', error = NULL, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(now, id);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiReviewAnalysis(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
documentTitle?: string | null;
|
||||||
|
analysis: ContractAnalysisResult;
|
||||||
|
contractPayload: ContractPayload | null;
|
||||||
|
rawAiResponse?: string | null;
|
||||||
|
}
|
||||||
|
): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET status = 'needs_review',
|
||||||
|
document_title = COALESCE(?, document_title),
|
||||||
|
analysis_json = ?,
|
||||||
|
contract_payload_json = ?,
|
||||||
|
confidence = ?,
|
||||||
|
error = NULL,
|
||||||
|
raw_ai_response = ?,
|
||||||
|
analyzed_at = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(
|
||||||
|
data.documentTitle ?? null,
|
||||||
|
stringifyJson(data.analysis),
|
||||||
|
stringifyJson(data.contractPayload),
|
||||||
|
data.analysis.confidence,
|
||||||
|
data.rawAiResponse ?? null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAiReviewFailed(id: number, error: string): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET status = 'failed', error = ?, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(error, now, id);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAiReviewForRetry(id: number): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET status = 'pending', error = NULL, updated_at = ?
|
||||||
|
WHERE id = ? AND status IN ('failed', 'needs_review', 'pending')`
|
||||||
|
).run(now, id);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectAiReview(id: number): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET status = 'rejected', rejected_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(now, now, id);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiReviewContract(id: number, contractId: number): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET contract_id = ?, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(contractId, now, id);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveAiReview(id: number, contractId: number): AiReview | null {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ai_review_queue
|
||||||
|
SET status = 'approved', contract_id = ?, approved_at = ?, error = NULL, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(contractId, now, now, id);
|
||||||
|
return getAiReview(id);
|
||||||
|
}
|
||||||
@@ -28,7 +28,16 @@ const configSchema = z.object({
|
|||||||
ntfyTopic: z.string().min(1).optional(),
|
ntfyTopic: z.string().min(1).optional(),
|
||||||
ntfyToken: z.string().optional(),
|
ntfyToken: z.string().optional(),
|
||||||
ntfyPriority: z.string().optional(),
|
ntfyPriority: z.string().optional(),
|
||||||
icalSecret: z.string().optional()
|
icalSecret: z.string().optional(),
|
||||||
|
aiEnabled: z.coerce.boolean().default(false),
|
||||||
|
aiProvider: z.enum(["openai", "openai-compatible", "gemini"]).optional(),
|
||||||
|
aiBaseUrl: z.string().url().optional(),
|
||||||
|
aiModel: z.string().min(1).optional(),
|
||||||
|
aiApiKey: z.string().min(1).optional(),
|
||||||
|
aiSystemPrompt: z.string().optional(),
|
||||||
|
aiTimeoutSeconds: z.coerce.number().min(5).max(300).default(60),
|
||||||
|
aiMaxTokens: z.coerce.number().min(256).max(16000).default(2000),
|
||||||
|
paperlessWebhookSecret: z.string().min(10).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
@@ -72,7 +81,16 @@ const rawConfig = {
|
|||||||
ntfyTopic: readEnv("NTFY_TOPIC"),
|
ntfyTopic: readEnv("NTFY_TOPIC"),
|
||||||
ntfyToken: readEnv("NTFY_TOKEN"),
|
ntfyToken: readEnv("NTFY_TOKEN"),
|
||||||
ntfyPriority: readEnv("NTFY_PRIORITY"),
|
ntfyPriority: readEnv("NTFY_PRIORITY"),
|
||||||
icalSecret: readEnv("ICAL_SECRET")
|
icalSecret: readEnv("ICAL_SECRET"),
|
||||||
|
aiEnabled: parseBoolean(readEnv("AI_ENABLED"), false),
|
||||||
|
aiProvider: readEnv("AI_PROVIDER"),
|
||||||
|
aiBaseUrl: readEnv("AI_BASE_URL"),
|
||||||
|
aiModel: readEnv("AI_MODEL"),
|
||||||
|
aiApiKey: readEnv("AI_API_KEY"),
|
||||||
|
aiSystemPrompt: readEnv("AI_SYSTEM_PROMPT"),
|
||||||
|
aiTimeoutSeconds: readEnv("AI_TIMEOUT_SECONDS"),
|
||||||
|
aiMaxTokens: readEnv("AI_MAX_TOKENS"),
|
||||||
|
paperlessWebhookSecret: readEnv("PAPERLESS_WEBHOOK_SECRET")
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|||||||
36
src/db.ts
36
src/db.ts
@@ -50,6 +50,24 @@ CREATE TABLE IF NOT EXISTS categories (
|
|||||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_review_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
paperless_document_id INTEGER NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
document_title TEXT,
|
||||||
|
analysis_json TEXT,
|
||||||
|
contract_payload_json TEXT,
|
||||||
|
confidence REAL,
|
||||||
|
error TEXT,
|
||||||
|
raw_ai_response TEXT,
|
||||||
|
contract_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
analyzed_at TEXT,
|
||||||
|
approved_at TEXT,
|
||||||
|
rejected_at TEXT
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
||||||
@@ -81,4 +99,22 @@ export type ContractDbRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AiReviewDbRow = {
|
||||||
|
id: number;
|
||||||
|
paperless_document_id: number;
|
||||||
|
status: string;
|
||||||
|
document_title: string | null;
|
||||||
|
analysis_json: string | null;
|
||||||
|
contract_payload_json: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
error: string | null;
|
||||||
|
raw_ai_response: string | null;
|
||||||
|
contract_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
analyzed_at: string | null;
|
||||||
|
approved_at: string | null;
|
||||||
|
rejected_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|||||||
245
src/index.ts
245
src/index.ts
@@ -1,6 +1,24 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import express, { NextFunction, Request, Response } from "express";
|
import express, { NextFunction, Request, Response } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
approveAiReview,
|
||||||
|
getAiReview,
|
||||||
|
listAiReviews,
|
||||||
|
markAiReviewFailed,
|
||||||
|
rejectAiReview,
|
||||||
|
resetAiReviewForRetry,
|
||||||
|
setAiReviewContract
|
||||||
|
} from "./aiReviewStore.js";
|
||||||
|
import type { AiReviewStatus } from "./aiReviewStore.js";
|
||||||
|
import {
|
||||||
|
enqueuePaperlessDocumentAnalysis,
|
||||||
|
processAiReview,
|
||||||
|
processPendingAiReviews
|
||||||
|
} from "./aiReviewProcessor.js";
|
||||||
|
import { testAiConfiguration } from "./aiProviders.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import {
|
import {
|
||||||
createContract,
|
createContract,
|
||||||
@@ -89,7 +107,16 @@ const settingsUpdateSchema = z.object({
|
|||||||
ntfyPriority: z.string().nullable().optional(),
|
ntfyPriority: z.string().nullable().optional(),
|
||||||
authUsername: z.string().nullable().optional(),
|
authUsername: z.string().nullable().optional(),
|
||||||
authPassword: z.string().nullable().optional(),
|
authPassword: z.string().nullable().optional(),
|
||||||
icalSecret: z.string().min(10).nullable().optional()
|
icalSecret: z.string().min(10).nullable().optional(),
|
||||||
|
aiEnabled: z.boolean().optional(),
|
||||||
|
aiProvider: z.enum(["openai", "openai-compatible", "gemini"]).nullable().optional(),
|
||||||
|
aiBaseUrl: z.string().url().nullable().optional(),
|
||||||
|
aiModel: z.string().nullable().optional(),
|
||||||
|
aiApiKey: z.string().min(1).nullable().optional(),
|
||||||
|
aiSystemPrompt: z.string().nullable().optional(),
|
||||||
|
aiTimeoutSeconds: z.coerce.number().min(5).max(300).optional(),
|
||||||
|
aiMaxTokens: z.coerce.number().min(256).max(16000).optional(),
|
||||||
|
paperlessWebhookSecret: z.string().min(10).nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const categorySchema = z.object({
|
const categorySchema = z.object({
|
||||||
@@ -114,13 +141,22 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
|
|||||||
ntfyServerUrl: runtime.ntfyServerUrl,
|
ntfyServerUrl: runtime.ntfyServerUrl,
|
||||||
ntfyTopic: runtime.ntfyTopic,
|
ntfyTopic: runtime.ntfyTopic,
|
||||||
ntfyPriority: runtime.ntfyPriority ?? "default",
|
ntfyPriority: runtime.ntfyPriority ?? "default",
|
||||||
authUsername: runtime.authUsername
|
authUsername: runtime.authUsername,
|
||||||
|
aiEnabled: runtime.aiEnabled,
|
||||||
|
aiProvider: runtime.aiProvider,
|
||||||
|
aiBaseUrl: runtime.aiBaseUrl,
|
||||||
|
aiModel: runtime.aiModel,
|
||||||
|
aiSystemPrompt: runtime.aiSystemPrompt,
|
||||||
|
aiTimeoutSeconds: runtime.aiTimeoutSeconds,
|
||||||
|
aiMaxTokens: runtime.aiMaxTokens
|
||||||
},
|
},
|
||||||
secrets: {
|
secrets: {
|
||||||
paperlessTokenSet: Boolean(runtime.paperlessToken),
|
paperlessTokenSet: Boolean(runtime.paperlessToken),
|
||||||
mailPasswordSet: Boolean(runtime.mailPassword),
|
mailPasswordSet: Boolean(runtime.mailPassword),
|
||||||
ntfyTokenSet: Boolean(runtime.ntfyToken),
|
ntfyTokenSet: Boolean(runtime.ntfyToken),
|
||||||
authPasswordSet: Boolean(runtime.authPassword)
|
authPasswordSet: Boolean(runtime.authPassword),
|
||||||
|
aiApiKeySet: Boolean(runtime.aiApiKey),
|
||||||
|
paperlessWebhookSecretSet: Boolean(runtime.paperlessWebhookSecret)
|
||||||
},
|
},
|
||||||
icalSecret: runtime.icalSecret
|
icalSecret: runtime.icalSecret
|
||||||
};
|
};
|
||||||
@@ -179,6 +215,50 @@ function buildIcsFeed(
|
|||||||
return lines.join("\r\n") + "\r\n";
|
return lines.join("\r\n") + "\r\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeCompare(expected: string | null | undefined, provided: string | null | undefined): boolean {
|
||||||
|
if (!expected || !provided) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
const providedBuffer = Buffer.from(provided);
|
||||||
|
if (expectedBuffer.length !== providedBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWebhookDocumentId(body: unknown): number | null {
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = body as Record<string, unknown>;
|
||||||
|
const candidates = [
|
||||||
|
payload.document_id,
|
||||||
|
payload.documentId,
|
||||||
|
payload.id,
|
||||||
|
payload.document,
|
||||||
|
payload.document_pk
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const value = typeof candidate === "string" ? Number(candidate) : candidate;
|
||||||
|
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWebhookSecret(req: Request): string | null {
|
||||||
|
const header =
|
||||||
|
req.get("x-contract-companion-secret") ??
|
||||||
|
req.get("x-paperless-webhook-secret") ??
|
||||||
|
req.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||||
|
if (header) {
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
return typeof req.query.secret === "string" ? req.query.secret : null;
|
||||||
|
}
|
||||||
|
|
||||||
app.get("/healthz", (_req, res) => {
|
app.get("/healthz", (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
@@ -256,6 +336,30 @@ app.get("/calendar/feed.ics", (req, res) => {
|
|||||||
res.send(ics);
|
res.send(ics);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/integrations/paperless/webhook", (req, res) => {
|
||||||
|
const runtime = getRuntimeSettings();
|
||||||
|
if (!runtime.paperlessWebhookSecret) {
|
||||||
|
return res.status(503).json({ error: "Paperless webhook secret is not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedSecret = readWebhookSecret(req);
|
||||||
|
if (!safeCompare(runtime.paperlessWebhookSecret, providedSecret)) {
|
||||||
|
return res.status(401).json({ error: "Invalid webhook secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = extractWebhookDocumentId(req.body);
|
||||||
|
if (!documentId) {
|
||||||
|
return res.status(400).json({ error: "Webhook payload does not contain a document id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentTitle =
|
||||||
|
req.body && typeof req.body === "object" && typeof (req.body as Record<string, unknown>).title === "string"
|
||||||
|
? (req.body as Record<string, string>).title
|
||||||
|
: null;
|
||||||
|
const review = enqueuePaperlessDocumentAnalysis(documentId, documentTitle);
|
||||||
|
res.status(202).json({ status: review.status, reviewId: review.id, paperlessDocumentId: documentId });
|
||||||
|
});
|
||||||
|
|
||||||
app.use(authenticateRequest);
|
app.use(authenticateRequest);
|
||||||
|
|
||||||
app.get("/config", (_req, res) => {
|
app.get("/config", (_req, res) => {
|
||||||
@@ -263,6 +367,7 @@ app.get("/config", (_req, res) => {
|
|||||||
const paperlessConfigured = Boolean(runtime.paperlessBaseUrl && runtime.paperlessToken);
|
const paperlessConfigured = Boolean(runtime.paperlessBaseUrl && runtime.paperlessToken);
|
||||||
const mailConfigured = Boolean(runtime.mailServer && runtime.mailFrom && runtime.mailTo);
|
const mailConfigured = Boolean(runtime.mailServer && runtime.mailFrom && runtime.mailTo);
|
||||||
const ntfyConfigured = Boolean(runtime.ntfyServerUrl && runtime.ntfyTopic);
|
const ntfyConfigured = Boolean(runtime.ntfyServerUrl && runtime.ntfyTopic);
|
||||||
|
const aiConfigured = Boolean(runtime.aiEnabled && runtime.aiProvider && runtime.aiApiKey && runtime.aiModel);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
@@ -281,7 +386,11 @@ app.get("/config", (_req, res) => {
|
|||||||
mailUseTls: runtime.mailUseTls,
|
mailUseTls: runtime.mailUseTls,
|
||||||
ntfyConfigured,
|
ntfyConfigured,
|
||||||
authEnabled: isAuthEnabled(),
|
authEnabled: isAuthEnabled(),
|
||||||
authTokenExpiresInHours: config.authTokenExpiresInHours
|
authTokenExpiresInHours: config.authTokenExpiresInHours,
|
||||||
|
aiEnabled: runtime.aiEnabled,
|
||||||
|
aiConfigured,
|
||||||
|
aiProvider: runtime.aiProvider,
|
||||||
|
paperlessWebhookConfigured: Boolean(runtime.paperlessWebhookSecret)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,6 +472,33 @@ app.put("/settings", (req, res) => {
|
|||||||
if (Object.prototype.hasOwnProperty.call(payload, "icalSecret")) {
|
if (Object.prototype.hasOwnProperty.call(payload, "icalSecret")) {
|
||||||
update.icalSecret = payload.icalSecret ?? null;
|
update.icalSecret = payload.icalSecret ?? null;
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiEnabled")) {
|
||||||
|
update.aiEnabled = payload.aiEnabled ?? false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiProvider")) {
|
||||||
|
update.aiProvider = payload.aiProvider ?? null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiBaseUrl")) {
|
||||||
|
update.aiBaseUrl = payload.aiBaseUrl ?? null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiModel")) {
|
||||||
|
update.aiModel = payload.aiModel ?? null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiApiKey")) {
|
||||||
|
update.aiApiKey = payload.aiApiKey ?? null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiSystemPrompt")) {
|
||||||
|
update.aiSystemPrompt = payload.aiSystemPrompt ?? null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiTimeoutSeconds")) {
|
||||||
|
update.aiTimeoutSeconds = payload.aiTimeoutSeconds;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "aiMaxTokens")) {
|
||||||
|
update.aiMaxTokens = payload.aiMaxTokens;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "paperlessWebhookSecret")) {
|
||||||
|
update.paperlessWebhookSecret = payload.paperlessWebhookSecret ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
let runtime = updateRuntimeSettings(update);
|
let runtime = updateRuntimeSettings(update);
|
||||||
if (!runtime.icalSecret) {
|
if (!runtime.icalSecret) {
|
||||||
@@ -406,6 +542,16 @@ app.post("/settings/test/ntfy", async (_req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/settings/test/ai", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const runtime = getRuntimeSettings();
|
||||||
|
await testAiConfiguration(runtime);
|
||||||
|
res.json({ status: "ok" });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/integrations/paperless/search", async (req, res, next) => {
|
app.get("/integrations/paperless/search", async (req, res, next) => {
|
||||||
const query = (typeof req.query.q === "string" ? req.query.q : typeof req.query.query === "string" ? req.query.query : "").trim();
|
const query = (typeof req.query.q === "string" ? req.query.q : typeof req.query.query === "string" ? req.query.query : "").trim();
|
||||||
const page = Number(req.query.page ?? 1);
|
const page = Number(req.query.page ?? 1);
|
||||||
@@ -461,6 +607,96 @@ app.get("/integrations/paperless/documents/:documentId", async (req, res, next)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/ai/reviews", (req, res) => {
|
||||||
|
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||||
|
const allowedStatuses: AiReviewStatus[] = ["pending", "analyzing", "needs_review", "approved", "rejected", "failed"];
|
||||||
|
if (status && !allowedStatuses.includes(status as AiReviewStatus)) {
|
||||||
|
return res.status(400).json({ error: "Invalid review status" });
|
||||||
|
}
|
||||||
|
res.json(listAiReviews(status as AiReviewStatus | undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/ai/reviews/:id", (req, res) => {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: "Invalid review id" });
|
||||||
|
}
|
||||||
|
const review = getAiReview(id);
|
||||||
|
if (!review) {
|
||||||
|
return res.status(404).json({ error: "Review not found" });
|
||||||
|
}
|
||||||
|
res.json(review);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/ai/reviews/:id/retry", async (req, res, next) => {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: "Invalid review id" });
|
||||||
|
}
|
||||||
|
const review = resetAiReviewForRetry(id);
|
||||||
|
if (!review) {
|
||||||
|
return res.status(404).json({ error: "Review not found" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await processAiReview(id);
|
||||||
|
res.json(updated ?? review);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/ai/reviews/:id/reject", (req, res) => {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: "Invalid review id" });
|
||||||
|
}
|
||||||
|
const review = rejectAiReview(id);
|
||||||
|
if (!review) {
|
||||||
|
return res.status(404).json({ error: "Review not found" });
|
||||||
|
}
|
||||||
|
res.json(review);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/ai/reviews/:id/approve", async (req, res, next) => {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: "Invalid review id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const review = getAiReview(id);
|
||||||
|
if (!review) {
|
||||||
|
return res.status(404).json({ error: "Review not found" });
|
||||||
|
}
|
||||||
|
if (review.status === "rejected") {
|
||||||
|
return res.status(400).json({ error: "Rejected reviews cannot be approved" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContract = req.body && typeof req.body === "object" && "contract" in req.body
|
||||||
|
? (req.body as { contract?: unknown }).contract
|
||||||
|
: review.contractPayload;
|
||||||
|
if (!rawContract) {
|
||||||
|
return res.status(400).json({ error: "Review does not contain a contract draft" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = validatePayload(contractCreateSchema, rawContract) as ContractPayload;
|
||||||
|
try {
|
||||||
|
let contractId = review.contractId;
|
||||||
|
let contract = contractId ? getContract(contractId) : null;
|
||||||
|
if (!contract) {
|
||||||
|
contract = createContract({ ...payload, paperlessDocumentId: review.paperlessDocumentId });
|
||||||
|
contractId = contract.id;
|
||||||
|
setAiReviewContract(id, contract.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await paperlessClient.writeContractMetadata(review.paperlessDocumentId, contract.id, contract, review.analysis);
|
||||||
|
const approved = approveAiReview(id, contract.id);
|
||||||
|
res.json({ review: approved, contract });
|
||||||
|
} catch (error) {
|
||||||
|
markAiReviewFailed(id, error instanceof Error ? error.message : "Approval failed");
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/contracts", (req, res) => {
|
app.get("/contracts", (req, res) => {
|
||||||
const skip = Number(req.query.skip ?? 0);
|
const skip = Number(req.query.skip ?? 0);
|
||||||
const limit = Math.min(Number(req.query.limit ?? 100), 500);
|
const limit = Math.min(Number(req.query.limit ?? 100), 500);
|
||||||
@@ -585,6 +821,7 @@ const server = app.listen(config.port, () => {
|
|||||||
logger.warn("Authentication is disabled; consider setting AUTH_USERNAME and AUTH_PASSWORD.");
|
logger.warn("Authentication is disabled; consider setting AUTH_USERNAME and AUTH_PASSWORD.");
|
||||||
}
|
}
|
||||||
deadlineMonitor.start();
|
deadlineMonitor.start();
|
||||||
|
processPendingAiReviews();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { getRuntimeSettings } from "./runtimeSettings.js";
|
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||||
|
import { ContractAnalysisResult, ContractPayload } from "./types.js";
|
||||||
|
|
||||||
const logger = createLogger(config.logLevel);
|
const logger = createLogger(config.logLevel);
|
||||||
|
|
||||||
@@ -26,10 +27,13 @@ export class PaperlessClient {
|
|||||||
return `${trimmedBase}/${trimmedPath}`;
|
return `${trimmedBase}/${trimmedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders(): HeadersInit {
|
private getHeaders(json = false): HeadersInit {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: "application/json"
|
Accept: "application/json"
|
||||||
};
|
};
|
||||||
|
if (json) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
const { paperlessToken } = getRuntimeSettings();
|
const { paperlessToken } = getRuntimeSettings();
|
||||||
if (paperlessToken) {
|
if (paperlessToken) {
|
||||||
headers.Authorization = `Token ${paperlessToken}`;
|
headers.Authorization = `Token ${paperlessToken}`;
|
||||||
@@ -37,8 +41,8 @@ export class PaperlessClient {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchJson<T>(url: URL): Promise<T> {
|
private async fetchJson<T>(url: URL, init: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(url, { headers: this.getHeaders() });
|
const response = await fetch(url, { ...init, headers: init.headers ?? this.getHeaders() });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
logger.error(`Paperless API error ${response.status}: ${text}`);
|
logger.error(`Paperless API error ${response.status}: ${text}`);
|
||||||
@@ -89,6 +93,138 @@ export class PaperlessClient {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureTag(name: string): Promise<number> {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error("Paperless integration is not configured");
|
||||||
|
}
|
||||||
|
const normalized = name.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Tag name must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUrl = new URL(this.buildUrl("/api/tags/"));
|
||||||
|
listUrl.searchParams.set("page_size", "100");
|
||||||
|
const payload = await this.fetchJson<PaperlessCollectionResponse<Record<string, unknown>>>(listUrl);
|
||||||
|
const existing = (payload.results ?? []).find(
|
||||||
|
(tag) => typeof tag.name === "string" && tag.name.toLowerCase() === normalized.toLowerCase()
|
||||||
|
);
|
||||||
|
if (typeof existing?.id === "number") {
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUrl = new URL(this.buildUrl("/api/tags/"));
|
||||||
|
const created = await this.fetchJson<Record<string, unknown>>(createUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.getHeaders(true),
|
||||||
|
body: JSON.stringify({ name: normalized })
|
||||||
|
});
|
||||||
|
if (typeof created.id !== "number") {
|
||||||
|
throw new Error(`Paperless did not return an id for tag ${normalized}`);
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureCustomField(name: string, dataType: string): Promise<number> {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error("Paperless integration is not configured");
|
||||||
|
}
|
||||||
|
const normalized = name.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Custom field name must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUrl = new URL(this.buildUrl("/api/custom_fields/"));
|
||||||
|
listUrl.searchParams.set("page_size", "100");
|
||||||
|
const payload = await this.fetchJson<PaperlessCollectionResponse<Record<string, unknown>>>(listUrl);
|
||||||
|
const existing = (payload.results ?? []).find(
|
||||||
|
(field) => typeof field.name === "string" && field.name.toLowerCase() === normalized.toLowerCase()
|
||||||
|
);
|
||||||
|
if (typeof existing?.id === "number") {
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUrl = new URL(this.buildUrl("/api/custom_fields/"));
|
||||||
|
const created = await this.fetchJson<Record<string, unknown>>(createUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.getHeaders(true),
|
||||||
|
body: JSON.stringify({ name: normalized, data_type: dataType })
|
||||||
|
});
|
||||||
|
if (typeof created.id !== "number") {
|
||||||
|
throw new Error(`Paperless did not return an id for custom field ${normalized}`);
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeContractMetadata(
|
||||||
|
documentId: number,
|
||||||
|
contractId: number,
|
||||||
|
contract: ContractPayload,
|
||||||
|
analysis: ContractAnalysisResult | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error("Paperless integration is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await this.getDocument(documentId);
|
||||||
|
if (!document) {
|
||||||
|
throw new Error("Document not found in paperless");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagIds = new Set<number>();
|
||||||
|
const existingTags = Array.isArray(document.tags) ? document.tags : [];
|
||||||
|
for (const tag of existingTags) {
|
||||||
|
if (typeof tag === "number") {
|
||||||
|
tagIds.add(tag);
|
||||||
|
} else if (tag && typeof tag === "object" && typeof (tag as Record<string, unknown>).id === "number") {
|
||||||
|
tagIds.add((tag as Record<string, number>).id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagIds.add(await this.ensureTag("contract"));
|
||||||
|
tagIds.add(await this.ensureTag("contract-ai-reviewed"));
|
||||||
|
|
||||||
|
const fieldValues: Record<string, string | number | boolean | null> = {};
|
||||||
|
const addField = async (name: string, dataType: string, value: string | number | boolean | null | undefined) => {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = await this.ensureCustomField(name, dataType);
|
||||||
|
fieldValues[String(id)] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
await addField("Contract Companion ID", "integer", contractId);
|
||||||
|
await addField("Contract Title", "string", contract.title);
|
||||||
|
await addField("Contract Provider", "string", contract.provider ?? null);
|
||||||
|
await addField("Contract Category", "string", contract.category ?? null);
|
||||||
|
await addField("Contract Start Date", "date", contract.contractStartDate ?? null);
|
||||||
|
await addField("Contract End Date", "date", contract.contractEndDate ?? null);
|
||||||
|
await addField("Termination Notice Days", "integer", contract.terminationNoticeDays ?? null);
|
||||||
|
await addField("Renewal Period Months", "integer", contract.renewalPeriodMonths ?? null);
|
||||||
|
await addField("Auto Renewal", "boolean", contract.autoRenew ?? false);
|
||||||
|
await addField("Contract Price", "float", contract.price ?? null);
|
||||||
|
await addField("Contract Currency", "string", contract.currency ?? "EUR");
|
||||||
|
await addField("Contract Summary", "string", analysis?.summary ?? contract.notes ?? null);
|
||||||
|
|
||||||
|
const existingCustomFields = Array.isArray(document.custom_fields)
|
||||||
|
? document.custom_fields as Array<Record<string, unknown>>
|
||||||
|
: [];
|
||||||
|
for (const item of existingCustomFields) {
|
||||||
|
const fieldId = typeof item.field === "number" ? item.field : typeof item.id === "number" ? item.id : null;
|
||||||
|
if (fieldId !== null && !Object.prototype.hasOwnProperty.call(fieldValues, String(fieldId))) {
|
||||||
|
fieldValues[String(fieldId)] = (item.value as string | number | boolean | null | undefined) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUrl = new URL(this.buildUrl(`/api/documents/${documentId}/`));
|
||||||
|
await this.fetchJson<Record<string, unknown>>(updateUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: this.getHeaders(true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
tags: Array.from(tagIds),
|
||||||
|
custom_fields: fieldValues
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async enrichDocuments(documents: PaperlessDocument[]): Promise<void> {
|
async enrichDocuments(documents: PaperlessDocument[]): Promise<void> {
|
||||||
if (!documents.length) return;
|
if (!documents.length) return;
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,25 @@ export interface RuntimeSettings {
|
|||||||
authUsername: string | null;
|
authUsername: string | null;
|
||||||
authPassword: string | null;
|
authPassword: string | null;
|
||||||
icalSecret: string | null;
|
icalSecret: string | null;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||||
|
aiBaseUrl: string | null;
|
||||||
|
aiModel: string | null;
|
||||||
|
aiApiKey: string | null;
|
||||||
|
aiSystemPrompt: string | null;
|
||||||
|
aiTimeoutSeconds: number;
|
||||||
|
aiMaxTokens: number;
|
||||||
|
paperlessWebhookSecret: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numericKeys = new Set<SettingKey>(["schedulerIntervalMinutes", "alertDaysBefore", "mailPort"]);
|
const numericKeys = new Set<SettingKey>([
|
||||||
const booleanKeys = new Set<SettingKey>(["mailUseTls"]);
|
"schedulerIntervalMinutes",
|
||||||
|
"alertDaysBefore",
|
||||||
|
"mailPort",
|
||||||
|
"aiTimeoutSeconds",
|
||||||
|
"aiMaxTokens"
|
||||||
|
]);
|
||||||
|
const booleanKeys = new Set<SettingKey>(["mailUseTls", "aiEnabled"]);
|
||||||
|
|
||||||
function coerceNumber(value: unknown, fallback: number): number {
|
function coerceNumber(value: unknown, fallback: number): number {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
@@ -80,6 +95,13 @@ function normalizeLocale(value: unknown, fallback: string): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAiProvider(value: unknown, fallback: RuntimeSettings["aiProvider"]): RuntimeSettings["aiProvider"] {
|
||||||
|
if (value === "openai" || value === "openai-compatible" || value === "gemini") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRuntimeSettings(): RuntimeSettings {
|
export function getRuntimeSettings(): RuntimeSettings {
|
||||||
const stored = listSettings();
|
const stored = listSettings();
|
||||||
|
|
||||||
@@ -89,6 +111,12 @@ export function getRuntimeSettings(): RuntimeSettings {
|
|||||||
);
|
);
|
||||||
const alertDaysBefore = coerceNumber(stored.alertDaysBefore, config.alertDaysBefore);
|
const alertDaysBefore = coerceNumber(stored.alertDaysBefore, config.alertDaysBefore);
|
||||||
const mailPort = stored.mailPort !== undefined ? coerceNumber(stored.mailPort, config.mailPort) : config.mailPort;
|
const mailPort = stored.mailPort !== undefined ? coerceNumber(stored.mailPort, config.mailPort) : config.mailPort;
|
||||||
|
const aiTimeoutSeconds = stored.aiTimeoutSeconds !== undefined
|
||||||
|
? coerceNumber(stored.aiTimeoutSeconds, config.aiTimeoutSeconds)
|
||||||
|
: config.aiTimeoutSeconds;
|
||||||
|
const aiMaxTokens = stored.aiMaxTokens !== undefined
|
||||||
|
? coerceNumber(stored.aiMaxTokens, config.aiMaxTokens)
|
||||||
|
: config.aiMaxTokens;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
|
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
|
||||||
@@ -114,7 +142,19 @@ export function getRuntimeSettings(): RuntimeSettings {
|
|||||||
ntfyPriority: coerceString(stored.ntfyPriority, config.ntfyPriority ?? null),
|
ntfyPriority: coerceString(stored.ntfyPriority, config.ntfyPriority ?? null),
|
||||||
authUsername: coerceString(stored.authUsername, config.authUsername ?? null),
|
authUsername: coerceString(stored.authUsername, config.authUsername ?? null),
|
||||||
authPassword: coerceString(stored.authPassword, config.authPassword ?? null),
|
authPassword: coerceString(stored.authPassword, config.authPassword ?? null),
|
||||||
icalSecret: coerceString(stored.icalSecret, config.icalSecret ?? null)
|
icalSecret: coerceString(stored.icalSecret, config.icalSecret ?? null),
|
||||||
|
aiEnabled:
|
||||||
|
stored.aiEnabled !== undefined
|
||||||
|
? coerceBoolean(stored.aiEnabled, config.aiEnabled)
|
||||||
|
: config.aiEnabled,
|
||||||
|
aiProvider: normalizeAiProvider(stored.aiProvider, config.aiProvider ?? null),
|
||||||
|
aiBaseUrl: coerceString(stored.aiBaseUrl, config.aiBaseUrl ?? null),
|
||||||
|
aiModel: coerceString(stored.aiModel, config.aiModel ?? null),
|
||||||
|
aiApiKey: coerceString(stored.aiApiKey, config.aiApiKey ?? null),
|
||||||
|
aiSystemPrompt: coerceString(stored.aiSystemPrompt, config.aiSystemPrompt ?? null),
|
||||||
|
aiTimeoutSeconds,
|
||||||
|
aiMaxTokens,
|
||||||
|
paperlessWebhookSecret: coerceString(stored.paperlessWebhookSecret, config.paperlessWebhookSecret ?? null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +171,14 @@ export function updateRuntimeSettings(update: Partial<RuntimeSettings>): Runtime
|
|||||||
value = normalizeLocale(value, config.appLocale);
|
value = normalizeLocale(value, config.appLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "aiProvider") {
|
||||||
|
value = normalizeAiProvider(value, null);
|
||||||
|
if (!value) {
|
||||||
|
removeSetting(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (numericKeys.has(key)) {
|
if (numericKeys.has(key)) {
|
||||||
const numericValue = coerceNumber(value, 0);
|
const numericValue = coerceNumber(value, 0);
|
||||||
setSetting(key, numericValue);
|
setSetting(key, numericValue);
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ export type SettingKey =
|
|||||||
| "ntfyPriority"
|
| "ntfyPriority"
|
||||||
| "authUsername"
|
| "authUsername"
|
||||||
| "authPassword"
|
| "authPassword"
|
||||||
| "icalSecret";
|
| "icalSecret"
|
||||||
|
| "aiEnabled"
|
||||||
|
| "aiProvider"
|
||||||
|
| "aiBaseUrl"
|
||||||
|
| "aiModel"
|
||||||
|
| "aiApiKey"
|
||||||
|
| "aiSystemPrompt"
|
||||||
|
| "aiTimeoutSeconds"
|
||||||
|
| "aiMaxTokens"
|
||||||
|
| "paperlessWebhookSecret";
|
||||||
|
|
||||||
export type StoredSettings = Partial<Record<SettingKey, unknown>>;
|
export type StoredSettings = Partial<Record<SettingKey, unknown>>;
|
||||||
|
|
||||||
|
|||||||
18
src/types.ts
18
src/types.ts
@@ -14,6 +14,24 @@ export interface ContractPayload {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContractAnalysisResult {
|
||||||
|
isContract: boolean;
|
||||||
|
title: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
category: string | null;
|
||||||
|
contractStartDate: string | null;
|
||||||
|
contractEndDate: string | null;
|
||||||
|
terminationNoticeDays: number | null;
|
||||||
|
renewalPeriodMonths: number | null;
|
||||||
|
autoRenew: boolean;
|
||||||
|
price: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
|
summary: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Contract extends ContractPayload {
|
export interface Contract extends ContractPayload {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user