Erweiterungen

This commit is contained in:
2026-01-27 09:58:28 +01:00
parent 24c0d192dd
commit 5768a37c5e
10 changed files with 861 additions and 2135 deletions

View File

@@ -2,6 +2,7 @@ package de.assecutor.votianlt.ai.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j;
@@ -47,40 +48,32 @@ public class AiStatisticsService {
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
log.info("Processing statistics query: {}", userQuery);
// Gather current statistics
String statisticsContext = buildStatisticsContext();
// Determine query type and prepare chart data
// Determine query type and prepare chart data (includes customer filter detection)
QueryAnalysis analysis = analyzeQueryType(userQuery);
log.debug("Query analysis - Type: {}, Chart: {}", analysis.queryType, analysis.chartType);
log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}",
analysis.queryType, analysis.chartType,
analysis.customerFilter != null ? analysis.customerFilter : "none",
analysis.statusFilter != null ? analysis.statusFilter : "none");
// Gather context (statistics or job list depending on query type)
String statisticsContext = buildStatisticsContext(analysis);
// Build prompt for LLM
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
// System prompt for the statistics assistant
String systemPrompt = """
Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen.
Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken.
WICHTIGE FORMATIERUNGSREGELN:
- Verwende KEINE Tabellen (keine | oder --- Zeichen)
- Die Daten werden bereits als interaktives Diagramm visualisiert
- Fasse die wichtigsten Erkenntnisse in Fließtext oder kurzen Aufzählungen zusammen
- Nenne konkrete Zahlen im Text, aber liste nicht alle Werte tabellarisch auf
Antworte auf Deutsch, präzise und freundlich.
Erkläre die Daten kurz und gib bei Bedarf Empfehlungen.
Halte die Antwort kompakt (max. 3-4 Sätze für einfache Fragen, mehr für komplexe).
""";
// System prompt - different for list vs statistics queries
String systemPrompt = analysis.queryType.equals("list")
? buildListSystemPrompt()
: buildStatisticsSystemPrompt();
// Call LLM via direct REST client (like aimailassistant)
String llmResponse = llmClient.chat(systemPrompt, prompt);
if (llmResponse != null) {
if (llmResponse != null && !llmResponse.isBlank()) {
log.info("LLM response received, length: {} chars", llmResponse.length());
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
} else {
log.warn("LLM returned null response, using fallback");
log.warn("LLM returned null or blank response, using fallback");
return new StatisticsResponse(
buildFallbackResponse(analysis),
analysis.chartType,
@@ -92,45 +85,284 @@ public class AiStatisticsService {
private record QueryAnalysis(
String queryType,
String chartType,
String chartData
String chartData,
String customerFilter, // null = no filter, show all data
JobStatus statusFilter // null = no status filter
) {}
private String buildStatisticsSystemPrompt() {
return """
Du bist ein Statistik-Assistent für ein Logistikunternehmen.
WICHTIG - ANTWORTSTIL:
- Beantworte NUR die gestellte Frage - keine zusätzlichen Informationen!
- Keine allgemeinen Tipps, Empfehlungen oder weiterführende Hinweise
- Keine Vergleiche mit anderen Daten, außer explizit gefragt
- Kurz und präzise: maximal 2-3 Sätze
- Nenne die relevanten Zahlen direkt
DIAGRAMM-HINWEIS:
- Ein Diagramm wird AUTOMATISCH angezeigt - nicht erwähnen oder beschreiben
- Sage NIEMALS "Ich kann kein Diagramm zeichnen" oder ähnliches
FORMATIERUNG:
- Keine Tabellen
- Keine langen Aufzählungen
- Fließtext bevorzugen
Antworte auf Deutsch.
""";
}
private String buildListSystemPrompt() {
return """
Du bist ein Assistent für ein Logistikunternehmen.
WICHTIG - ANTWORTSTIL:
- Der Benutzer fragt nach einer Liste von Jobs/Aufträgen
- Die Job-Liste wird bereits als Daten angezeigt
- Fasse nur kurz zusammen, was gefunden wurde (z.B. "Es wurden X Jobs gefunden")
- Keine detaillierte Auflistung der Jobs nötig - die Daten sind bereits sichtbar
- Maximal 1-2 Sätze
KEIN DIAGRAMM:
- Es wird KEIN Diagramm angezeigt bei Listen-Anfragen
- Erwähne keine Diagramme
Antworte auf Deutsch.
""";
}
/**
* Detect user-specified chart type from the query.
* Returns null if no specific chart type was requested.
*/
private String detectUserChartTypePreference(String query) {
String lowerQuery = query.toLowerCase();
// Balkendiagramm / Bar Chart
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") ||
lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) {
return "bar";
}
// Tortendiagramm / Pie Chart
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") ||
lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") ||
lowerQuery.contains("kreisdiagramm")) {
return "pie";
}
// Donut / Ring Chart
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") ||
lowerQuery.contains("doughnut")) {
return "doughnut";
}
// Liniendiagramm / Line Chart
if (lowerQuery.contains("linie") || lowerQuery.contains("line") ||
lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") ||
lowerQuery.contains("graph")) {
return "line";
}
// Flächendiagramm / Area Chart
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") ||
lowerQuery.contains("flächendiagramm")) {
return "line"; // Line with fill=true
}
// Radar Chart
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") ||
lowerQuery.contains("spinne")) {
return "radar";
}
// Polararea Chart
if (lowerQuery.contains("polar")) {
return "polarArea";
}
return null; // No specific preference
}
private QueryAnalysis analyzeQueryType(String query) {
String lowerQuery = query.toLowerCase();
// Status-bezogene Anfragen
if (lowerQuery.contains("status") || lowerQuery.contains("offen") ||
lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") ||
lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) {
return new QueryAnalysis("status", "doughnut", buildStatusChartData());
// First, check if this is a LIST query (no chart needed)
boolean isListQuery = isListQuery(lowerQuery);
log.debug("Is list query: {}", isListQuery);
// Check if user specified a chart type (only relevant for non-list queries)
String userChartType = isListQuery ? null : detectUserChartTypePreference(query);
log.debug("User chart type preference: {}", userChartType != null ? userChartType : "none");
// Check if user specified a customer filter
String customerFilter = detectCustomerFilter(query);
log.debug("Customer filter: {}", customerFilter != null ? customerFilter : "none (showing all data)");
// Check if user specified a status filter
JobStatus statusFilter = detectStatusFilter(lowerQuery);
log.debug("Status filter: {}", statusFilter != null ? statusFilter : "none");
// For list queries, return no chart
if (isListQuery) {
return new QueryAnalysis("list", null, null, customerFilter, statusFilter);
}
// Determine query type and default chart type
String queryType;
String defaultChartType;
String chartData;
// Status-bezogene Anfragen (Statistik, nicht Liste)
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") ||
lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) ||
lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
queryType = "status";
defaultChartType = "doughnut";
chartData = buildStatusChartData(customerFilter);
}
// Umsatz-bezogene Anfragen
if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
lowerQuery.contains("kunde") || lowerQuery.contains("customer") ||
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
lowerQuery.contains("einnahmen")) {
return new QueryAnalysis("revenue", "bar", buildRevenueChartData());
queryType = "revenue";
defaultChartType = "bar";
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData();
}
// Trend-bezogene Anfragen
if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") ||
lowerQuery.contains("verlauf")) {
return new QueryAnalysis("trend", "line", buildTrendChartData());
queryType = "trend";
defaultChartType = "line";
chartData = buildTrendChartData(customerFilter);
}
// Task-bezogene Anfragen
if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
lowerQuery.contains("erledigt")) {
return new QueryAnalysis("tasks", "doughnut", buildTaskChartData());
queryType = "tasks";
defaultChartType = "doughnut";
chartData = buildTaskChartData();
}
// Allgemeine Übersicht
else {
queryType = "overview";
defaultChartType = "bar";
chartData = buildOverviewChartData(customerFilter);
}
// Allgemeine Übersicht
return new QueryAnalysis("overview", "bar", buildOverviewChartData());
// Use user's chart type preference if specified, otherwise use default
String chartType = userChartType != null ? userChartType : defaultChartType;
return new QueryAnalysis(queryType, chartType, chartData, customerFilter, null);
}
private String buildStatusChartData() {
Map<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
/**
* Detect status filter from the query.
*/
private JobStatus detectStatusFilter(String lowerQuery) {
if (lowerQuery.contains("angelegt") || lowerQuery.contains("erstellt") || lowerQuery.contains("created")) {
return JobStatus.CREATED;
}
if (lowerQuery.contains("in bearbeitung") || lowerQuery.contains("in progress")) {
return JobStatus.IN_PROGRESS;
}
if (lowerQuery.contains("abholung geplant") || lowerQuery.contains("pickup scheduled")) {
return JobStatus.PICKUP_SCHEDULED;
}
if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
return JobStatus.PICKED_UP;
}
if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") || lowerQuery.contains("unterwegs")) {
return JobStatus.IN_TRANSIT;
}
if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) {
return JobStatus.DELIVERED;
}
if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
return JobStatus.COMPLETED;
}
if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") || lowerQuery.contains("abgebrochen")) {
return JobStatus.CANCELLED;
}
return null;
}
/**
* Detect if the query is asking for a list of jobs (not statistics).
*/
private boolean isListQuery(String lowerQuery) {
// Keywords that indicate a list/detail query (not statistics)
boolean hasListKeywords = lowerQuery.contains("zeige alle") ||
lowerQuery.contains("liste") ||
lowerQuery.contains("welche jobs") ||
lowerQuery.contains("alle jobs") ||
lowerQuery.contains("alle aufträge") ||
lowerQuery.contains("zeige die jobs") ||
lowerQuery.contains("zeige die aufträge") ||
lowerQuery.contains("zeig mir die") ||
lowerQuery.contains("gib mir die");
// Keywords that indicate statistics (override list detection)
boolean hasStatsKeywords = lowerQuery.contains("statistik") ||
lowerQuery.contains("diagramm") ||
lowerQuery.contains("chart") ||
lowerQuery.contains("verteilung") ||
lowerQuery.contains("wie viele") ||
lowerQuery.contains("anzahl") ||
lowerQuery.contains("zähle") ||
lowerQuery.contains("umsatz") ||
lowerQuery.contains("trend") ||
lowerQuery.contains("torte") ||
lowerQuery.contains("balken");
return hasListKeywords && !hasStatsKeywords;
}
/**
* Detect customer filter from the query.
* Returns the matching customer name or null if no filter detected.
*/
private String detectCustomerFilter(String query) {
String lowerQuery = query.toLowerCase();
// Keywords that indicate a customer filter
String[] filterIndicators = {
"für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ",
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
"nur ", "ausschließlich ", "speziell "
};
// Check if any indicator is present
boolean hasIndicator = false;
String foundIndicator = null;
for (String indicator : filterIndicators) {
int idx = lowerQuery.indexOf(indicator);
if (idx >= 0) {
hasIndicator = true;
foundIndicator = indicator;
break;
}
}
if (!hasIndicator) {
log.debug("detectCustomerFilter - No filter indicator found in: '{}'", query);
return null;
}
log.debug("detectCustomerFilter - Found indicator '{}' in query: '{}'", foundIndicator, query);
// Try to find a matching customer in the query
String matchedCustomer = statisticsService.findMatchingCustomer(query);
log.debug("detectCustomerFilter - Matched customer: '{}'", matchedCustomer);
return matchedCustomer;
}
private String buildStatusChartData(String customerFilter) {
Map<JobStatus, Long> statusCounts = customerFilter != null
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
: statisticsService.getJobCountsByStatus();
List<String> labels = new ArrayList<>();
List<Long> data = new ArrayList<>();
@@ -154,7 +386,8 @@ public class AiStatisticsService {
}
}
return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Aufträge");
String label = customerFilter != null ? "Aufträge (" + customerFilter + ")" : "Aufträge";
return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), label);
}
private String buildRevenueChartData() {
@@ -177,9 +410,30 @@ public class AiStatisticsService {
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
}
private String buildTrendChartData() {
private String buildCustomerRevenueChartData(String customer) {
var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customer);
var totalRevenue = statisticsService.getTotalRevenueForCustomer(customer);
long totalJobs = statisticsService.getTotalJobCountForCustomer(customer);
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
List<Double> data = List.of(
(double) totalJobs,
(double) completed,
(double) inProgress,
totalRevenue.doubleValue() / 100 // Scale down for better visualization
);
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
return buildChartJsonDouble(labels, data, colors, customer);
}
private String buildTrendChartData(String customerFilter) {
int currentYear = Year.now().getValue();
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(currentYear);
Map<Month, Long> monthlyData = customerFilter != null
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
: statisticsService.getMonthlyJobCounts(currentYear);
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
@@ -189,11 +443,15 @@ public class AiStatisticsService {
data.add(monthlyData.getOrDefault(month, 0L));
}
String datasetLabel = customerFilter != null
? String.format("%s - %d", customerFilter, currentYear)
: String.format("Aufträge %d", currentYear);
return String.format("""
{
"labels": %s,
"datasets": [{
"label": "Aufträge %d",
"label": "%s",
"data": %s,
"borderColor": "#6366f1",
"backgroundColor": "rgba(99, 102, 241, 0.15)",
@@ -206,7 +464,7 @@ public class AiStatisticsService {
"fill": true
}]
}
""", toJsonArray(labels), currentYear, data);
""", toJsonArray(labels), datasetLabel, data);
}
private String buildTaskChartData() {
@@ -222,10 +480,14 @@ public class AiStatisticsService {
return buildChartJson(labels, data, colors, "Aufgaben");
}
private String buildOverviewChartData() {
Map<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
private String buildOverviewChartData(String customerFilter) {
Map<JobStatus, Long> statusCounts = customerFilter != null
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
: statisticsService.getJobCountsByStatus();
long total = statisticsService.getTotalJobCount();
long total = customerFilter != null
? statisticsService.getTotalJobCountForCustomer(customerFilter)
: statisticsService.getTotalJobCount();
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L);
@@ -234,7 +496,8 @@ public class AiStatisticsService {
List<Long> data = List.of(total, completed, inProgress, open);
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
return buildChartJson(labels, data, colors, "Aufträge");
String label = customerFilter != null ? customerFilter : "Aufträge";
return buildChartJson(labels, data, colors, label);
}
private String buildChartJson(List<String> labels, List<Long> data, List<String> colors, String label) {
@@ -273,37 +536,103 @@ public class AiStatisticsService {
}
}
private String buildStatisticsContext() {
private String buildStatisticsContext(QueryAnalysis analysis) {
StringBuilder context = new StringBuilder();
String customerFilter = analysis.customerFilter;
JobStatus statusFilter = analysis.statusFilter;
// For LIST queries, show actual job data
if ("list".equals(analysis.queryType)) {
return buildListContext(customerFilter, statusFilter);
}
// For statistics queries
if (customerFilter != null) {
// Filtered statistics for a specific customer
context.append(String.format("**Statistiken für Kunde: %s**\n\n", customerFilter));
var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customerFilter);
context.append("**Aufträge nach Status:**\n");
statusCounts.forEach((status, count) -> {
if (count > 0) {
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count));
}
});
context.append(String.format("\n**Übersicht für %s:**\n", customerFilter));
context.append(String.format("- Gesamtanzahl Aufträge: %d\n",
statisticsService.getTotalJobCountForCustomer(customerFilter)));
context.append(String.format("- Abschlussrate: %.1f%%\n",
statisticsService.getCompletionRateForCustomer(customerFilter)));
context.append(String.format("- Umsatz: %.2f EUR\n",
statisticsService.getTotalRevenueForCustomer(customerFilter)));
} else {
// General statistics (all data)
var statusCounts = statisticsService.getJobCountsByStatus();
context.append("**Aktuelle Auftragsstatistiken:**\n");
statusCounts.forEach((status, count) ->
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
context.append(String.format("\n**Gesamtübersicht:**\n"));
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue()));
// Task statistics
var taskStats = statisticsService.getTaskCompletionStats();
context.append(String.format("\n**Aufgaben:**\n"));
context.append(String.format("- Gesamt: %d\n", taskStats.get("total")));
context.append(String.format("- Erledigt: %d\n", taskStats.get("completed")));
context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending")));
// Top customers
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
if (!topCustomers.isEmpty()) {
context.append("\n**Top 5 Kunden nach Umsatz:**\n");
for (var entry : topCustomers) {
context.append(String.format("- %s: %.2f EUR\n",
entry.getKey() != null ? entry.getKey() : "Unbekannt",
entry.getValue()));
}
}
}
return context.toString();
}
private String buildListContext(String customerFilter, JobStatus statusFilter) {
StringBuilder context = new StringBuilder();
// Job counts by status
var statusCounts = statisticsService.getJobCountsByStatus();
context.append("**Aktuelle Auftragsstatistiken:**\n");
statusCounts.forEach((status, count) ->
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
List<Job> jobs;
if (customerFilter != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
context.append(String.format("**Jobs für %s mit Status %s:**\n\n",
customerFilter, statusFilter.getDisplayName()));
} else if (customerFilter != null) {
jobs = statisticsService.getJobsByCustomer(customerFilter);
context.append(String.format("**Jobs für %s:**\n\n", customerFilter));
} else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatus(statusFilter);
context.append(String.format("**Jobs mit Status %s:**\n\n", statusFilter.getDisplayName()));
} else {
jobs = statisticsService.getLatestJobs(20);
context.append("**Aktuelle Jobs:**\n\n");
}
// Totals
context.append(String.format("\n**Gesamtübersicht:**\n"));
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue()));
context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size()));
// Task statistics
var taskStats = statisticsService.getTaskCompletionStats();
context.append(String.format("\n**Aufgaben:**\n"));
context.append(String.format("- Gesamt: %d\n", taskStats.get("total")));
context.append(String.format("- Erledigt: %d\n", taskStats.get("completed")));
context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending")));
// Top customers
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
if (!topCustomers.isEmpty()) {
context.append("\n**Top 5 Kunden nach Umsatz:**\n");
for (var entry : topCustomers) {
context.append(String.format("- %s: %.2f EUR\n",
entry.getKey() != null ? entry.getKey() : "Unbekannt",
entry.getValue()));
// Show job summaries (max 10)
int shown = 0;
for (Job job : jobs) {
if (shown >= 10) {
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
break;
}
context.append(String.format("- %s: %s (%s)\n",
job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
job.getStatus().getDisplayName()));
shown++;
}
return context.toString();
@@ -319,37 +648,79 @@ public class AiStatisticsService {
}
private String buildFallbackResponse(QueryAnalysis analysis) {
String customer = analysis.customerFilter;
JobStatus statusFilter = analysis.statusFilter;
return switch (analysis.queryType) {
case "list" -> {
List<Job> jobs;
if (customer != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.",
jobs.size(), customer, statusFilter.getDisplayName());
} else if (customer != null) {
jobs = statisticsService.getJobsByCustomer(customer);
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
} else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatus(statusFilter);
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.",
jobs.size(), statusFilter.getDisplayName());
} else {
yield "Hier sind die aktuellen Jobs.";
}
}
case "status" -> {
var counts = statisticsService.getJobCountsByStatus();
StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n");
var counts = customer != null
? statisticsService.getJobCountsByStatusForCustomer(customer)
: statisticsService.getJobCountsByStatus();
String title = customer != null
? String.format("**Auftragsübersicht für %s:**\n\n", customer)
: "**Auftragsübersicht nach Status:**\n\n";
StringBuilder sb = new StringBuilder(title);
counts.forEach((status, count) -> {
if (count > 0) {
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
}
});
sb.append(String.format("\n**Gesamt:** %d Aufträge", statisticsService.getTotalJobCount()));
long total = customer != null
? statisticsService.getTotalJobCountForCustomer(customer)
: statisticsService.getTotalJobCount();
sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
yield sb.toString();
}
case "revenue" -> {
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
int rank = 1;
for (var entry : topCustomers) {
sb.append(String.format("%d. **%s:** %.2f EUR\n",
rank++,
entry.getKey() != null ? entry.getKey() : "Unbekannt",
entry.getValue()));
if (customer != null) {
var revenue = statisticsService.getTotalRevenueForCustomer(customer);
var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
yield String.format("**Umsatz für %s:**\n\n" +
"- **Gesamtumsatz:** %.2f EUR\n" +
"- **Aufträge:** %d",
customer, revenue, jobCount);
} else {
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
int rank = 1;
for (var entry : topCustomers) {
sb.append(String.format("%d. **%s:** %.2f EUR\n",
rank++,
entry.getKey() != null ? entry.getKey() : "Unbekannt",
entry.getValue()));
}
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
yield sb.toString();
}
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
yield sb.toString();
}
case "trend" -> {
int year = Year.now().getValue();
var monthly = statisticsService.getMonthlyJobCounts(year);
var monthly = customer != null
? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
: statisticsService.getMonthlyJobCounts(year);
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " +
"Die Verteilung ist im Diagramm ersichtlich.", year, total);
String title = customer != null
? String.format("**Monatstrend %d für %s:**", year, customer)
: String.format("**Monatstrend %d:**", year);
yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " +
"Die Verteilung ist im Diagramm ersichtlich.", title, total);
}
case "tasks" -> {
var taskStats = statisticsService.getTaskCompletionStats();
@@ -363,13 +734,24 @@ public class AiStatisticsService {
total, completed, rate, taskStats.getOrDefault("pending", 0L));
}
default -> {
yield String.format("**Übersicht:**\n\n" +
"- **Aufträge gesamt:** %d\n" +
"- **Abschlussrate:** %.1f%%\n" +
"- **Gesamtumsatz:** %.2f EUR",
statisticsService.getTotalJobCount(),
statisticsService.getCompletionRate(),
statisticsService.getTotalRevenue());
if (customer != null) {
yield String.format("**Übersicht für %s:**\n\n" +
"- **Aufträge gesamt:** %d\n" +
"- **Abschlussrate:** %.1f%%\n" +
"- **Umsatz:** %.2f EUR",
customer,
statisticsService.getTotalJobCountForCustomer(customer),
statisticsService.getCompletionRateForCustomer(customer),
statisticsService.getTotalRevenueForCustomer(customer));
} else {
yield String.format("**Übersicht:**\n\n" +
"- **Aufträge gesamt:** %d\n" +
"- **Abschlussrate:** %.1f%%\n" +
"- **Gesamtumsatz:** %.2f EUR",
statisticsService.getTotalJobCount(),
statisticsService.getCompletionRate(),
statisticsService.getTotalRevenue());
}
}
};
}

View File

@@ -106,16 +106,23 @@ public class LlmRestClient {
}
private String extractContent(String response) {
if (response == null) {
if (response == null || response.isBlank()) {
log.warn("LLM returned null or blank response");
return null;
}
try {
JsonNode root = objectMapper.readTree(response);
JsonNode choices = root.path("choices");
if (choices.isArray() && !choices.isEmpty()) {
return choices.get(0).path("message").path("content").asText();
String content = choices.get(0).path("message").path("content").asText();
// asText() returns empty string for null/missing nodes - treat as null
if (content == null || content.isBlank()) {
log.warn("LLM response content is empty");
return null;
}
return content;
}
log.warn("Unexpected response structure: {}", response);
log.warn("Unexpected response structure (no choices): {}", response);
return null;
} catch (Exception e) {
log.error("Error parsing LLM response: {}", e.getMessage());

View File

@@ -262,7 +262,11 @@ public class StatisticsView extends VerticalLayout {
// Text Response
Div textDiv = new Div();
textDiv.getStyle().set("margin-top", "var(--lumo-space-s)");
textDiv.getElement().setProperty("innerHTML", formatMarkdown(response.textResponse()));
String responseText = response.textResponse();
if (responseText == null || responseText.isBlank()) {
responseText = "*Die Statistikdaten wurden erfolgreich abgerufen und werden im Diagramm angezeigt.*";
}
textDiv.getElement().setProperty("innerHTML", formatMarkdown(responseText));
bubble.add(textDiv);
// Chart wenn vorhanden
@@ -505,7 +509,7 @@ public class StatisticsView extends VerticalLayout {
}
}
""";
case "doughnut", "pie" -> """
case "doughnut" -> """
{
responsive: true,
maintainAspectRatio: false,
@@ -535,6 +539,118 @@ public class StatisticsView extends VerticalLayout {
}
}
""";
case "pie" -> """
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
}
},
animation: {
animateRotate: true,
animateScale: true,
duration: 1000,
easing: 'easeOutQuart'
}
}
""";
case "radar" -> """
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
}
},
scales: {
r: {
beginAtZero: true,
grid: {
color: 'rgba(0,0,0,0.1)'
},
pointLabels: {
font: { size: 12 }
}
}
},
elements: {
line: {
borderWidth: 2
},
point: {
radius: 4,
hoverRadius: 6
}
},
animation: {
duration: 1000,
easing: 'easeOutQuart'
}
}
""";
case "polarArea" -> """
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
}
},
scales: {
r: {
beginAtZero: true,
grid: {
color: 'rgba(0,0,0,0.1)'
}
}
},
animation: {
animateRotate: true,
animateScale: true,
duration: 1000,
easing: 'easeOutQuart'
}
}
""";
default -> """
{
responsive: true,

View File

@@ -65,6 +65,12 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
*/
List<Job> findByCustomerSelection(String customerSelection);
/**
* Findet Aufträge anhand einer Kundenauswahl (case-insensitive)
*/
@Query("{ 'customerSelection': { $regex: ?0, $options: 'i' } }")
List<Job> findByCustomerSelectionIgnoreCase(String customerSelection);
/**
* Prüft, ob eine Auftragsnummer bereits existiert
*/

View File

@@ -16,6 +16,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Service for job statistics and aggregations.
@@ -142,10 +143,29 @@ public class JobStatisticsService {
}
/**
* Get jobs by customer selection.
* Get jobs by customer selection (case-insensitive, flexible matching).
*/
public List<Job> getJobsByCustomer(String customer) {
return jobRepository.findByCustomerSelection(customer);
if (customer == null || customer.isBlank()) {
return List.of();
}
// Trim and escape regex special characters for MongoDB
String trimmedCustomer = customer.trim();
String escapedCustomer = trimmedCustomer.replaceAll("([.^$*+?()\\[\\]{}|\\\\])", "\\\\$1");
// First try exact match (with optional whitespace)
String exactRegex = "^\\s*" + escapedCustomer + "\\s*$";
List<Job> jobs = jobRepository.findByCustomerSelectionIgnoreCase(exactRegex);
log.debug("getJobsByCustomer('{}') - exact regex: '{}' - found {} jobs", customer, exactRegex, jobs.size());
// If no exact match, try partial match (customer name contains the search term)
if (jobs.isEmpty()) {
String containsRegex = ".*" + escapedCustomer + ".*";
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, jobs.size());
}
return jobs;
}
/**
@@ -200,4 +220,227 @@ public class JobStatisticsService {
public List<Job> getLatestJobs(int limit) {
return jobRepository.findLatestJobs().stream().limit(limit).toList();
}
// ==================== Filtered Statistics Methods ====================
/**
* Get all available customer names for autocomplete/filtering.
*/
public List<String> getAllCustomerNames() {
return jobRepository.findAll().stream()
.map(Job::getCustomerSelection)
.filter(c -> c != null && !c.isBlank())
.distinct()
.sorted()
.toList();
}
/**
* Get job counts by status filtered by customer.
*/
public Map<JobStatus, Long> getJobCountsByStatusForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer);
Map<JobStatus, Long> counts = new EnumMap<>(JobStatus.class);
for (JobStatus status : JobStatus.values()) {
counts.put(status, 0L);
}
for (Job job : customerJobs) {
counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L);
}
return counts;
}
/**
* Get total job count for a customer.
*/
public long getTotalJobCountForCustomer(String customer) {
return getJobsByCustomer(customer).size();
}
/**
* Get total revenue for a customer.
*/
public BigDecimal getTotalRevenueForCustomer(String customer) {
return getJobsByCustomer(customer).stream()
.map(Job::getPrice)
.filter(price -> price != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* Get completion rate for a customer.
*/
public double getCompletionRateForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer);
if (customerJobs.isEmpty()) {
return 0.0;
}
long completed = customerJobs.stream()
.filter(j -> j.getStatus() == JobStatus.COMPLETED)
.count();
return (double) completed / customerJobs.size() * 100.0;
}
/**
* Get monthly job counts for a customer in a specific year.
*/
public Map<Month, Long> getMonthlyJobCountsForCustomer(int year, String customer) {
Map<Month, Long> monthlyCounts = new LinkedHashMap<>();
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
List<Job> customerJobs = getJobsByCustomer(customer).stream()
.filter(j -> j.getCreatedAt() != null
&& !j.getCreatedAt().isBefore(yearStart)
&& !j.getCreatedAt().isAfter(yearEnd))
.toList();
// Initialize all months with 0
for (Month month : Month.values()) {
monthlyCounts.put(month, 0L);
}
// Count jobs per month
for (Job job : customerJobs) {
Month month = job.getCreatedAt().getMonth();
monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L);
}
return monthlyCounts;
}
/**
* Get jobs filtered by customer and optionally by status.
*/
public List<Job> getJobsByCustomerAndStatus(String customer, JobStatus status) {
List<Job> customerJobs = getJobsByCustomer(customer);
if (status == null) {
return customerJobs;
}
return customerJobs.stream()
.filter(j -> j.getStatus() == status)
.toList();
}
/**
* Find best matching customer name from query (fuzzy matching).
*/
public String findMatchingCustomer(String query) {
if (query == null || query.isBlank()) {
return null;
}
String lowerQuery = query.toLowerCase();
List<String> allCustomers = getAllCustomerNames();
log.debug("findMatchingCustomer - Query: '{}', Available customers: {}", query, allCustomers);
// First: exact match (case insensitive)
for (String customer : allCustomers) {
if (customer.equalsIgnoreCase(query)) {
log.debug("findMatchingCustomer - Exact match found: '{}'", customer);
return customer;
}
}
// Second: check if query contains a customer name
for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase();
if (lowerQuery.contains(lowerCustomer)) {
log.debug("findMatchingCustomer - Query contains customer: '{}'", customer);
return customer;
}
}
// Third: extract potential customer name from query and check if it matches a customer
// This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
String extractedName = extractCustomerNameFromQuery(query);
if (extractedName != null) {
String lowerExtracted = extractedName.toLowerCase();
for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase();
// Check if customer name starts with the extracted name, or contains it
if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) {
log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, customer);
return customer;
}
}
}
// Fourth: word match (any significant word from the query matches customer name)
String[] queryWords = lowerQuery.split("\\s+");
for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase();
for (String word : queryWords) {
// Skip common words and short words
if (word.length() >= 4 && !isCommonWord(word) && lowerCustomer.contains(word)) {
log.debug("findMatchingCustomer - Word match found: '{}' in '{}'", word, customer);
return customer;
}
}
}
log.debug("findMatchingCustomer - No match found for query: '{}'", query);
return null;
}
/**
* Extract potential customer name from a query string.
* Looks for patterns like "firma X", "kunde X", "für X", etc.
*/
private String extractCustomerNameFromQuery(String query) {
String lowerQuery = query.toLowerCase();
// Patterns that typically precede a customer name
String[] patterns = {
"firma ", "kunde ", "kunden ", "unternehmen ",
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
"der firma ", "des kunden ", "bei "
};
for (String pattern : patterns) {
int idx = lowerQuery.indexOf(pattern);
if (idx >= 0) {
// Extract everything after the pattern
int startIdx = idx + pattern.length();
String afterPattern = query.substring(startIdx).trim();
// Remove common trailing words
afterPattern = removeTrailingCommonWords(afterPattern);
if (!afterPattern.isBlank()) {
log.debug("extractCustomerNameFromQuery - Found potential name: '{}'", afterPattern);
return afterPattern;
}
}
}
return null;
}
/**
* Remove common trailing words from extracted customer name.
*/
private String removeTrailingCommonWords(String text) {
String[] trailingPatterns = {
" an$", " anzeigen$", " zeigen$", " auflisten$", " liste$",
" status$", " mit status$", " die$", " der$", " das$"
};
String result = text;
for (String pattern : trailingPatterns) {
result = result.replaceAll("(?i)" + pattern, "").trim();
}
return result;
}
/**
* Check if a word is a common German/English word that should be ignored in matching.
*/
private boolean isCommonWord(String word) {
return Set.of(
"zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und",
"firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the"
).contains(word.toLowerCase());
}
}