149 lines
12 KiB
TypeScript
149 lines
12 KiB
TypeScript
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"
|
|
import { files, filetags, folders, scannerInboxDocuments } from "../../../db/schema"
|
|
import { assignmentKinds, assignmentOptions, assignmentTargets, FileAssignments } from "../../modules/scanner-inbox-assignments"
|
|
import { createScannerInboxService, ScannerInboxService } from "../../modules/scanner-inbox.service"
|
|
import { loadFileBuffer } from "../../utils/fileBuffer"
|
|
import { assertInternalDocumentAccess, decodeDocumentUpload, documentDownloadUrl, fileMetadataColumns, loadDocumentFile, MCP_FILE_LIMIT } from "../documentFiles"
|
|
import { InputField, idField, nullable, numericField, objectField, textField, uuidField } from "../masterdataFields"
|
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
|
import { McpContext, McpTool } from "../types"
|
|
|
|
const searchField: InputField = {
|
|
schema: { type: "string", maxLength: 200, description: "Suchtext im Dateinamen bzw. Namen." },
|
|
parse(value, path) {
|
|
const text = textField().parse(value, path)
|
|
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
|
|
return text
|
|
},
|
|
}
|
|
const filenameField: InputField = {
|
|
schema: { type: "string", minLength: 1, maxLength: 200 },
|
|
parse: (value, path) => textField(true).parse(value, path),
|
|
}
|
|
const paginationFields = {
|
|
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
|
|
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
|
|
}
|
|
const assignmentFields = Object.fromEntries(Object.entries(assignmentTargets).map(([key, target]) =>
|
|
[key, nullable(target.uuid ? uuidField : idField)]))
|
|
const assignmentsField = objectField(assignmentFields)
|
|
const kindField: InputField = {
|
|
schema: { type: "string", enum: Object.keys(assignmentTargets) },
|
|
parse(value) {
|
|
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(assignmentTargets, value)) throw new Error("Unbekannte Zuordnungsart.")
|
|
return value
|
|
},
|
|
}
|
|
const formatField: InputField = {
|
|
schema: { type: "string", enum: ["url", "base64"], default: "url" },
|
|
parse(value) {
|
|
if (value !== "url" && value !== "base64") throw new Error("format muss url oder base64 sein.")
|
|
return value
|
|
},
|
|
}
|
|
const like = (value: string) => `%${value.replace(/[\\%_]/g, "\\$&")}%`
|
|
|
|
type Dependencies = {
|
|
inbox?: (context: McpContext) => ScannerInboxService
|
|
readFile?: typeof loadFileBuffer
|
|
}
|
|
|
|
export function createDocumentTools(dependencies: Dependencies = {}): McpTool[] {
|
|
const inbox = dependencies.inbox || ((context: McpContext) => createScannerInboxService(context.server.db))
|
|
const readFile = dependencies.readFile || loadFileBuffer
|
|
const tool = (name: string, title: string, description: string, write: boolean, fields: Record<string, InputField>,
|
|
required: string[], handler: McpTool["handler"]): McpTool => ({
|
|
name: `documents.${name}`, title, description,
|
|
requiredPermissions: [write ? "documents.write" : "documents.read"],
|
|
inputSchema: { ...objectField(fields).schema, required },
|
|
async handler(context, args) {
|
|
const input = objectField(fields).parse(args, "arguments")
|
|
for (const key of required) if (input[key] === undefined) throw new Error(`${key} ist erforderlich.`)
|
|
await assertInternalDocumentAccess(context)
|
|
return handler(context, input)
|
|
},
|
|
})
|
|
return [
|
|
tool("inbox.list", "Dokumentenablage auflisten", "Listet offene Dokumente der Dokumentenablage mit Status der KI-Ablagevorschläge. id ist die Datei-UUID.", false,
|
|
{ search: searchField, ...paginationFields }, [], async (context, args) => {
|
|
const rows = await context.server.db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
|
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt,
|
|
suggestionStatus: scannerInboxDocuments.suggestionStatus })
|
|
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
|
.where(and(eq(scannerInboxDocuments.tenantId, context.tenantId), eq(files.tenant, context.tenantId),
|
|
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
|
|
args.search ? ilike(scannerInboxDocuments.remoteFilename, like(args.search as string)) : undefined))
|
|
.orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
|
|
return paginatedResult(rows, args)
|
|
}),
|
|
tool("inbox.count", "Offene Dokumente zählen", "Zählt nicht archivierte, noch nicht abgelegte Dokumente des Mandanten.", false, {}, [],
|
|
context => inbox(context).openCount(context.tenantId)),
|
|
...([ ["folders", folders], ["types", filetags] ] as const).map(([name, table]) =>
|
|
tool(`${name}.list`, name === "folders" ? "Ablageordner auflisten" : "Dokumenttypen auflisten",
|
|
"Listet verfügbare Ablageziele. Ordner enthalten parent, standardFiletype und standardFiletypeIsOptional.", false,
|
|
{ search: searchField, ...paginationFields }, [], async (context, args) => {
|
|
const rows = await context.server.db.select().from(table)
|
|
.where(and(eq(table.tenant, context.tenantId), eq(table.archived, false), args.search ? ilike(table.name, like(args.search as string)) : undefined))
|
|
.orderBy(asc(table.name), asc(table.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
|
|
return paginatedResult(rows, args)
|
|
})),
|
|
tool("assignment_kinds.list", "Zuordnungsarten auflisten", "Zeigt die unterstützten Zuordnungsarten, beispielsweise Projekt, Kunde, Eingangsrechnung oder Ausgangsbeleg.", false, {}, [],
|
|
async () => ({ kinds: assignmentKinds })),
|
|
tool("assignments.search", "Zuordnungsziele suchen", "Sucht verfügbare Datensätze einer Zuordnungsart im aktiven Mandanten.", false,
|
|
{ kind: kindField, search: searchField, ...paginationFields }, ["kind"], async (context, args) =>
|
|
paginatedResult(await assignmentOptions(context.server.db, context.tenantId, args.kind as string, args.search as string || "",
|
|
limitFromArgs(args) + 1, offsetFromArgs(args)), args)),
|
|
tool("inbox.assignments.get", "Dokumentzuordnungen laden", "Lädt bestehende Zuordnungen eines offenen Dokuments aus der Dokumentenablage.", false,
|
|
{ id: uuidField }, ["id"], async (context, args) => ({ assignments: await inbox(context).getAssignments(context.tenantId, args.id as string) })),
|
|
tool("inbox.suggestion.get", "Ablagevorschlag laden", "Lädt Status und Vorschlag der KI-Erkennung. Ein Vorschlag legt das Dokument noch nicht ab.", false,
|
|
{ id: uuidField }, ["id"], (context, args) => inbox(context).getSuggestion(context.tenantId, args.id as string)),
|
|
tool("inbox.suggestion.refresh", "Ablagevorschlag neu anfordern", "Stellt die KI-Erkennung erneut in die Warteschlange. Den Status anschließend mit documents.inbox.suggestion.get abfragen.", true,
|
|
{ id: uuidField }, ["id"], (context, args) => inbox(context).queueSuggestion(context.tenantId, args.id as string)),
|
|
tool("inbox.file", "Dokument ablegen", "Legt ein offenes Dokument in einem Ordner (folder: null für Hauptebene) mit Dokumenttyp ab. Optionale Zuordnungen ändern nur angegebene Felder; null entfernt eine Zuordnung. Feste Ordnertypen werden geprüft.", true,
|
|
{ id: uuidField, folder: nullable(uuidField), type: uuidField, assignments: assignmentsField }, ["id", "folder", "type"],
|
|
(context, args) => inbox(context).fileDocument(context.tenantId, context.userId, args.id as string, args.folder as string | null,
|
|
args.type as string, args.assignments as FileAssignments)),
|
|
tool("inbox.archive", "Dokument archivieren", "Archiviert ein offenes Dokument aus der Dokumentenablage. Bereits abgelegte Dokumente werden abgewiesen.", true,
|
|
{ id: uuidField }, ["id"], async (context, args) => {
|
|
await inbox(context).archiveDocument(context.tenantId, context.userId, args.id as string)
|
|
return { id: args.id, success: true, action: "archived" }
|
|
}),
|
|
tool("files.list", "Gespeicherte Dateien suchen", "Listet nicht archivierte Dateien einschließlich PDFs zu Eingangsrechnungen und Ausgangsbelegen. Filter werden kombiniert; folder: null sucht auf der Hauptebene.", false,
|
|
{ search: searchField, folder: nullable(uuidField), type: uuidField, mimeType: textField(true), ...assignmentFields, ...paginationFields }, [],
|
|
async (context, args) => {
|
|
const conditions = [eq(files.tenant, context.tenantId), eq(files.archived, false)]
|
|
if (args.search) conditions.push(or(ilike(files.name, like(args.search as string)), ilike(files.extractedText, like(args.search as string))))
|
|
for (const key of ["folder", "type", "mimeType", ...Object.keys(assignmentFields)]) {
|
|
if (args[key] !== undefined) conditions.push(args[key] === null ? isNull(files[key]) : eq(files[key], args[key]))
|
|
}
|
|
const rows = await context.server.db.select(fileMetadataColumns).from(files).where(and(...conditions))
|
|
.orderBy(desc(files.createdAt), files.id).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
|
|
return paginatedResult(rows, args)
|
|
}),
|
|
tool("files.get", "Dateidetails laden", "Lädt Metadaten, Zuordnungs-IDs und bereits extrahierten Text einer Datei. Binärinhalt über documents.files.download abrufen.", false,
|
|
{ id: uuidField }, ["id"], async (context, args) => {
|
|
const { path, ...file } = await loadDocumentFile(context, args.id as string)
|
|
return { file }
|
|
}),
|
|
tool("files.download", "PDF oder Datei herunterladen", "Liefert standardmäßig eine Download-URL für die Originaldatei. GET benötigt denselben Authorization: Bearer MCP-Token. Alternativ format: base64 für Dateiinhalt bis 20 MiB direkt im Ergebnis; keine PDF-Konvertierung.", false,
|
|
{ id: uuidField, format: formatField }, ["id"], async (context, args) => {
|
|
const file = await loadDocumentFile(context, args.id as string)
|
|
if (!file.path) throw new Error("Kein Dateiinhalt verfügbar.")
|
|
const metadata = { id: file.id, filename: file.name, mimeType: file.mimeType, size: file.size }
|
|
if (args.format !== "base64") return { ...metadata, url: documentDownloadUrl(file.id), method: "GET", authentication: "Bearer" }
|
|
if (file.size > MCP_FILE_LIMIT) throw new Error("Die Datei ist für Base64 zu groß. Bitte format: url verwenden.")
|
|
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
|
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
|
}),
|
|
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
|
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
|
["filename", "contentBase64"], async (context, args) => {
|
|
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
|
const result = await inbox(context).upload(context.tenantId, context.userId, args.filename as string, body)
|
|
return { ...result, filename: args.filename, mimeType, size: body.length }
|
|
}),
|
|
]
|
|
}
|
|
|
|
export const documentTools = createDocumentTools()
|