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 # ===========================================