194 lines
9.3 KiB
Java
194 lines
9.3 KiB
Java
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;
|
|
}
|
|
}
|