diff --git a/.gitignore b/.gitignore index 21f8584..8ae5a02 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ *.iml .DS_Store +# H2 Database files +/data/ + # The following files are generated/updated by vaadin-maven-plugin node_modules/ src/main/frontend/generated/ diff --git a/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java b/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java index d119f33..9ad5175 100644 --- a/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java +++ b/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java @@ -94,22 +94,38 @@ public class LlmService { - Format: "Herr Vorname Nachname" oder "Frau Vorname Nachname" - 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: - - Nach einem Preis, Kosten oder Angebot gefragt wird - - Formulierungen wie "Was würde es kosten...", "Können Sie mir ein Angebot machen...", "Preisanfrage", "Kostenvoranschlag" + ===== QUOTE_REQUEST (Angebotsanfrage) ===== + Setze "QUOTE_REQUEST" wenn EINES dieser Stichwörter vorkommt: + - "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 - - Der Absender noch keine Entscheidung getroffen hat - - Worte wie "würde", "könnte", "möchte wissen", "interessiert an" - Setze "ORDER" NUR wenn: - - Ein konkreter, verbindlicher Auftrag erteilt wird - - Feste Termine und Daten genannt werden - - Formulierungen wie "Bitte holen Sie ab...", "Wir beauftragen Sie...", "Hiermit bestellen wir..." - - Der Kunde klar eine Leistung beauftragt (nicht nur anfragt) + ===== ORDER (Auftrag) ===== + Setze "ORDER" NUR wenn EINES dieser Stichwörter vorkommt UND es sich um einen verbindlichen Auftrag handelt: + - "Auftrag" (z.B. "hiermit erteilen wir den Auftrag", "Auftragserteilung") + - "beauftragen", "Beauftragung" + - "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: - Extrahiere alle Stationen (Abholung und Zustellung) @@ -118,6 +134,8 @@ public class LlmService { } private String buildPrompt(OrderEmail email) { + String keywordHint = detectKeywords(email); + return String.format(""" Analysiere die folgende Email und extrahiere die relevanten Informationen: @@ -125,15 +143,63 @@ public class LlmService { Betreff: %s Inhalt: + %s + %s """, email.getFromName() != null ? email.getFromName() : "", email.getFromAddress(), 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) { try { JsonNode root = objectMapper.readTree(response); diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java index d04daf0..f8b923e 100644 --- a/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java @@ -5,11 +5,16 @@ import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.confirmdialog.ConfirmDialog; 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.html.Div; import com.vaadin.flow.component.html.H4; import com.vaadin.flow.component.html.Pre; 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.orderedlayout.FlexComponent; 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.textfield.EmailField; import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.aimailassistant.mail.domain.EmailType; import de.assecutor.aimailassistant.mail.domain.Gender; import de.assecutor.aimailassistant.mail.domain.OrderEmail; import de.assecutor.aimailassistant.mail.domain.OrderSummary; 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.SmtpEmailService; @@ -343,14 +350,47 @@ public class OrderDetailDialog extends Dialog { title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL); 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(); stationCard.addClassNames( LumoUtility.Background.CONTRAST_5, LumoUtility.BorderRadius.MEDIUM, LumoUtility.Padding.SMALL ); - stationCard.setWidthFull(); + stationCard.getStyle() + .set("flex", "1") + .set("position", "relative"); HorizontalLayout stationContent = new HorizontalLayout(); stationContent.setAlignItems(FlexComponent.Alignment.CENTER); @@ -386,33 +426,79 @@ public class OrderDetailDialog extends Dialog { stationInfo.setSpacing(false); stationInfo.getStyle().set("min-width", "0"); - if (station.getName() != null) { + if (station.getName() != null && !station.getName().isBlank()) { Span name = new Span(station.getName()); name.addClassName(LumoUtility.FontWeight.SEMIBOLD); stationInfo.add(name); } - if (station.getAddress() != null) { + if (station.getAddress() != null && !station.getAddress().isBlank()) { Span address = new Span(station.getAddress()); address.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL); address.getStyle().set("word-break", "break-word"); 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.setFlexGrow(1, stationInfo); - stationCard.add(stationContent); - section.add(stationCard); + stationCard.add(stationContent, editButton); + + cardWrapper.add(numberBadge, stationCard); + + // Drag and Drop setup + DragSource dragSource = DragSource.create(cardWrapper); + dragSource.setDragData(index); + dragSource.addDragStartListener(e -> cardWrapper.getStyle().set("opacity", "0.5")); + dragSource.addDragEndListener(e -> cardWrapper.getStyle().remove("opacity")); + + DropTarget 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 - if (summary.getStations().size() >= 2) { + if (stations.size() >= 2) { Button showTourButton = new Button("Tour anzeigen", e -> openTourMap()); showTourButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL); showTourButton.setWidthFull(); 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() { @@ -420,6 +506,64 @@ public class OrderDetailDialog extends Dialog { 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 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() { VerticalLayout section = new VerticalLayout(); section.setPadding(false); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index da0ff76..5ecfe5a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,13 +7,13 @@ vaadin.launch-browser=true # To improve the performance during development. vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant -# H2 Database Configuration -spring.datasource.url=jdbc:h2:mem:mailassistant +# H2 Database Configuration (file-based for persistence) +spring.datasource.url=jdbc:h2:file:./data/mailassistant;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= 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 # IMAP Configuration