ai 전략 기능 추가
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.StrategyAgentChatRequest;
|
||||
import com.goldenchart.dto.StrategyAgentChatResponse;
|
||||
import com.goldenchart.service.StrategyAgentChatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL 생성/수정.
|
||||
*
|
||||
* POST /api/strategy/agent/chat
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/strategy/agent")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyAgentController {
|
||||
|
||||
private final StrategyAgentChatService agentService;
|
||||
|
||||
@PostMapping("/chat")
|
||||
public ResponseEntity<?> chat(
|
||||
@RequestBody StrategyAgentChatRequest body,
|
||||
@RequestHeader Map<String, String> headers,
|
||||
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
|
||||
TradingControllerSupport.requireRegisteredUser(headers);
|
||||
try {
|
||||
Long userId = TradingControllerSupport.parseUserId(headers);
|
||||
StrategyAgentChatResponse res = agentService.chat(body, userId, deviceId);
|
||||
return ResponseEntity.ok(res);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(503).body(Map.of("message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] chat 오류: {}", e.getMessage());
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("message", "AI 전략 생성 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.LlmModelsRequest;
|
||||
import com.goldenchart.dto.LlmModelsResponse;
|
||||
import com.goldenchart.dto.LlmTestResponse;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
|
||||
import com.goldenchart.service.StrategyAgentChatService;
|
||||
import com.goldenchart.service.StrategyEvaluationAiVerifyJobService;
|
||||
import com.goldenchart.service.StrategyEvaluationLlmService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -28,6 +31,7 @@ public class StrategyEvaluationController {
|
||||
|
||||
private final StrategyEvaluationLlmService llmService;
|
||||
private final StrategyEvaluationAiVerifyJobService aiVerifyJobService;
|
||||
private final StrategyAgentChatService agentChatService;
|
||||
|
||||
@PostMapping("/ai-verify/jobs")
|
||||
public ResponseEntity<?> startAiVerifyJob(
|
||||
@@ -89,6 +93,20 @@ public class StrategyEvaluationController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/llm-models")
|
||||
public ResponseEntity<?> llmModels(
|
||||
@RequestBody(required = false) LlmModelsRequest body,
|
||||
@RequestHeader Map<String, String> headers) {
|
||||
TradingControllerSupport.requireRegisteredUser(headers);
|
||||
if (body == null || body.getHost() == null || body.getHost().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "호스트가 필요합니다."));
|
||||
}
|
||||
LlmModelsResponse res = agentChatService.listModels(
|
||||
body.getHost(), body.getPort(), body.getUseHttps(),
|
||||
body.getModelsPath(), body.getTimeoutMs());
|
||||
return ResponseEntity.ok(res);
|
||||
}
|
||||
|
||||
@PostMapping("/llm-test")
|
||||
public ResponseEntity<?> llmTest(
|
||||
@RequestHeader Map<String, String> headers,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** LLM 모델 목록 조회 — 저장 전 입력값으로 /v1/models 프록시 */
|
||||
@Data
|
||||
public class LlmModelsRequest {
|
||||
private String host;
|
||||
private Integer port;
|
||||
private Boolean useHttps;
|
||||
/** OpenAI 호환 모델 목록 경로 (기본 /v1/models) */
|
||||
private String modelsPath;
|
||||
private Long timeoutMs;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** LLM 모델 목록 조회 결과 */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LlmModelsResponse {
|
||||
private boolean ok;
|
||||
private List<String> models;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** AI 전략 에이전트 — 멀티턴 대화 요청 */
|
||||
@Data
|
||||
public class StrategyAgentChatRequest {
|
||||
|
||||
/** user/assistant 대화 히스토리 (system 제외) */
|
||||
private List<ChatMessage> messages;
|
||||
|
||||
/** 현재 편집기 상태 컨텍스트 (buy/sell DSL, candleType, 지표 설정 등) */
|
||||
private JsonNode context;
|
||||
|
||||
@Data
|
||||
public static class ChatMessage {
|
||||
private String role;
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** AI 전략 에이전트 — 응답 */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StrategyAgentChatResponse {
|
||||
/** 사용자에게 보여줄 한국어 설명 (json 블록 제거됨) */
|
||||
private String reply;
|
||||
/** "ask"(추가 질문) | "build"(전략 생성/수정) */
|
||||
private String action;
|
||||
/** build일 때 매수 조건 LogicNode 트리 (없으면 null) */
|
||||
private JsonNode buyCondition;
|
||||
/** build일 때 매도 조건 LogicNode 트리 (없으면 null) */
|
||||
private JsonNode sellCondition;
|
||||
private String model;
|
||||
private long latencyMs;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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.LlmModelsResponse;
|
||||
import com.goldenchart.dto.StrategyAgentChatRequest;
|
||||
import com.goldenchart.dto.StrategyAgentChatResponse;
|
||||
import com.goldenchart.service.LlmSettingsResolver.ResolvedLlmSettings;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL(LogicNode 트리)을 생성/수정한다.
|
||||
*
|
||||
* <p>설정에 저장된 LLM(OpenAI 호환)을 사용하며, 정보가 부족하면 되묻고(action=ask),
|
||||
* 충분하면 전략을 생성/수정한다(action=build).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyAgentChatService {
|
||||
|
||||
/** ```json ... ``` 또는 ``` ... ``` 코드펜스 추출 */
|
||||
private static final Pattern JSON_FENCE =
|
||||
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final String SYSTEM_PROMPT = """
|
||||
당신은 GoldenChart 전략편집기의 AI 전략 에이전트입니다.
|
||||
사용자의 자연어 요청을 받아 매매 시그널 전략을 GoldenChart 전략 DSL(JSON)로 구성/수정합니다.
|
||||
|
||||
【행동 규칙 — 멀티턴 에이전트】
|
||||
- 요청이 모호하거나 핵심 정보가 빠졌으면, 전략을 만들지 말고 한국어로 짧고 구체적으로 되물으세요. (action=ask)
|
||||
핵심 정보 예: 매수/매도 구분, 지표 기간(예: 일목 9/26/52), 임계값(예: RSI 30), 캔들 종류(예: 5분봉),
|
||||
"N봉 이내" 같은 윈도우 조건 여부.
|
||||
- 정보가 충분하면 전략을 생성하거나, 이미 전략이 있으면 그것을 수정합니다. (action=build)
|
||||
- 현재 편집기 상태(【현재 편집기 상태】의 buyCondition/sellCondition)가 비어있지 않으면, 처음부터 새로
|
||||
만들지 말고 기존 DSL을 최소 변경으로 수정하세요.
|
||||
- 한 번에 한두 가지만 묻고, 합리적인 기본값이 있으면 가정하고 진행하되 가정한 내용을 설명에 명시하세요.
|
||||
|
||||
【출력 형식】
|
||||
- 항상 한국어 설명을 먼저 작성합니다.
|
||||
- action=build일 때만, 설명 뒤에 단 하나의 ```json 코드블록을 추가합니다. 형식:
|
||||
```json
|
||||
{ "action": "build", "buyCondition": <LogicNode 또는 null>, "sellCondition": <LogicNode 또는 null> }
|
||||
```
|
||||
- action=ask일 때는 json 코드블록을 절대 넣지 마세요(질문만).
|
||||
- 매수 전략만 요청되면 sellCondition은 null로 둡니다(반대도 동일).
|
||||
|
||||
【LogicNode 스키마】
|
||||
- 노드: { "id": string, "type": "AND"|"OR"|"NOT"|"CONDITION"|"TIMEFRAME", "children": [LogicNode], "condition": ConditionDSL }
|
||||
- AND/OR: children 배열을 결합. NOT: children[0] 부정. CONDITION: leaf 조건(condition 필수).
|
||||
- TIMEFRAME: { "type":"TIMEFRAME", "candleType":"5m", "children":[...] } — 하위 조건을 해당 분봉으로 평가.
|
||||
- id는 고유 문자열(예: "n1","n2"...).
|
||||
- ConditionDSL: {
|
||||
"indicatorType": string, "conditionType": string,
|
||||
"leftField": string, "rightField": string,
|
||||
"period": number?, "leftPeriod": number?, "rightPeriod": number?,
|
||||
"candleRangeMode": "CURRENT"|"EXISTS_IN"|"NOT_EXISTS_IN"?, "candleRange": number?,
|
||||
"valuePeriodOverride": boolean?, "thresholdOverride": boolean?, "targetValue": number?
|
||||
}
|
||||
|
||||
【conditionType】
|
||||
- 공통: GT, LT, GTE, LTE, EQ, NEQ, CROSS_UP, CROSS_DOWN, SLOPE_UP, SLOPE_DOWN, DIFF_GT, DIFF_LT, HOLD_N_DAYS, LOWEST_LTE, LOWEST_GTE, TREND_LINE_CROSS_UP, TREND_LINE_CROSS_DOWN
|
||||
- 일목 전용(indicatorType=ICHIMOKU): ABOVE_CLOUD, BELOW_CLOUD, IN_CLOUD, CLOUD_BREAK_UP, CLOUD_BREAK_DOWN, SPAN1_GT_SPAN2, SPAN1_LT_SPAN2, SPAN1_CROSS_UP_SPAN2, SPAN1_CROSS_DOWN_SPAN2, LAGGING_GT_PRICE, LAGGING_LT_PRICE, LAGGING_ABOVE_PRIOR_CLOUD, CHIKOU_CROSS_ABOVE_PRIOR_CLOUD
|
||||
- 거래량(indicatorType=VOLUME): VOLUME_GT_MA_RATIO
|
||||
- CROSS_UP/CROSS_DOWN은 "직전봉→현재봉" 교차 이벤트이며 단순 수준 비교(GT/LT)와 다릅니다.
|
||||
|
||||
【indicatorType 과 주요 필드(leftField/rightField)】
|
||||
- 가격/거래량: CLOSE_PRICE, OPEN_PRICE, HIGH_PRICE, LOW_PRICE, VOLUME_VALUE, VOLUME_MA, NONE
|
||||
- RSI: leftField RSI_VALUE / rightField HL_OVER|HL_MID|HL_UNDER 또는 K_{숫자}
|
||||
- MACD: MACD_LINE, SIGNAL_LINE, HISTOGRAM / rightField SIGNAL_LINE 또는 HL_MID(0선)
|
||||
- STOCHASTIC: STOCH_K, STOCH_D / rightField STOCH_D, HL_OVER|HL_UNDER, K_{숫자}
|
||||
- CCI: CCI_VALUE / OVERBOUGHT_100, OVERSOLD_NEG100, K_{숫자}
|
||||
- ADX: ADX_VALUE / ADX_25, K_{숫자}; DMI: PDI, MDI (서로 교차)
|
||||
- MA: MA1..MA11 (period1..11) — SMA 슬롯; EMA: EMA1..EMA11
|
||||
- BOLLINGER: UPPER_BAND, MIDDLE_BAND, LOWER_BAND (가격과 비교)
|
||||
- DONCHIAN: DC_UPPER_{9|10|20|55}, DC_LOWER_{...}, DC_MIDDLE_20
|
||||
- ICHIMOKU: CONVERSION_LINE(전환선), BASE_LINE(기준선), LEADING_SPAN1, LEADING_SPAN2, LAGGING_SPAN(후행스팬)
|
||||
- 임계값 직접지정: rightField "K_{숫자}", thresholdOverride=true, targetValue=숫자
|
||||
|
||||
【기간 지정】
|
||||
- 전략 전용 기간을 쓰려면 valuePeriodOverride=true 와 period 를 함께 지정(예: RSI 9 → period:9).
|
||||
- 일목 기본 9/26/52는 indicatorType=ICHIMOKU의 conditionType만으로 충분(별도 period 불필요).
|
||||
|
||||
【윈도우(N봉 이내)】
|
||||
- "최근 N봉 내 1회라도" → candleRangeMode:"EXISTS_IN", candleRange:N (N>=2)
|
||||
- "최근 N봉 내 한 번도 없음" → "NOT_EXISTS_IN"
|
||||
- 미지정 시 현재 봉만 평가(CURRENT).
|
||||
|
||||
【예시 1 — 정보 부족(ask)】
|
||||
사용자: "골든크로스로 매수해줘"
|
||||
응답: "어떤 이동평균선 조합으로 골든크로스를 잡을까요? 예를 들어 단기 5 / 장기 20처럼 기간을 알려주세요. 캔들은 어떤 분봉인가요?"
|
||||
(json 블록 없음)
|
||||
|
||||
【예시 2 — 일목 전환선이 기준선 상향돌파 매수(build)】
|
||||
응답: "일목균형표 전환선이 기준선을 상향돌파할 때 매수하도록 구성했습니다."
|
||||
```json
|
||||
{ "action": "build",
|
||||
"buyCondition": { "id":"n1","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"CROSS_UP","leftField":"CONVERSION_LINE","rightField":"BASE_LINE","candleRangeMode":"CURRENT"} },
|
||||
"sellCondition": null }
|
||||
```
|
||||
|
||||
【예시 3 — 후행스팬이 26봉 전 캔들 상향돌파 AND 전환선이 기준선 상향돌파(build)】
|
||||
응답: "후행스팬의 가격 상향돌파와 전환선의 기준선 상향돌파를 모두 만족할 때 매수합니다."
|
||||
```json
|
||||
{ "action": "build",
|
||||
"buyCondition": { "id":"r","type":"AND","children":[
|
||||
{ "id":"a","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"LAGGING_GT_PRICE","leftField":"LAGGING_SPAN","rightField":"NONE","candleRangeMode":"CURRENT"} },
|
||||
{ "id":"b","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"CROSS_UP","leftField":"CONVERSION_LINE","rightField":"BASE_LINE","candleRangeMode":"CURRENT"} }
|
||||
] },
|
||||
"sellCondition": null }
|
||||
```
|
||||
""";
|
||||
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final LlmSettingsResolver llmSettingsResolver;
|
||||
|
||||
public StrategyAgentChatResponse chat(StrategyAgentChatRequest request, Long userId, String deviceId) throws Exception {
|
||||
ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId);
|
||||
if (!cfg.enabled()) {
|
||||
throw new IllegalStateException("LLM 사용이 비활성화되어 있습니다. 설정 > LLM에서 사용을 켜 주세요.");
|
||||
}
|
||||
if (request == null || request.getMessages() == null || request.getMessages().isEmpty()) {
|
||||
throw new IllegalArgumentException("messages 가 필요합니다.");
|
||||
}
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
ObjectNode body = buildChatBody(cfg, request);
|
||||
String responseJson = postChat(cfg, body);
|
||||
JsonNode root = objectMapper.readTree(responseJson);
|
||||
String content = extractAssistantContent(root);
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new IllegalStateException("LLM 응답에서 텍스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return parseAssistantContent(content, root.path("model").asText(cfg.model()),
|
||||
System.currentTimeMillis() - started);
|
||||
}
|
||||
|
||||
/** 저장 전 입력값(host/port/useHttps/modelsPath)으로 /v1/models 프록시 조회 */
|
||||
public LlmModelsResponse listModels(String host, Integer port, Boolean useHttps,
|
||||
String modelsPath, Long timeoutMs) {
|
||||
int p = port != null ? Math.max(1, Math.min(65535, port)) : 16000;
|
||||
boolean https = useHttps != null && useHttps;
|
||||
String baseUrl = LlmSettingsResolver.buildBaseUrl(host, p, https);
|
||||
String path = (modelsPath == null || modelsPath.isBlank()) ? "/v1/models"
|
||||
: (modelsPath.startsWith("/") ? modelsPath.trim() : "/" + modelsPath.trim());
|
||||
long timeout = timeoutMs != null ? Math.max(2_000L, Math.min(60_000L, timeoutMs)) : 15_000L;
|
||||
|
||||
try {
|
||||
WebClient client = webClientBuilder.baseUrl(baseUrl).build();
|
||||
String json = client.get()
|
||||
.uri(path)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeout));
|
||||
if (json == null || json.isBlank()) {
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 목록 응답이 비어 있습니다.").build();
|
||||
}
|
||||
List<String> models = parseModelIds(objectMapper.readTree(json));
|
||||
if (models.isEmpty()) {
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 목록을 찾을 수 없습니다. (" + baseUrl + path + ")").build();
|
||||
}
|
||||
return LlmModelsResponse.builder().ok(true).models(models)
|
||||
.message(models.size() + "개 모델").build();
|
||||
} catch (WebClientResponseException e) {
|
||||
log.warn("[StrategyAgent] 모델 목록 HTTP {} — {}", e.getStatusCode(), e.getMessage());
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 서버 응답 오류: " + e.getStatusCode().value()).build();
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] 모델 목록 조회 실패: {}", e.getMessage());
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 서버에 연결할 수 없습니다. (" + baseUrl + path + ")").build();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseModelIds(JsonNode root) {
|
||||
List<String> out = new ArrayList<>();
|
||||
JsonNode data = root.path("data");
|
||||
if (data.isArray()) {
|
||||
for (JsonNode m : data) {
|
||||
String id = m.path("id").asText(null);
|
||||
if (id != null && !id.isBlank()) out.add(id);
|
||||
}
|
||||
} else if (root.path("models").isArray()) {
|
||||
// 일부 서버는 {"models":[{"name":...}]} 형태(예: Ollama)
|
||||
for (JsonNode m : root.path("models")) {
|
||||
String id = m.path("id").asText(m.path("name").asText(null));
|
||||
if (id != null && !id.isBlank()) out.add(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private ObjectNode buildChatBody(ResolvedLlmSettings cfg, StrategyAgentChatRequest request) throws Exception {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", cfg.model());
|
||||
// DSL 출력은 길 수 있어 최소 2048 토큰 확보
|
||||
body.put("max_tokens", Math.max(cfg.maxTokens(), 2048));
|
||||
body.put("temperature", cfg.temperature());
|
||||
|
||||
StringBuilder system = new StringBuilder(SYSTEM_PROMPT);
|
||||
if (request.getContext() != null && !request.getContext().isNull()) {
|
||||
system.append("\n\n【현재 편집기 상태】\n")
|
||||
.append(objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(request.getContext()));
|
||||
}
|
||||
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
messages.addObject().put("role", "system").put("content", system.toString());
|
||||
for (StrategyAgentChatRequest.ChatMessage m : request.getMessages()) {
|
||||
if (m == null || m.getContent() == null || m.getContent().isBlank()) continue;
|
||||
String role = "assistant".equals(m.getRole()) ? "assistant" : "user";
|
||||
messages.addObject().put("role", role).put("content", m.getContent());
|
||||
}
|
||||
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("[StrategyAgent] LLM HTTP {} — {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new IllegalStateException("LLM 서버 응답 오류: " + e.getStatusCode().value());
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] 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;
|
||||
}
|
||||
|
||||
/** 어시스턴트 content에서 설명과 ```json DSL 블록을 분리·파싱 */
|
||||
StrategyAgentChatResponse parseAssistantContent(String content, String model, long latencyMs) {
|
||||
String reply = content.trim();
|
||||
JsonNode buy = null;
|
||||
JsonNode sell = null;
|
||||
String action = "ask";
|
||||
|
||||
Matcher matcher = JSON_FENCE.matcher(content);
|
||||
String jsonBlock = null;
|
||||
while (matcher.find()) {
|
||||
String candidate = matcher.group(1).trim();
|
||||
if (candidate.startsWith("{") || candidate.startsWith("[")) {
|
||||
jsonBlock = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonBlock != null) {
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(jsonBlock);
|
||||
JsonNode buyNode = firstNonMissing(parsed, "buyCondition", "buy");
|
||||
JsonNode sellNode = firstNonMissing(parsed, "sellCondition", "sell");
|
||||
buy = nullIfEmpty(buyNode);
|
||||
sell = nullIfEmpty(sellNode);
|
||||
String parsedAction = parsed.path("action").asText(null);
|
||||
if (buy != null || sell != null) {
|
||||
action = "build";
|
||||
} else if (parsedAction != null) {
|
||||
action = parsedAction;
|
||||
}
|
||||
// 설명에서 json 코드펜스 제거
|
||||
reply = JSON_FENCE.matcher(content).replaceAll("").trim();
|
||||
if (reply.isEmpty()) {
|
||||
reply = action.equals("build") ? "요청하신 조건으로 전략을 구성했습니다." : content.trim();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] DSL JSON 파싱 실패: {}", e.getMessage());
|
||||
action = "ask";
|
||||
}
|
||||
}
|
||||
|
||||
return StrategyAgentChatResponse.builder()
|
||||
.reply(reply)
|
||||
.action(action)
|
||||
.buyCondition(buy)
|
||||
.sellCondition(sell)
|
||||
.model(model)
|
||||
.latencyMs(latencyMs)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static JsonNode firstNonMissing(JsonNode parent, String... keys) {
|
||||
for (String key : keys) {
|
||||
JsonNode node = parent.get(key);
|
||||
if (node != null && !node.isMissingNode()) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonNode nullIfEmpty(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) return null;
|
||||
if (node.isObject() && node.isEmpty()) return null;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user