diff --git a/backend/src/modules/email/email.entity-suggestions.ts b/backend/src/modules/email/email.entity-suggestions.ts index b64a816..60b0515 100644 --- a/backend/src/modules/email/email.entity-suggestions.ts +++ b/backend/src/modules/email/email.entity-suggestions.ts @@ -24,6 +24,16 @@ export const suggestionFormat = z.object({ }) export type EntitySuggestion = z.infer["suggestions"][number] +export function dismissEntitySuggestion( + suggestions: EntitySuggestion[] | null | undefined, + entityType: string, + entityId: number, +) { + return (suggestions || []).filter(suggestion => + suggestion.entityType !== entityType || suggestion.entityId !== entityId, + ) +} + // Only known, tenant-scoped candidates may become actionable suggestions. export function validateSuggestions(value: unknown, candidates: EntityCandidate[]) { const parsed = suggestionFormat.parse(value) diff --git a/backend/src/routes/emailAsUser.ts b/backend/src/routes/emailAsUser.ts index 76b06b8..2d601b3 100644 --- a/backend/src/routes/emailAsUser.ts +++ b/backend/src/routes/emailAsUser.ts @@ -1,4 +1,9 @@ -import { suggestEmailEntities, type EntityCandidate, type EntitySuggestion } from "../modules/email/email.entity-suggestions" +import { + dismissEntitySuggestion, + suggestEmailEntities, + type EntityCandidate, + type EntitySuggestion, +} from "../modules/email/email.entity-suggestions" import nodemailer from "nodemailer" import { FastifyInstance } from "fastify" import { and, desc, eq, isNotNull } from "drizzle-orm" @@ -617,6 +622,38 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { } }) + server.delete("/email/messages/:id/entity-suggestions/:entityType/:entityId", async (req, reply) => { + if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" }) + try { + const { id, entityType, entityId: rawEntityId } = req.params as { + id: string + entityType: string + entityId: string + } + const entityId = Number(rawEntityId) + if (!getEntityDefinition(entityType)) { + return reply.code(400).send({ error: "Nicht unterstützter Entitätstyp" }) + } + if (!Number.isSafeInteger(entityId) || entityId <= 0) { + return reply.code(400).send({ error: "Ungültige Entitäts-ID" }) + } + + const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id) + if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" }) + + const suggestions = dismissEntitySuggestion(message.entitySuggestions, entityType, entityId) + await server.db.update(emailMessages).set({ entitySuggestions: suggestions }).where(and( + eq(emailMessages.id, id), + eq(emailMessages.tenantId, req.user.tenant_id), + eq(emailMessages.userId, req.user.user_id), + )) + return reply.send({ success: true, suggestions }) + } catch (err: any) { + req.log.error(err) + return reply.code(500).send({ error: "Vorschlag konnte nicht ausgeblendet werden." }) + } + }) + server.post("/email/messages/:id/entity-links", async (req, reply) => { try { if (!req.user?.tenant_id) { diff --git a/backend/tests/emailEntitySuggestions.test.ts b/backend/tests/emailEntitySuggestions.test.ts index 8263b18..2350b10 100644 --- a/backend/tests/emailEntitySuggestions.test.ts +++ b/backend/tests/emailEntitySuggestions.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict" import test from "node:test" -import { buildSuggestionMail, selectCandidates, validateSuggestions, type EntityCandidate } from "../src/modules/email/email.entity-suggestions" +import { + buildSuggestionMail, + dismissEntitySuggestion, + selectCandidates, + validateSuggestions, + type EntityCandidate, +} from "../src/modules/email/email.entity-suggestions" const customer: EntityCandidate = { entityType: "customers", entityId: 1, entityName: "Muster GmbH", entityTypeLabel: "Kunde", email: "kontakt@muster.de" } const project: EntityCandidate = { entityType: "projects", entityId: 1, entityName: "Umbau", entityTypeLabel: "Projekt", number: "P-2026-123" } @@ -54,3 +60,12 @@ test("recipient and CC headers neither reach the model nor affect candidate rank assert.equal(ranked[0], customer) assert.equal(ranked[1], project) }) + +test("dismisses only the selected entity suggestion", () => { + const projectSuggestion = { ...suggestion, entityType: "projects" as const, reason: "Projektnummer stimmt überein." } + assert.deepEqual( + dismissEntitySuggestion([suggestion, projectSuggestion], "customers", 1), + [projectSuggestion], + ) + assert.deepEqual(dismissEntitySuggestion(null, "customers", 1), []) +}) diff --git a/docs/bedienung/email-ki-zuordnung.md b/docs/bedienung/email-ki-zuordnung.md index 326cb21..0c5f369 100644 --- a/docs/bedienung/email-ki-zuordnung.md +++ b/docs/bedienung/email-ki-zuordnung.md @@ -1,8 +1,8 @@ # KI-Zuordnung von E-Mails -Beim Öffnen einer E-Mail im Postfach startet die Analyse im Hintergrund. Unter „KI-Zuordnungsvorschläge“ erscheinen bis zu fünf Vorschläge für Kunden, Lieferanten, Projekte und Objekte mit Begründung und Einschätzung der Übereinstimmung. „Übernehmen“ legt die jeweilige Verknüpfung an. Die manuelle Zuordnung bleibt verfügbar. +Beim Öffnen einer E-Mail im Postfach startet die Analyse im Hintergrund. Unter „KI-Zuordnungsvorschläge“ erscheinen bis zu fünf Vorschläge für Kunden, Lieferanten, Projekte und Objekte mit Begründung und Einschätzung der Übereinstimmung. „Übernehmen“ legt die jeweilige Verknüpfung an. „Ausblenden“ entfernt einen unpassenden Vorschlag dauerhaft aus der gespeicherten Analyse dieser Mail. Die manuelle Zuordnung bleibt verfügbar. -Die Analyse wird pro Mail gespeichert, auch wenn sie keine Treffer findet. Erneutes Öffnen verwendet das gespeicherte Ergebnis. „Erneut prüfen“ aktualisiert die Analyse, beispielsweise nach Änderungen an Stammdaten. Bereits verknüpfte Entitäten werden ausgeblendet. Fehler werden nicht gespeichert und können erneut versucht werden. +Die Analyse wird pro Mail gespeichert, auch wenn sie keine Treffer findet. Erneutes Öffnen verwendet das gespeicherte Ergebnis. „Erneut prüfen“ aktualisiert die gesamte Analyse, beispielsweise nach Änderungen an Stammdaten; dabei kann ein zuvor ausgeblendeter Vorschlag wieder erscheinen, wenn die KI ihn erneut erkennt. Bereits verknüpfte Entitäten werden ausgeblendet. Fehler werden nicht gespeichert und können erneut versucht werden. ## Betrieb diff --git a/frontend/components/email/EmailEntityLinks.vue b/frontend/components/email/EmailEntityLinks.vue index e9cf73a..0614a8c 100644 --- a/frontend/components/email/EmailEntityLinks.vue +++ b/frontend/components/email/EmailEntityLinks.vue @@ -27,6 +27,7 @@ type Suggestion = Omit & { confidence: "high" | "medium"; reas const suggestions = ref([]) const loadingSuggestions = ref(false) const suggestionError = ref("") +const dismissingSuggestion = ref("") let suggestionRequest = 0 let active = true onBeforeUnmount(() => { active = false; suggestionRequest++ }) @@ -124,6 +125,31 @@ async function addLink(suggestion?: Suggestion) { } } +async function dismissSuggestion(suggestion: Suggestion) { + const key = `${suggestion.entityType}:${suggestion.entityId}` + const messageId = props.messageId + if (dismissingSuggestion.value) return + dismissingSuggestion.value = key + try { + await useNuxtApp().$api( + `/api/email/messages/${messageId}/entity-suggestions/${suggestion.entityType}/${suggestion.entityId}`, + { method: "DELETE" }, + ) + if (!active || messageId !== props.messageId) return + suggestions.value = suggestions.value.filter(item => + item.entityType !== suggestion.entityType || item.entityId !== suggestion.entityId, + ) + } catch (err: any) { + toast.add({ + title: "Ausblenden fehlgeschlagen", + description: err?.data?.error || err?.message, + color: "error", + }) + } finally { + if (messageId === props.messageId) dismissingSuggestion.value = "" + } +} + async function removeLink(link: EntityLink) { const messageId = props.messageId saving.value = true @@ -199,7 +225,21 @@ watch(selectedEntityType, loadEntityOptions, { immediate: true })

{{ suggestion.reason }}

- Übernehmen +
+ + Ausblenden + + Übernehmen +