From edac7540cde753be61d491c95f3b555de03a1cc6 Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 17 Jun 2026 15:37:23 +0900 Subject: [PATCH] =?UTF-8?q?llm=20=EB=8F=99=EC=9E=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/AiVerifyAsyncConfig.java | 30 +++ .../StrategyEvaluationController.java | 46 +++- ...egyEvaluationAiVerifyJobStartResponse.java | 16 ++ ...gyEvaluationAiVerifyJobStatusResponse.java | 20 ++ .../StrategyEvaluationAiVerifyJobRunner.java | 31 +++ .../StrategyEvaluationAiVerifyJobService.java | 112 +++++++++ .../service/StrategyEvaluationLlmService.java | 27 +- frontend/nginx-locations.inc | 27 ++ .../src/components/StrategyEvaluationPage.tsx | 125 ++++++++-- .../StrategyEvaluationAiVerifyModal.tsx | 9 +- frontend/src/styles/strategyEvaluation.css | 89 +++++++ frontend/src/utils/backendApi.ts | 46 +++- .../src/utils/strategyEvaluationAiVerify.ts | 151 ++++++++--- .../src/utils/strategyEvaluationLlmContext.ts | 235 ++++++++++++++++++ 14 files changed, 901 insertions(+), 63 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/config/AiVerifyAsyncConfig.java create mode 100644 backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStartResponse.java create mode 100644 backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStatusResponse.java create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobRunner.java create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobService.java create mode 100644 frontend/src/utils/strategyEvaluationLlmContext.ts diff --git a/backend/src/main/java/com/goldenchart/config/AiVerifyAsyncConfig.java b/backend/src/main/java/com/goldenchart/config/AiVerifyAsyncConfig.java new file mode 100644 index 0000000..27f2e17 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/AiVerifyAsyncConfig.java @@ -0,0 +1,30 @@ +package com.goldenchart.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 전략평가 AI 검증 — LLM·Ta4j 재평가를 HTTP 요청 스레드와 분리. + */ +@Configuration +public class AiVerifyAsyncConfig { + + @Bean(name = "aiVerifyExecutor") + public Executor aiVerifyExecutor( + @Value("${goldenchart.llm.ai-verify.pool-size:2}") int poolSize, + @Value("${goldenchart.llm.ai-verify.queue-capacity:8}") int queueCapacity) { + ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); + ex.setThreadNamePrefix("ai-verify-"); + ex.setCorePoolSize(Math.max(1, poolSize)); + ex.setMaxPoolSize(Math.max(1, poolSize)); + ex.setQueueCapacity(queueCapacity); + ex.setWaitForTasksToCompleteOnShutdown(true); + ex.setAwaitTerminationSeconds(120); + ex.initialize(); + return ex; + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java b/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java index 3c131ad..014faa9 100644 --- a/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java +++ b/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java @@ -1,8 +1,11 @@ package com.goldenchart.controller; 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.StrategyEvaluationAiVerifyJobService; import com.goldenchart.service.StrategyEvaluationLlmService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -13,7 +16,9 @@ import java.util.Map; /** * 전략 평가 부가 기능. * - * POST /api/strategy/evaluation/ai-verify — LLM 기반 평가·시그널 일치 검증 + * POST /api/strategy/evaluation/ai-verify/jobs — AI 검증 비동기 시작 (jobId 즉시 반환) + * GET /api/strategy/evaluation/ai-verify/jobs/{jobId} — job 상태·결과 조회 + * POST /api/strategy/evaluation/ai-verify — (레거시) 동기 검증 * POST /api/strategy/evaluation/llm-test — LLM 연결 테스트 */ @RestController @@ -22,6 +27,45 @@ import java.util.Map; public class StrategyEvaluationController { private final StrategyEvaluationLlmService llmService; + private final StrategyEvaluationAiVerifyJobService aiVerifyJobService; + + @PostMapping("/ai-verify/jobs") + public ResponseEntity startAiVerifyJob( + @RequestBody StrategyEvaluationAiVerifyRequest body, + @RequestHeader Map headers, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + TradingControllerSupport.requireRegisteredUser(headers); + if (body == null || body.getContext() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "context 가 필요합니다.")); + } + try { + Long userId = TradingControllerSupport.parseUserId(headers); + StrategyEvaluationAiVerifyJobStartResponse res = + aiVerifyJobService.startJob(body.getContext(), userId, deviceId); + return ResponseEntity.accepted().body(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())); + } + } + + @GetMapping("/ai-verify/jobs/{jobId}") + public ResponseEntity getAiVerifyJob( + @PathVariable String jobId, + @RequestHeader Map headers) { + TradingControllerSupport.requireRegisteredUser(headers); + try { + Long userId = TradingControllerSupport.parseUserId(headers); + StrategyEvaluationAiVerifyJobStatusResponse res = aiVerifyJobService.getJob(jobId, userId); + if (res == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(res); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } @PostMapping("/ai-verify") public ResponseEntity aiVerify( diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStartResponse.java b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStartResponse.java new file mode 100644 index 0000000..bfbd8f8 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStartResponse.java @@ -0,0 +1,16 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StrategyEvaluationAiVerifyJobStartResponse { + private String jobId; + /** QUEUED | RUNNING */ + private String status; +} diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStatusResponse.java b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStatusResponse.java new file mode 100644 index 0000000..e93d9fe --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyJobStatusResponse.java @@ -0,0 +1,20 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StrategyEvaluationAiVerifyJobStatusResponse { + private String jobId; + /** QUEUED | RUNNING | COMPLETED | FAILED */ + private String status; + private String error; + private StrategyEvaluationAiVerifyResponse result; + private long createdAtMs; + private Long completedAtMs; +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobRunner.java b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobRunner.java new file mode 100644 index 0000000..b92de49 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobRunner.java @@ -0,0 +1,31 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StrategyEvaluationAiVerifyJobRunner { + + private final StrategyEvaluationLlmService llmService; + private final StrategyEvaluationAiVerifyJobService jobService; + + @Async("aiVerifyExecutor") + public void execute(String jobId, JsonNode context, Long userId, String deviceId) { + jobService.markRunning(jobId); + try { + var result = llmService.verify(context, userId, deviceId); + jobService.markCompleted(jobId, result); + } catch (Exception e) { + log.warn("[AiVerifyJob] fail jobId={}: {}", jobId, e.getMessage()); + String msg = e.getMessage() != null && !e.getMessage().isBlank() + ? e.getMessage() + : "AI 검증 중 오류가 발생했습니다."; + jobService.markFailed(jobId, msg); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobService.java b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobService.java new file mode 100644 index 0000000..3c3274a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationAiVerifyJobService.java @@ -0,0 +1,112 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse; +import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse; +import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +public class StrategyEvaluationAiVerifyJobService { + + private static final long JOB_TTL_MS = 60L * 60 * 1000; + + private final StrategyEvaluationAiVerifyJobRunner jobRunner; + + private final ConcurrentHashMap jobs = new ConcurrentHashMap<>(); + + public StrategyEvaluationAiVerifyJobStartResponse startJob(JsonNode context, Long userId, String deviceId) { + purgeExpired(); + String jobId = UUID.randomUUID().toString(); + JobRecord record = new JobRecord(jobId, userId, System.currentTimeMillis()); + jobs.put(jobId, record); + jobRunner.execute(jobId, context, userId, deviceId); + return StrategyEvaluationAiVerifyJobStartResponse.builder() + .jobId(jobId) + .status(record.status) + .build(); + } + + public StrategyEvaluationAiVerifyJobStatusResponse getJob(String jobId, Long userId) { + purgeExpired(); + JobRecord record = jobs.get(jobId); + if (record == null || !record.userId.equals(userId)) { + return null; + } + return toResponse(record); + } + + void markRunning(String jobId) { + JobRecord record = jobs.get(jobId); + if (record != null) { + record.status = "RUNNING"; + } + } + + void markCompleted(String jobId, StrategyEvaluationAiVerifyResponse result) { + JobRecord record = jobs.get(jobId); + if (record != null) { + record.status = "COMPLETED"; + record.result = result; + record.error = null; + record.completedAtMs = System.currentTimeMillis(); + } + } + + void markFailed(String jobId, String error) { + JobRecord record = jobs.get(jobId); + if (record != null) { + record.status = "FAILED"; + record.error = error; + record.completedAtMs = System.currentTimeMillis(); + } + } + + private static StrategyEvaluationAiVerifyJobStatusResponse toResponse(JobRecord record) { + return StrategyEvaluationAiVerifyJobStatusResponse.builder() + .jobId(record.jobId) + .status(record.status) + .error(record.error) + .result(record.result) + .createdAtMs(record.createdAtMs) + .completedAtMs(record.completedAtMs) + .build(); + } + + private void purgeExpired() { + long cutoff = System.currentTimeMillis() - JOB_TTL_MS; + Iterator> it = jobs.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = it.next(); + long ts = e.getValue().completedAtMs != null + ? e.getValue().completedAtMs + : e.getValue().createdAtMs; + if (ts < cutoff) { + it.remove(); + } + } + } + + private static final class JobRecord { + private final String jobId; + private final Long userId; + private final long createdAtMs; + private String status = "QUEUED"; + private String error; + private StrategyEvaluationAiVerifyResponse result; + private Long completedAtMs; + + private JobRecord(String jobId, Long userId, long createdAtMs) { + this.jobId = jobId; + this.userId = userId; + this.createdAtMs = createdAtMs; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java index df33d50..a3ad0aa 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java @@ -26,31 +26,36 @@ public class StrategyEvaluationLlmService { private static final String SYSTEM_PROMPT = """ 당신은 GoldenChart 전략 평가(Ta4j Rule) 검증 전문가입니다. - 사용자 JSON에는 선택 봉 시점의 조건 평가 결과, 차트 매매 시그널, 전략 DSL 요약이 포함됩니다. - ta4jDiagnostics 섹션은 서버가 동일 경로로 Ta4j Rule 을 재빌드·재평가한 결과(Rule descriptor, trace 로그, frontend vs backend 비교)입니다. + 사용자 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) 선택 봉에서 매수/매도 전체 Rule(overallEntryMet/overallExitMet)과 개별 조건(satisfied)이 논리적으로 일치하는지 - 2) ta4jDiagnostics.reEvaluation·entryRule/exitRule.satisfiedAtEvalIndex 와 프론트 evaluation 이 일치하는지 - 3) entryRule/exitRule.evaluationTraceLog 로 Rule 평가 경로를 추적해 불일치 원인을 설명 - 4) 차트에 표시된 매매 시그널(selectedBarSignals, barSignalHighlight)이 평가 결과와 맞는지 - 5) 시그널이 없을 때: 실제로 조건 미충족이라 시그널이 없는 것인지, 로직·데이터 불일치 가능성이 있는지 - 6) 의심 지점이 있으면 구체적 조건 ID·지표·수치·Rule 타입을 인용 + 1) dslExpression·evaluationSemantics 기준으로 satisfied·overallEntryMet/overallExitMet 논리 일치 + 2) ta4jDiagnostics.reEvaluation·entryRule/exitRule.satisfiedAtEvalIndex 와 evaluation 일치 + 3) 차트 시그널(selectedBarSignals, barSignalHighlight)과 overall Rule 일치 + 4) 실제 불일치만 "오류 의심" — CROSS 조건의 정상 미충족은 "정상" 출력 형식(마크다운): ## 종합 판정 (정상 / 주의 / 오류 의심 — 한 줄 요약) ## 조건 평가 검증 - (매수·매도 각각 bullet) + (매수·매도 각각 bullet — dslExpression 인용) ## 차트 시그널 검증 (선택 봉 시그널 vs Rule 결과) ## 결론 및 권장 조치 (1~3문장) - - 로직 문제(종합 판정이 주의·오류 의심)인 경우, 위 분석에서 불일치 원인·조건 ID·지표를 구체적으로 명시하세요. """; private static final String TEST_USER_PROMPT = "ping — 연결 테스트입니다. 한 문장으로 응답해 주세요."; diff --git a/frontend/nginx-locations.inc b/frontend/nginx-locations.inc index a11a7f5..adde0af 100644 --- a/frontend/nginx-locations.inc +++ b/frontend/nginx-locations.inc @@ -24,6 +24,33 @@ } # ── LLM / AI 검증 (Ta4j 재평가 + LLM — exact match, 일반 /api/ 60s 회피) ─── + location = /api/strategy/evaluation/ai-verify/jobs { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_buffering off; + client_max_body_size 16m; + } + + location ~ ^/api/strategy/evaluation/ai-verify/jobs/[^/]+$ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_buffering off; + } + location = /api/strategy/evaluation/ai-verify { proxy_pass http://backend:8080; proxy_http_version 1.1; diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 1c79fc6..e2c4cdc 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -100,17 +100,35 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const [reportModel, setReportModel] = useState(null); const [reportLoading, setReportLoading] = useState(false); const [aiVerifyOpen, setAiVerifyOpen] = useState(false); - const [aiVerifyLoading, setAiVerifyLoading] = useState(false); + const [aiVerifyRunning, setAiVerifyRunning] = useState(false); const [aiVerifyError, setAiVerifyError] = useState(null); const [aiVerifyResult, setAiVerifyResult] = useState(null); const [aiVerifyContext, setAiVerifyContext] = useState(null); const [aiVerifySummary, setAiVerifySummary] = useState(null); + const [aiVerifyCompleteNotice, setAiVerifyCompleteNotice] = useState(false); + const [aiVerifyErrorNotice, setAiVerifyErrorNotice] = useState(null); const aiVerifyGenRef = useRef(0); + const aiVerifyOpenRef = useRef(aiVerifyOpen); + const aiVerifyPollAbortRef = useRef(null); const reportCacheKeyRef = useRef(null); const reportGenRef = useRef(0); const evalSessionRef = useRef(0); const paramsRevisionRef = useRef(paramsRevision); + useEffect(() => { + return () => { + aiVerifyPollAbortRef.current?.abort(); + }; + }, []); + + useEffect(() => { + aiVerifyOpenRef.current = aiVerifyOpen; + if (aiVerifyOpen) { + setAiVerifyCompleteNotice(false); + setAiVerifyErrorNotice(null); + } + }, [aiVerifyOpen]); + const getEvalParams = useMemo( () => mergeEvalGetParams(baseGetParams, appliedIndicatorParams), [baseGetParams, appliedIndicatorParams], @@ -392,12 +410,18 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const runAiVerify = useCallback(async () => { if (aiVerifyDisabled || !selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) return; - const gen = ++aiVerifyGenRef.current; + aiVerifyPollAbortRef.current?.abort(); + const pollAbort = new AbortController(); + aiVerifyPollAbortRef.current = pollAbort; + + const runId = ++aiVerifyGenRef.current; setAiVerifyOpen(true); - setAiVerifyLoading(true); + setAiVerifyRunning(true); setAiVerifyError(null); setAiVerifyResult(null); setAiVerifyContext(null); + setAiVerifyCompleteNotice(false); + setAiVerifyErrorNotice(null); try { const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); @@ -419,15 +443,25 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { setAiVerifySummary(summarizeVerifyContext(context)); setAiVerifyContext(context); - const result = await requestStrategyEvaluationAiVerify(context); - if (gen !== aiVerifyGenRef.current) return; + const result = await requestStrategyEvaluationAiVerify(context, { + signal: pollAbort.signal, + }); + if (runId !== aiVerifyGenRef.current) return; setAiVerifyResult(result); + if (!aiVerifyOpenRef.current) { + setAiVerifyCompleteNotice(true); + } } catch (e) { - if (gen !== aiVerifyGenRef.current) return; - setAiVerifyError(e instanceof Error ? e.message : 'AI 검증 요청 실패'); + if (runId !== aiVerifyGenRef.current) return; + if (e instanceof DOMException && e.name === 'AbortError') return; + const msg = e instanceof Error ? e.message : 'AI 검증 요청 실패'; + setAiVerifyError(msg); + if (!aiVerifyOpenRef.current) { + setAiVerifyErrorNotice(msg); + } } finally { - if (gen === aiVerifyGenRef.current) { - setAiVerifyLoading(false); + if (runId === aiVerifyGenRef.current) { + setAiVerifyRunning(false); } } }, [ @@ -448,13 +482,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { ]); const handleOpenAiVerify = useCallback(() => { + if (aiVerifyRunning) { + setAiVerifyOpen(true); + return; + } void runAiVerify(); - }, [runAiVerify]); + }, [aiVerifyRunning, runAiVerify]); const handleCloseAiVerify = useCallback(() => { - ++aiVerifyGenRef.current; setAiVerifyOpen(false); - setAiVerifyLoading(false); }, []); const handleOpenReport = useCallback(async () => { @@ -708,7 +744,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { reportLoading={reportLoading} onOpenAiVerify={handleOpenAiVerify} aiVerifyDisabled={aiVerifyDisabled} - aiVerifyLoading={aiVerifyLoading && aiVerifyOpen} + aiVerifyLoading={aiVerifyRunning} marketTickers={marketFeed.tickers} marketInfos={marketFeed.marketInfos} marketLoading={marketFeed.loading} @@ -806,7 +842,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { { void runAiVerify(); }} /> + + {aiVerifyRunning && !aiVerifyOpen && ( +
+ + AI 분석 진행 중… + +
+ )} + + {(aiVerifyCompleteNotice || aiVerifyErrorNotice) && !aiVerifyOpen && ( +
+ + {aiVerifyErrorNotice ?? 'AI 분석이 완료되었습니다.'} + +
+ {!aiVerifyErrorNotice && ( + + )} + + {aiVerifyErrorNotice && ( + + )} +
+
+ )} ); } diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx index f7116d3..eb57171 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx @@ -299,7 +299,10 @@ const StrategyEvaluationAiVerifyModal: React.FC = ({

LLM이 평가 결과와 차트 시그널을 분석 중입니다…

-

Ta4j 재평가와 LLM 분석에 최대 2~3분 걸릴 수 있습니다.

+

+ Ta4j 재평가와 LLM 분석에 최대 2~3분 걸릴 수 있습니다. + 모달을 닫아도 백그라운드에서 분석이 계속됩니다. +

)} @@ -335,7 +338,9 @@ const StrategyEvaluationAiVerifyModal: React.FC = ({