Erweiterungen

This commit is contained in:
2026-01-22 19:57:40 +01:00
parent e46f12807a
commit 4e5bd439bd
4 changed files with 237 additions and 24 deletions

3
.gitignore vendored
View File

@@ -8,6 +8,9 @@
*.iml *.iml
.DS_Store .DS_Store
# H2 Database files
/data/
# The following files are generated/updated by vaadin-maven-plugin # The following files are generated/updated by vaadin-maven-plugin
node_modules/ node_modules/
src/main/frontend/generated/ src/main/frontend/generated/

View File

@@ -94,22 +94,38 @@ public class LlmService {
- Format: "Herr Vorname Nachname" oder "Frau Vorname Nachname" - Format: "Herr Vorname Nachname" oder "Frau Vorname Nachname"
- Wenn kein Geschlecht erkennbar, nur den Namen extrahieren - Wenn kein Geschlecht erkennbar, nur den Namen extrahieren
WICHTIG - Unterscheidung zwischen ORDER und QUOTE_REQUEST: KRITISCH - Unterscheidung zwischen ORDER und QUOTE_REQUEST anhand von Stichwörtern:
Setze "QUOTE_REQUEST" wenn: ===== QUOTE_REQUEST (Angebotsanfrage) =====
- Nach einem Preis, Kosten oder Angebot gefragt wird Setze "QUOTE_REQUEST" wenn EINES dieser Stichwörter vorkommt:
- Formulierungen wie "Was würde es kosten...", "Können Sie mir ein Angebot machen...", "Preisanfrage", "Kostenvoranschlag" - "Angebot" (z.B. "bitte um Angebot", "Angebot erstellen")
- "Angebotsanfrage"
- "Anfrage für ein Angebot"
- "Preisanfrage"
- "Kostenvoranschlag"
- "Was kostet...", "Was würde es kosten..."
- "Können Sie mir ein Angebot machen..."
- "Preis", "Kosten" (in fragendem Kontext)
- "möchte gerne wissen", "interessiert an"
- "würde", "könnte", "eventuell", "vielleicht"
- Unverbindliche Anfragen ohne konkreten Auftrag - Unverbindliche Anfragen ohne konkreten Auftrag
- Der Absender noch keine Entscheidung getroffen hat
- Worte wie "würde", "könnte", "möchte wissen", "interessiert an"
Setze "ORDER" NUR wenn: ===== ORDER (Auftrag) =====
- Ein konkreter, verbindlicher Auftrag erteilt wird Setze "ORDER" NUR wenn EINES dieser Stichwörter vorkommt UND es sich um einen verbindlichen Auftrag handelt:
- Feste Termine und Daten genannt werden - "Auftrag" (z.B. "hiermit erteilen wir den Auftrag", "Auftragserteilung")
- Formulierungen wie "Bitte holen Sie ab...", "Wir beauftragen Sie...", "Hiermit bestellen wir..." - "beauftragen", "Beauftragung"
- Der Kunde klar eine Leistung beauftragt (nicht nur anfragt) - "bestellen", "Bestellung"
- "bitte abholen", "bitte zustellen" (als direkte Anweisung, nicht als Frage)
- "hiermit bestellen wir..."
- "wir beauftragen Sie..."
- Feste, verbindliche Termine werden genannt
- Der Kunde hat BEREITS eine Entscheidung getroffen
Im Zweifel: Wähle "QUOTE_REQUEST" ===== ENTSCHEIDUNGSLOGIK =====
1. Prüfe ZUERST auf Stichwörter für QUOTE_REQUEST
2. Wenn "Angebot", "Angebotsanfrage", "Preisanfrage", "Kostenvoranschlag" vorkommt → QUOTE_REQUEST
3. Wenn "Auftrag", "beauftragen", "bestellen" vorkommt UND verbindlich formuliert → ORDER
4. Im Zweifel oder bei unklarer Formulierung → QUOTE_REQUEST
Weitere Regeln: Weitere Regeln:
- Extrahiere alle Stationen (Abholung und Zustellung) - Extrahiere alle Stationen (Abholung und Zustellung)
@@ -118,6 +134,8 @@ public class LlmService {
} }
private String buildPrompt(OrderEmail email) { private String buildPrompt(OrderEmail email) {
String keywordHint = detectKeywords(email);
return String.format(""" return String.format("""
Analysiere die folgende Email und extrahiere die relevanten Informationen: Analysiere die folgende Email und extrahiere die relevanten Informationen:
@@ -125,15 +143,63 @@ public class LlmService {
Betreff: %s Betreff: %s
Inhalt: Inhalt:
%s
%s %s
""", """,
email.getFromName() != null ? email.getFromName() : "", email.getFromName() != null ? email.getFromName() : "",
email.getFromAddress(), email.getFromAddress(),
email.getSubject(), email.getSubject(),
email.getContent() email.getContent(),
keywordHint
); );
} }
private String detectKeywords(OrderEmail email) {
String fullText = (email.getSubject() + " " + email.getContent()).toLowerCase();
// Keywords für Angebotsanfrage (QUOTE_REQUEST)
boolean hasQuoteKeywords =
fullText.contains("angebot") ||
fullText.contains("angebotsanfrage") ||
fullText.contains("preisanfrage") ||
fullText.contains("kostenvoranschlag") ||
fullText.contains("was kostet") ||
fullText.contains("was würde") ||
fullText.contains("könnten sie mir") ||
fullText.contains("preis für") ||
fullText.contains("kosten für");
// Keywords für Auftrag (ORDER)
boolean hasOrderKeywords =
fullText.contains("auftrag") ||
fullText.contains("beauftrag") ||
fullText.contains("bestellung") ||
fullText.contains("bestellen") ||
fullText.contains("hiermit bestell") ||
fullText.contains("bitte abholen") ||
fullText.contains("bitte zustellen") ||
fullText.contains("abholung am") ||
fullText.contains("zustellung am");
StringBuilder hint = new StringBuilder("HINWEIS zur Klassifizierung: ");
if (hasQuoteKeywords && !hasOrderKeywords) {
hint.append("Die Email enthält Stichwörter für eine ANGEBOTSANFRAGE (z.B. 'Angebot', 'Preisanfrage'). ");
hint.append("Klassifiziere als QUOTE_REQUEST, es sei denn, es handelt sich eindeutig um einen verbindlichen Auftrag.");
} else if (hasOrderKeywords && !hasQuoteKeywords) {
hint.append("Die Email enthält Stichwörter für einen AUFTRAG (z.B. 'Auftrag', 'beauftragen', 'bestellen'). ");
hint.append("Klassifiziere als ORDER, wenn die Formulierung verbindlich ist.");
} else if (hasQuoteKeywords && hasOrderKeywords) {
hint.append("Die Email enthält sowohl Angebots- als auch Auftrags-Stichwörter. ");
hint.append("Prüfe sorgfältig: Wird ein Angebot ANGEFRAGT oder ein Auftrag ERTEILT? Im Zweifel: QUOTE_REQUEST.");
} else {
hint.append("Keine eindeutigen Stichwörter gefunden. Analysiere den Kontext sorgfältig. Im Zweifel: QUOTE_REQUEST.");
}
return hint.toString();
}
private OrderSummary parseResponse(String response) { private OrderSummary parseResponse(String response) {
try { try {
JsonNode root = objectMapper.readTree(response); JsonNode root = objectMapper.readTree(response);

View File

@@ -5,11 +5,16 @@ import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog; import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.dnd.DragSource;
import com.vaadin.flow.component.dnd.DropEffect;
import com.vaadin.flow.component.dnd.DropTarget;
import com.vaadin.flow.component.formlayout.FormLayout; import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H4; import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Pre; import com.vaadin.flow.component.html.Pre;
import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
@@ -17,12 +22,14 @@ import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.EmailField; import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.theme.lumo.LumoUtility; import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.aimailassistant.mail.domain.EmailType; import de.assecutor.aimailassistant.mail.domain.EmailType;
import de.assecutor.aimailassistant.mail.domain.Gender; import de.assecutor.aimailassistant.mail.domain.Gender;
import de.assecutor.aimailassistant.mail.domain.OrderEmail; import de.assecutor.aimailassistant.mail.domain.OrderEmail;
import de.assecutor.aimailassistant.mail.domain.OrderSummary; import de.assecutor.aimailassistant.mail.domain.OrderSummary;
import de.assecutor.aimailassistant.mail.domain.Station; import de.assecutor.aimailassistant.mail.domain.Station;
import de.assecutor.aimailassistant.mail.domain.StationAction;
import de.assecutor.aimailassistant.mail.service.LlmService; import de.assecutor.aimailassistant.mail.service.LlmService;
import de.assecutor.aimailassistant.mail.service.SmtpEmailService; import de.assecutor.aimailassistant.mail.service.SmtpEmailService;
@@ -343,14 +350,47 @@ public class OrderDetailDialog extends Dialog {
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL); title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
section.add(title); section.add(title);
for (Station station : summary.getStations()) { buildStationCards(section);
return section;
}
private void buildStationCards(VerticalLayout section) {
var stations = summary.getStations();
for (int i = 0; i < stations.size(); i++) {
Station station = stations.get(i);
int index = i;
HorizontalLayout cardWrapper = new HorizontalLayout();
cardWrapper.setAlignItems(FlexComponent.Alignment.CENTER);
cardWrapper.setSpacing(true);
cardWrapper.setWidthFull();
cardWrapper.getStyle().set("cursor", "grab");
// Numbered badge (circular, light blue)
Span numberBadge = new Span(String.valueOf(i + 1));
numberBadge.getStyle()
.set("display", "flex")
.set("align-items", "center")
.set("justify-content", "center")
.set("width", "28px")
.set("height", "28px")
.set("min-width", "28px")
.set("border-radius", "50%")
.set("background-color", "var(--lumo-primary-color-10pct)")
.set("color", "var(--lumo-primary-text-color)")
.set("font-weight", "600")
.set("font-size", "var(--lumo-font-size-s)");
Div stationCard = new Div(); Div stationCard = new Div();
stationCard.addClassNames( stationCard.addClassNames(
LumoUtility.Background.CONTRAST_5, LumoUtility.Background.CONTRAST_5,
LumoUtility.BorderRadius.MEDIUM, LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.SMALL LumoUtility.Padding.SMALL
); );
stationCard.setWidthFull(); stationCard.getStyle()
.set("flex", "1")
.set("position", "relative");
HorizontalLayout stationContent = new HorizontalLayout(); HorizontalLayout stationContent = new HorizontalLayout();
stationContent.setAlignItems(FlexComponent.Alignment.CENTER); stationContent.setAlignItems(FlexComponent.Alignment.CENTER);
@@ -386,33 +426,79 @@ public class OrderDetailDialog extends Dialog {
stationInfo.setSpacing(false); stationInfo.setSpacing(false);
stationInfo.getStyle().set("min-width", "0"); stationInfo.getStyle().set("min-width", "0");
if (station.getName() != null) { if (station.getName() != null && !station.getName().isBlank()) {
Span name = new Span(station.getName()); Span name = new Span(station.getName());
name.addClassName(LumoUtility.FontWeight.SEMIBOLD); name.addClassName(LumoUtility.FontWeight.SEMIBOLD);
stationInfo.add(name); stationInfo.add(name);
} }
if (station.getAddress() != null) { if (station.getAddress() != null && !station.getAddress().isBlank()) {
Span address = new Span(station.getAddress()); Span address = new Span(station.getAddress());
address.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL); address.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);
address.getStyle().set("word-break", "break-word"); address.getStyle().set("word-break", "break-word");
stationInfo.add(address); stationInfo.add(address);
} }
// Edit button in top-right corner
Button editButton = new Button(new Icon(VaadinIcon.EDIT));
editButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE, ButtonVariant.LUMO_SMALL);
editButton.getElement().setAttribute("title", "Station bearbeiten");
editButton.getStyle()
.set("position", "absolute")
.set("top", "var(--lumo-space-xs)")
.set("right", "var(--lumo-space-xs)");
editButton.addClickListener(e -> openStationEditDialog(station, section));
stationContent.add(actionBadge, stationInfo); stationContent.add(actionBadge, stationInfo);
stationContent.setFlexGrow(1, stationInfo); stationContent.setFlexGrow(1, stationInfo);
stationCard.add(stationContent); stationCard.add(stationContent, editButton);
section.add(stationCard);
cardWrapper.add(numberBadge, stationCard);
// Drag and Drop setup
DragSource<HorizontalLayout> dragSource = DragSource.create(cardWrapper);
dragSource.setDragData(index);
dragSource.addDragStartListener(e -> cardWrapper.getStyle().set("opacity", "0.5"));
dragSource.addDragEndListener(e -> cardWrapper.getStyle().remove("opacity"));
DropTarget<HorizontalLayout> dropTarget = DropTarget.create(cardWrapper);
dropTarget.setDropEffect(DropEffect.MOVE);
dropTarget.addDropListener(e -> {
e.getDragSourceComponent().ifPresent(source -> {
if (source instanceof HorizontalLayout) {
e.getDragData().ifPresent(data -> {
if (data instanceof Integer sourceIndex) {
reorderStations(sourceIndex, index, section);
}
});
}
});
});
section.add(cardWrapper);
} }
// Tour anzeigen Button // Tour anzeigen Button
if (summary.getStations().size() >= 2) { if (stations.size() >= 2) {
Button showTourButton = new Button("Tour anzeigen", e -> openTourMap()); Button showTourButton = new Button("Tour anzeigen", e -> openTourMap());
showTourButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL); showTourButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL);
showTourButton.setWidthFull(); showTourButton.setWidthFull();
section.add(showTourButton); section.add(showTourButton);
} }
}
return section; private void reorderStations(int fromIndex, int toIndex, VerticalLayout section) {
if (fromIndex == toIndex) return;
var stations = summary.getStations();
Station movedStation = stations.remove(fromIndex);
stations.add(toIndex, movedStation);
// Update summary JSON
orderEmail.setSummaryJson(llmService.serializeSummary(summary));
onProcessed.accept(orderEmail);
// Refresh the stations section
refreshStationsSection(section);
} }
private void openTourMap() { private void openTourMap() {
@@ -420,6 +506,64 @@ public class OrderDetailDialog extends Dialog {
tourMapDialog.open(); tourMapDialog.open();
} }
private void openStationEditDialog(Station station, VerticalLayout stationsSection) {
Dialog editDialog = new Dialog();
editDialog.setHeaderTitle("Station bearbeiten");
editDialog.setWidth("400px");
FormLayout formLayout = new FormLayout();
formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
TextField nameField = new TextField("Name");
nameField.setWidthFull();
nameField.setValue(station.getName() != null ? station.getName() : "");
TextArea addressField = new TextArea("Adresse");
addressField.setWidthFull();
addressField.setMinHeight("80px");
addressField.setValue(station.getAddress() != null ? station.getAddress() : "");
ComboBox<StationAction> actionComboBox = new ComboBox<>("Aktion");
actionComboBox.setItems(StationAction.values());
actionComboBox.setItemLabelGenerator(StationAction::getDisplayName);
actionComboBox.setValue(station.getAction());
actionComboBox.setWidthFull();
formLayout.add(nameField, addressField, actionComboBox);
editDialog.add(formLayout);
Button cancelButton = new Button("Abbrechen", e -> editDialog.close());
Button saveButton = new Button("Speichern", e -> {
station.setName(nameField.getValue());
station.setAddress(addressField.getValue());
station.setAction(actionComboBox.getValue());
// Update summary JSON
orderEmail.setSummaryJson(llmService.serializeSummary(summary));
onProcessed.accept(orderEmail);
// Refresh the stations section
refreshStationsSection(stationsSection);
editDialog.close();
Notification.show("Station aktualisiert", 3000, Notification.Position.BOTTOM_START);
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
editDialog.getFooter().add(cancelButton, saveButton);
editDialog.open();
}
private void refreshStationsSection(VerticalLayout stationsSection) {
stationsSection.removeAll();
H4 title = new H4("Stationen");
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
stationsSection.add(title);
buildStationCards(stationsSection);
}
private VerticalLayout createRemarksSection() { private VerticalLayout createRemarksSection() {
VerticalLayout section = new VerticalLayout(); VerticalLayout section = new VerticalLayout();
section.setPadding(false); section.setPadding(false);

View File

@@ -7,13 +7,13 @@ vaadin.launch-browser=true
# To improve the performance during development. # To improve the performance during development.
vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant
# H2 Database Configuration # H2 Database Configuration (file-based for persistence)
spring.datasource.url=jdbc:h2:mem:mailassistant spring.datasource.url=jdbc:h2:file:./data/mailassistant;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driverClassName=org.h2.Driver spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa spring.datasource.username=sa
spring.datasource.password= spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true spring.h2.console.enabled=true
# IMAP Configuration # IMAP Configuration