package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.goldenchart.dto.LlmTestResponse; import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse; import com.goldenchart.service.LlmSettingsResolver.ResolvedLlmSettings; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import java.time.Duration; /** * 전략 평가 AI 검증 — 챗봇 MLX LLM(OpenAI 호환) 프록시. */ @Service @RequiredArgsConstructor @Slf4j public class StrategyEvaluationLlmService { private static final String SYSTEM_PROMPT = """ 당신은 GoldenChart 전략 평가(Ta4j Rule) 검증 전문가입니다. 사용자 JSON에는 선택 봉 시점의 조건 평가, 차트 시그널, 전략 DSL, llmGuidance가 포함됩니다. ta4jDiagnostics는 서버 Ta4j 재평가 결과(frontend vs backend 비교)입니다. 【중요 — 오판 방지】 - evaluation.buyConditions/sellConditions 각 항목의 dslCondition·dslExpression·evaluationSemantics를 판단의 1차 근거로 사용하세요. UI용 thresholdLabel(예: "> 20")은 컨텍스트에 없습니다. - CROSS_UP/CROSS_DOWN은 GT/LT(수준 비교)와 다릅니다. 교차는 직전봉·현재봉 2봉 이벤트입니다. 현재값>임계값인데 CROSS_UP 미충족은 정상일 수 있습니다(이미 돌파 후 유지). - matchRate는 leaf 조건 satisfied=true 비율입니다. 0%%는 leaf 전부 미충족이지, Rule↔시그널 불일치를 뜻하지 않습니다. - deterministicHints.signalMatchesOverallRule이 "대체로 일치"이면 overall Rule과 차트 시그널은 일치합니다. 다음을 한국어로 분석하세요: 1) dslExpression·evaluationSemantics 기준으로 satisfied·overallEntryMet/overallExitMet 논리 일치 2) ta4jDiagnostics.reEvaluation·entryRule/exitRule.satisfiedAtEvalIndex 와 evaluation 일치 3) 차트 시그널(selectedBarSignals, barSignalHighlight)과 overall Rule 일치 4) 실제 불일치만 "오류 의심" — CROSS 조건의 정상 미충족은 "정상" 출력 형식(마크다운): ## 종합 판정 (정상 / 주의 / 오류 의심 — 한 줄 요약) ## 조건 평가 검증 (매수·매도 각각 bullet — dslExpression 인용) ## 차트 시그널 검증 (선택 봉 시그널 vs Rule 결과) ## 결론 및 권장 조치 (1~3문장) """; private static final String TEST_USER_PROMPT = "ping — 연결 테스트입니다. 한 문장으로 응답해 주세요."; private final WebClient.Builder webClientBuilder; private final ObjectMapper objectMapper; private final LlmSettingsResolver llmSettingsResolver; private final StrategyEvaluationTa4jDiagnosticsService ta4jDiagnosticsService; public StrategyEvaluationAiVerifyResponse verify(JsonNode context, Long userId, String deviceId) throws Exception { ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId); ensureEnabled(cfg); if (context == null || context.isNull()) { throw new IllegalArgumentException("context 가 필요합니다."); } ObjectNode enrichedContext = ta4jDiagnosticsService.enrichContext(context, userId, deviceId); JsonNode ta4jDiagnostics = enrichedContext.path("ta4jDiagnostics"); long started = System.currentTimeMillis(); ObjectNode body = buildChatBody(cfg, SYSTEM_PROMPT, "전략 평가 검증 데이터(JSON):\n" + objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(enrichedContext)); String responseJson = postChat(cfg, body); JsonNode root = objectMapper.readTree(responseJson); String content = extractAssistantContent(root); if (content == null || content.isBlank()) { throw new IllegalStateException("LLM 응답에서 분석 텍스트를 찾을 수 없습니다."); } return StrategyEvaluationAiVerifyResponse.builder() .analysis(content.trim()) .model(root.path("model").asText(cfg.model())) .latencyMs(System.currentTimeMillis() - started) .ta4jDiagnostics(ta4jDiagnostics.isMissingNode() || ta4jDiagnostics.isNull() ? null : ta4jDiagnostics) .build(); } public LlmTestResponse testConnection(Long userId, String deviceId) { ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId); ensureEnabled(cfg); long started = System.currentTimeMillis(); try { ObjectNode body = buildChatBody(cfg, "You are a helpful assistant.", TEST_USER_PROMPT); body.put("max_tokens", 32); String responseJson = postChat(cfg, body); JsonNode root = objectMapper.readTree(responseJson); String content = extractAssistantContent(root); if (content == null || content.isBlank()) { return LlmTestResponse.builder() .ok(false) .message("LLM 응답에서 텍스트를 찾을 수 없습니다.") .model(root.path("model").asText(cfg.model())) .latencyMs(System.currentTimeMillis() - started) .build(); } return LlmTestResponse.builder() .ok(true) .message("연결 성공: " + content.trim()) .model(root.path("model").asText(cfg.model())) .latencyMs(System.currentTimeMillis() - started) .build(); } catch (IllegalStateException e) { return LlmTestResponse.builder() .ok(false) .message(e.getMessage()) .model(cfg.model()) .latencyMs(System.currentTimeMillis() - started) .build(); } catch (Exception e) { log.warn("[StrategyEvaluationLlm] 연결 테스트 실패: {}", e.getMessage()); return LlmTestResponse.builder() .ok(false) .message("연결 테스트 실패: " + e.getMessage()) .model(cfg.model()) .latencyMs(System.currentTimeMillis() - started) .build(); } } private void ensureEnabled(ResolvedLlmSettings cfg) { if (!cfg.enabled()) { throw new IllegalStateException("LLM AI 검증이 비활성화되어 있습니다. 설정 > LLM에서 사용을 켜 주세요."); } } private ObjectNode buildChatBody(ResolvedLlmSettings cfg, String systemPrompt, String userPrompt) { ObjectNode body = objectMapper.createObjectNode(); body.put("model", cfg.model()); body.put("max_tokens", cfg.maxTokens()); body.put("temperature", cfg.temperature()); ArrayNode messages = body.putArray("messages"); messages.addObject().put("role", "system").put("content", systemPrompt); messages.addObject().put("role", "user").put("content", userPrompt); return body; } private String postChat(ResolvedLlmSettings cfg, ObjectNode body) { WebClient client = webClientBuilder.baseUrl(cfg.baseUrl()).build(); try { String responseJson = client.post() .uri(cfg.chatPath()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .bodyValue(body) .retrieve() .bodyToMono(String.class) .block(Duration.ofMillis(cfg.timeoutMs())); if (responseJson == null || responseJson.isBlank()) { throw new IllegalStateException("LLM 응답이 비어 있습니다."); } return responseJson; } catch (WebClientResponseException e) { log.warn("[StrategyEvaluationLlm] LLM HTTP {} — {}", e.getStatusCode(), e.getResponseBodyAsString()); throw new IllegalStateException("LLM 서버 응답 오류: " + e.getStatusCode().value()); } catch (IllegalStateException e) { throw e; } catch (Exception e) { log.warn("[StrategyEvaluationLlm] LLM 연결 실패: {}", e.getMessage()); throw new IllegalStateException("LLM 서버에 연결할 수 없습니다. (" + cfg.baseUrl() + cfg.chatPath() + ")"); } } private static String extractAssistantContent(JsonNode root) { JsonNode choices = root.path("choices"); if (!choices.isArray() || choices.isEmpty()) return null; JsonNode message = choices.get(0).path("message"); if (message.hasNonNull("content")) { return message.path("content").asText(null); } return null; } }