feat(email): allow dismissing entity suggestions
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 1m16s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
Build and Push Docker Images / build-docs (push) Successful in 1m18s
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 1m16s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
Build and Push Docker Images / build-docs (push) Successful in 1m18s
This commit is contained in:
@@ -24,6 +24,16 @@ export const suggestionFormat = z.object({
|
|||||||
})
|
})
|
||||||
export type EntitySuggestion = z.infer<typeof suggestionFormat>["suggestions"][number]
|
export type EntitySuggestion = z.infer<typeof suggestionFormat>["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.
|
// Only known, tenant-scoped candidates may become actionable suggestions.
|
||||||
export function validateSuggestions(value: unknown, candidates: EntityCandidate[]) {
|
export function validateSuggestions(value: unknown, candidates: EntityCandidate[]) {
|
||||||
const parsed = suggestionFormat.parse(value)
|
const parsed = suggestionFormat.parse(value)
|
||||||
|
|||||||
@@ -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 nodemailer from "nodemailer"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { and, desc, eq, isNotNull } from "drizzle-orm"
|
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) => {
|
server.post("/email/messages/:id/entity-links", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user?.tenant_id) {
|
if (!req.user?.tenant_id) {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import test from "node:test"
|
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 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" }
|
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[0], customer)
|
||||||
assert.equal(ranked[1], project)
|
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), [])
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# KI-Zuordnung von E-Mails
|
# 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
|
## Betrieb
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Suggestion = Omit<EntityLink, "id"> & { confidence: "high" | "medium"; reas
|
|||||||
const suggestions = ref<Suggestion[]>([])
|
const suggestions = ref<Suggestion[]>([])
|
||||||
const loadingSuggestions = ref(false)
|
const loadingSuggestions = ref(false)
|
||||||
const suggestionError = ref("")
|
const suggestionError = ref("")
|
||||||
|
const dismissingSuggestion = ref("")
|
||||||
let suggestionRequest = 0
|
let suggestionRequest = 0
|
||||||
let active = true
|
let active = true
|
||||||
onBeforeUnmount(() => { active = false; suggestionRequest++ })
|
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) {
|
async function removeLink(link: EntityLink) {
|
||||||
const messageId = props.messageId
|
const messageId = props.messageId
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -199,7 +225,21 @@ watch(selectedEntityType, loadEntityOptions, { immediate: true })
|
|||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-dimmed">{{ suggestion.reason }}</p>
|
<p class="mt-1 text-dimmed">{{ suggestion.reason }}</p>
|
||||||
</div>
|
</div>
|
||||||
<UButton size="xs" icon="i-heroicons-link" :disabled="saving" @click="addLink(suggestion)">Übernehmen</UButton>
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
icon="i-heroicons-eye-slash"
|
||||||
|
:loading="dismissingSuggestion === `${suggestion.entityType}:${suggestion.entityId}`"
|
||||||
|
:disabled="saving || Boolean(dismissingSuggestion)"
|
||||||
|
:aria-label="`${suggestion.entityName} ausblenden`"
|
||||||
|
@click="dismissSuggestion(suggestion)"
|
||||||
|
>
|
||||||
|
Ausblenden
|
||||||
|
</UButton>
|
||||||
|
<UButton size="xs" icon="i-heroicons-link" :disabled="saving || Boolean(dismissingSuggestion)" @click="addLink(suggestion)">Übernehmen</UButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user