diff --git a/pom.xml b/pom.xml
index 2274684..01bda3d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
de.assecutor.votianlt
votianlt
- 0.8.2
+ 0.8.4
jar
@@ -118,6 +118,12 @@
spring-boot-starter-mail
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
com.fasterxml.jackson.datatype
diff --git a/src/main/bundles/prod.bundle b/src/main/bundles/prod.bundle
index a3336fe..9921ad6 100644
Binary files a/src/main/bundles/prod.bundle and b/src/main/bundles/prod.bundle differ
diff --git a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
index 1e01f60..e730a98 100644
--- a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
+++ b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
@@ -5,6 +5,10 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+
/**
* Configuration for LLM integration via LM Studio.
* LM Studio provides an OpenAI-compatible API.
@@ -21,9 +25,71 @@ public class LlmConfig {
@PostConstruct
public void logConfig() {
- log.info("LLM Configuration initialized:");
- log.info(" Base URL: {}", baseUrl);
- log.info(" Model: {}", model);
+ log.info("=== LLM Configuration ===");
+ log.info("Base URL: {}", baseUrl);
+ log.info("Model: {}", model);
+ testConnection();
+ }
+
+ private void testConnection() {
+ log.info("Testing LLM connection to: {}", baseUrl);
+
+ // Test 1: Basic connectivity
+ testEndpoint(baseUrl + "/v1/models", "GET", null);
+
+ // Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal payload)
+ String testPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
+ log.info("Test payload (stream=false): {}", testPayload);
+ testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
+
+ // Test 3: Chat completions WITH streaming to compare behavior
+ String streamPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}";
+ log.info("Test payload (stream=true): {}", streamPayload);
+ testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload);
+ }
+
+ private void testEndpoint(String endpoint, String method, String payload) {
+ try {
+ log.info("Testing endpoint: {} {}", method, endpoint);
+ URL url = URI.create(endpoint).toURL();
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod(method);
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(10000);
+
+ if (payload != null) {
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/json");
+ try (var os = connection.getOutputStream()) {
+ os.write(payload.getBytes());
+ }
+ }
+
+ int responseCode = connection.getResponseCode();
+ String responseMessage = connection.getResponseMessage();
+
+ if (responseCode >= 200 && responseCode < 300) {
+ log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
+ } else {
+ // Read error body
+ String errorBody = "";
+ try (var is = connection.getErrorStream()) {
+ if (is != null) {
+ errorBody = new String(is.readAllBytes());
+ }
+ }
+ log.warn(" -> HTTP {} {} - {}", responseCode, responseMessage, errorBody);
+ }
+ connection.disconnect();
+ } catch (java.net.ConnectException e) {
+ log.error(" -> FAILED - Connection refused: {}", e.getMessage());
+ } catch (java.net.SocketTimeoutException e) {
+ log.error(" -> FAILED - Timeout: {}", e.getMessage());
+ } catch (java.net.UnknownHostException e) {
+ log.error(" -> FAILED - Unknown host: {}", e.getMessage());
+ } catch (Exception e) {
+ log.error(" -> FAILED: {} - {}", e.getClass().getSimpleName(), e.getMessage());
+ }
}
public String getBaseUrl() {
diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
index d2a611b..bb128dc 100644
--- a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
+++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
@@ -5,8 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.ai.chat.client.ChatClient;
-import org.springframework.ai.chat.model.ChatModel;
import org.springframework.stereotype.Service;
import java.time.Month;
@@ -17,21 +15,21 @@ import java.util.Map;
/**
* Service for AI-assisted statistics analysis with chart visualization.
- * Uses LM Studio via OpenAI-compatible API and local job statistics.
+ * Uses LM Studio via direct REST client (like aimailassistant) instead of Spring AI.
*/
@Service
@Slf4j
public class AiStatisticsService {
- private final ChatClient chatClient;
+ private final LlmRestClient llmClient;
private final JobStatisticsService statisticsService;
private final ObjectMapper objectMapper;
- public AiStatisticsService(ChatModel chatModel, JobStatisticsService statisticsService) {
- this.chatClient = ChatClient.builder(chatModel).build();
+ public AiStatisticsService(LlmRestClient llmClient, JobStatisticsService statisticsService) {
+ this.llmClient = llmClient;
this.statisticsService = statisticsService;
this.objectMapper = new ObjectMapper();
- log.info("AiStatisticsService initialized");
+ log.info("AiStatisticsService initialized with direct REST client");
}
/**
@@ -54,28 +52,35 @@ public class AiStatisticsService {
// Determine query type and prepare chart data
QueryAnalysis analysis = analyzeQueryType(userQuery);
+ log.debug("Query analysis - Type: {}, Chart: {}", analysis.queryType, analysis.chartType);
// Build prompt for LLM
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
- try {
- // Get LLM response
- String llmResponse = chatClient.prompt()
- .user(prompt)
- .call()
- .content();
+ // 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.
- log.info("LLM response received");
+ 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
- // Build chart data based on query type
- String chartType = analysis.chartType;
- String chartData = analysis.chartData;
+ 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).
+ """;
- return new StatisticsResponse(llmResponse, chartType, chartData);
+ // Call LLM via direct REST client (like aimailassistant)
+ String llmResponse = llmClient.chat(systemPrompt, prompt);
- } catch (Exception e) {
- log.error("Error calling LLM: {}", e.getMessage(), e);
- // Fallback: Return statistics without LLM analysis
+ if (llmResponse != null) {
+ 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");
return new StatisticsResponse(
buildFallbackResponse(analysis),
analysis.chartType,
@@ -305,23 +310,11 @@ public class AiStatisticsService {
}
private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) {
+ // User prompt contains only the context and question (system prompt is passed separately)
return String.format("""
- Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen.
- Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken.
-
%s
**Benutzerfrage:** %s
-
- 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).
""", statisticsContext, userQuery);
}
diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java
new file mode 100644
index 0000000..d827710
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java
@@ -0,0 +1,125 @@
+package de.assecutor.votianlt.ai.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Direct REST client for LM Studio API.
+ * Uses Spring WebClient like aimailassistant - bypasses Spring AI.
+ */
+@Component
+@Slf4j
+public class LlmRestClient {
+
+ private final WebClient webClient;
+ private final ObjectMapper objectMapper;
+ private final String model;
+
+ public LlmRestClient(
+ @Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl,
+ @Value("${spring.ai.openai.chat.options.model:local-model}") String model,
+ ObjectMapper objectMapper) {
+
+ this.webClient = WebClient.builder()
+ .baseUrl(baseUrl + "/v1/chat/completions")
+ .build();
+ this.model = model;
+ this.objectMapper = objectMapper;
+
+ log.info("LlmRestClient initialized - URL: {}/v1/chat/completions, Model: {}", baseUrl, model);
+ }
+
+ /**
+ * Send a chat completion request to LM Studio.
+ *
+ * @param systemPrompt System prompt for context
+ * @param userMessage User message/question
+ * @return LLM response text, or null on error
+ */
+ public String chat(String systemPrompt, String userMessage) {
+ return chat(systemPrompt, userMessage, 0.7, 2000);
+ }
+
+ /**
+ * Send a chat completion request to LM Studio with custom parameters.
+ *
+ * @param systemPrompt System prompt for context
+ * @param userMessage User message/question
+ * @param temperature Temperature for response randomness (0.0-1.0)
+ * @param maxTokens Maximum tokens in response
+ * @return LLM response text, or null on error
+ */
+ public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
+ try {
+ Map request = Map.of(
+ "model", model,
+ "messages", List.of(
+ Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
+ Map.of("role", "user", "content", userMessage)
+ ),
+ "temperature", temperature,
+ "max_tokens", maxTokens,
+ "stream", false // WICHTIG: Kein Streaming!
+ );
+
+ log.info("Sending request to LLM (model: {}, prompt length: {} chars)...",
+ model, userMessage.length());
+ long startTime = System.currentTimeMillis();
+
+ String response = webClient.post()
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(request)
+ .retrieve()
+ .bodyToMono(String.class)
+ .timeout(Duration.ofSeconds(120))
+ .block();
+
+ long duration = System.currentTimeMillis() - startTime;
+ log.info("LLM response received in {}ms", duration);
+ log.debug("Raw LLM response: {}", response);
+
+ return extractContent(response);
+
+ } catch (Exception e) {
+ log.error("Error calling LLM API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
+ if (log.isDebugEnabled()) {
+ log.debug("Full stack trace:", e);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Simple chat without system prompt.
+ */
+ public String chat(String userMessage) {
+ return chat(null, userMessage);
+ }
+
+ private String extractContent(String response) {
+ if (response == null) {
+ 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();
+ }
+ log.warn("Unexpected response structure: {}", response);
+ return null;
+ } catch (Exception e) {
+ log.error("Error parsing LLM response: {}", e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/src/main/resources/application-production.properties b/src/main/resources/application-production.properties
index a68e911..0c674c3 100644
--- a/src/main/resources/application-production.properties
+++ b/src/main/resources/application-production.properties
@@ -14,4 +14,18 @@ logging.level.de.assecutor.votianlt=INFO
logging.level.root=WARN
logging.file.name=logs/votianlt-production.log
logging.file.max-size=50MB
-logging.file.max-history=90
\ No newline at end of file
+logging.file.max-history=90
+
+# Debug logging for AI/LLM troubleshooting (can be disabled after debugging)
+logging.level.org.springframework.ai=DEBUG
+logging.level.org.springframework.web.client.RestTemplate=DEBUG
+logging.level.org.springframework.web.client.RestClient=DEBUG
+logging.level.org.apache.http=DEBUG
+logging.level.org.apache.http.wire=DEBUG
+logging.level.org.apache.http.headers=DEBUG
+# Java HTTP Client logging
+logging.level.jdk.httpclient=DEBUG
+logging.level.java.net.http=DEBUG
+# Spring HTTP logging
+logging.level.org.springframework.http.client=DEBUG
+logging.level.de.assecutor.votianlt.ai=DEBUG
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index d9ab0bf..46c59e5 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -113,6 +113,13 @@ spring.ai.openai.api-key=not-used
spring.ai.openai.chat.options.model=local-model
spring.ai.openai.chat.options.temperature=0.7
+# WICHTIG: Streaming deaktivieren - LM Studio/Docker können Streaming-Responses nicht korrekt handlen
+spring.ai.openai.chat.options.stream=false
+
+# Timeouts für OpenAI Client
+spring.ai.openai.connect-timeout=10s
+spring.ai.openai.read-timeout=120s
+
# ===========================================
# MCP Server Configuration
# ===========================================