ai 전략 기능 추가

This commit is contained in:
Macbook
2026-06-30 21:59:13 +09:00
parent f9447ea52f
commit 25cbadbffb
18 changed files with 2446 additions and 3 deletions
+48 -1
View File
@@ -47,6 +47,7 @@ import {
} from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
@@ -320,7 +321,7 @@ export default function StrategyEditorPage({
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<SignalTab>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
const [rightTab, setRightTab] = useState<'indicators' | 'templates' | 'ai'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
@@ -1463,6 +1464,45 @@ export default function StrategyEditorPage({
[templateRows, templateSearch],
);
const buildAiContext = useCallback((): AiStrategyContext => {
const buyEncoded = encodeConditionForSave(buyEditorState);
const sellEncoded = encodeConditionForSave(sellEditorState);
return {
buyCondition: buyEncoded,
sellCondition: sellEncoded,
signalTab: buySellTab(signalTab),
candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType,
};
}, [buyEditorState, sellEditorState, signalTab, currentEditorState]);
const applyAiStrategy = useCallback((buyDsl: LogicNode | null, sellDsl: LogicNode | null) => {
if (editorMode === 'graph') layoutFlushRef.current?.();
const buyDecoded = decodeConditionForEditor(buyDsl);
const sellDecoded = decodeConditionForEditor(sellDsl);
setBuyCondition(buyDecoded.root);
setSellCondition(sellDecoded.root);
setBuyOrphans([]);
setSellOrphans([]);
setSelectedNodeId(null);
setBuyLayout(prev => ({
...prev,
startCombineOp: normalizeStartCombineOp(buyDecoded.startCombineOp),
}));
setSellLayout(prev => ({
...prev,
startCombineOp: normalizeStartCombineOp(sellDecoded.startCombineOp),
}));
resetFlowLayout(layoutStrategyKey, 'buy');
resetFlowLayout(layoutStrategyKey, 'sell');
const targetTab: BuySellTab = buyDsl ? 'buy' : (sellDsl ? 'sell' : buySellTab(signalTab));
if (targetTab !== signalTab) setSignalTab(targetTab);
scheduleStrategyPersist();
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
if (editorMode === 'graph') layoutFlushRef.current?.();
@@ -2418,6 +2458,7 @@ export default function StrategyEditorPage({
<div className="se-right-tabs">
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}></button>
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>릿</button>
<button type="button" className={rightTab === 'ai' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('ai')}>AI </button>
</div>
<div className="se-right-body">
{rightTab === 'indicators' && (
@@ -2593,6 +2634,12 @@ export default function StrategyEditorPage({
onDelete={handleTemplateToolbarDelete}
/>
)}
{rightTab === 'ai' && (
<AiStrategyPanel
getContext={buildAiContext}
onApplyStrategy={applyAiStrategy}
/>
)}
</div>
</div>
</aside>
@@ -1,7 +1,7 @@
import React, { useMemo, useState } from 'react';
import type { LlmAppSettings } from '../../utils/llmSettings';
import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings';
import { fetchLlmConnectionTest } from '../../utils/backendApi';
import { fetchLlmConnectionTest, fetchLlmModels } from '../../utils/backendApi';
interface Props {
settings: LlmAppSettings;
@@ -34,6 +34,9 @@ const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = (
const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [loadingModels, setLoadingModels] = useState(false);
const [models, setModels] = useState<string[]>([]);
const [modelsMessage, setModelsMessage] = useState<{ ok: boolean; message: string } | null>(null);
const patch = (p: Partial<LlmAppSettings>) => {
setTestResult(null);
@@ -57,6 +60,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
}
};
const handleFetchModels = async () => {
setLoadingModels(true);
setModelsMessage(null);
try {
const res = await fetchLlmModels({
host: settings.host,
port: settings.port,
useHttps: settings.useHttps,
modelsPath: settings.modelsPath,
});
setModels(res.models ?? []);
setModelsMessage({ ok: res.ok, message: res.message ?? (res.ok ? '조회 완료' : '조회 실패') });
if (res.ok && res.models?.length && !res.models.includes(settings.model)) {
patch({ model: res.models[0] });
}
} catch (e) {
const msg = e instanceof Error ? e.message : '모델 조회 실패';
setModels([]);
setModelsMessage({ ok: false, message: msg });
} finally {
setLoadingModels(false);
}
};
const modelOptions = useMemo(() => {
const set = new Set(models);
if (settings.model) set.add(settings.model);
return Array.from(set);
}, [models, settings.model]);
return (
<div className="llm-settings-panel">
<SettingSection title="LLM 사용">
@@ -114,6 +147,15 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
placeholder="/v1/chat/completions"
/>
</SettingRow>
<SettingRow label="모델 목록 경로" desc="OpenAI 호환 모델 목록 엔드포인트 (드롭다운 조회용)">
<input
className="stg-input stg-input--wide"
type="text"
value={settings.modelsPath}
onChange={e => patch({ modelsPath: e.target.value })}
placeholder="/v1/models"
/>
</SettingRow>
<SettingRow label="연결 URL 미리보기" desc="저장 후 백엔드가 이 주소로 LLM에 요청합니다.">
<code className="stg-code-preview">{endpointPreview}</code>
</SettingRow>
@@ -137,7 +179,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
</SettingSection>
<SettingSection title="모델·생성 옵션">
<SettingRow label="모델" desc="LLM 서버에 등록된 모델 ID">
<SettingRow label="모델" desc="URL 입력 후 '모델 조회'로 목록을 받아 선택하세요. 조회가 안 되면 직접 입력할 수 있습니다.">
<div className="stg-row-inline">
<select
className="stg-input stg-input--wide"
value={settings.model}
onChange={e => patch({ model: e.target.value })}
>
{modelOptions.length === 0 && <option value=""> </option>}
{modelOptions.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button
type="button"
className="stg-btn-test"
disabled={loadingModels || !settings.host}
onClick={handleFetchModels}
>
{loadingModels ? '조회 중…' : '모델 조회'}
</button>
</div>
</SettingRow>
{modelsMessage && (
<SettingRow label="" desc="">
<span className={`stg-badge ${modelsMessage.ok ? 'stg-badge--ok' : 'stg-badge--err'}`}>
{modelsMessage.message}
</span>
</SettingRow>
)}
<SettingRow label="모델 직접 입력" desc="목록에 없는 모델 ID를 직접 입력할 때 사용">
<input
className="stg-input stg-input--wide"
type="text"
@@ -0,0 +1,238 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { LogicNode } from '../../utils/strategyTypes';
import {
requestStrategyAgentChat,
type StrategyAgentChatMessage,
} from '../../utils/strategyAgentChat';
export interface AiStrategyContext {
buyCondition: LogicNode | null;
sellCondition: LogicNode | null;
signalTab: 'buy' | 'sell';
candleType?: string;
[key: string]: unknown;
}
interface Props {
/** 전송·스냅샷 시점의 현재 편집기 상태를 반환 */
getContext: () => AiStrategyContext;
/** 매수·매도 조건을 편집기에 교체 적용 (null = 해당 측 비움) */
onApplyStrategy: (buy: LogicNode | null, sell: LogicNode | null) => void;
}
type Turn = {
id: number;
role: 'user' | 'assistant';
content: string;
action?: 'ask' | 'build';
/** build 턴 — 적용 직전 스냅샷(되돌리기용) */
snapshot?: { buy: LogicNode | null; sell: LogicNode | null };
/** build 턴 — 적용된 결과 */
produced?: { buy: LogicNode | null; sell: LogicNode | null };
reverted?: boolean;
};
const SUGGESTIONS = [
'전환선이 기준선을 상향돌파하면 매수',
'26봉 전 후행스팬이 종가를 상향돌파하고 전환선이 기준선을 상향돌파하면 매수',
'RSI 30 이하에서 상향돌파하면 매수, 70 이상이면 매도',
'지금 전략을 더 엄격하게 만들어줘',
];
const AiStrategyPanel: React.FC<Props> = ({ getContext, onApplyStrategy }) => {
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const idRef = useRef(0);
const scrollRef = useRef<HTMLDivElement | null>(null);
const nextId = () => ++idRef.current;
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, []);
const history = useMemo<StrategyAgentChatMessage[]>(
() => turns.map(t => ({ role: t.role, content: t.content })),
[turns],
);
const send = useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed || busy) return;
setError(null);
const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed };
const outgoing: StrategyAgentChatMessage[] = [...history, { role: 'user', content: trimmed }];
setTurns(prev => [...prev, userTurn]);
setInput('');
setBusy(true);
scrollToBottom();
const ctx = getContext();
try {
const res = await requestStrategyAgentChat(outgoing, ctx);
if (res.action === 'build' && res.hasStrategy) {
const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition };
// AI가 생략한 측은 기존 조건 유지
const produced = {
buy: res.buyCondition ?? ctx.buyCondition,
sell: res.sellCondition ?? ctx.sellCondition,
};
onApplyStrategy(produced.buy, produced.sell);
setTurns(prev => [...prev, {
id: nextId(),
role: 'assistant',
content: res.reply || '요청하신 조건으로 전략을 구성했습니다.',
action: 'build',
snapshot,
produced,
}]);
} else {
setTurns(prev => [...prev, {
id: nextId(),
role: 'assistant',
content: res.reply || '요청을 더 구체적으로 알려주세요.',
action: 'ask',
}]);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'AI 전략 생성 중 오류가 발생했습니다.';
setError(msg);
setTurns(prev => [...prev, {
id: nextId(),
role: 'assistant',
content: `오류: ${msg}`,
action: 'ask',
}]);
} finally {
setBusy(false);
scrollToBottom();
}
}, [busy, history, getContext, onApplyStrategy, scrollToBottom]);
const handleRevert = useCallback((turnId: number) => {
setTurns(prev => prev.map(t => {
if (t.id !== turnId || !t.snapshot) return t;
onApplyStrategy(t.snapshot.buy, t.snapshot.sell);
return { ...t, reverted: true };
}));
}, [onApplyStrategy]);
const handleReapply = useCallback((turnId: number) => {
setTurns(prev => prev.map(t => {
if (t.id !== turnId || !t.produced) return t;
onApplyStrategy(t.produced.buy, t.produced.sell);
return { ...t, reverted: false };
}));
}, [onApplyStrategy]);
const handleReset = useCallback(() => {
if (busy) return;
setTurns([]);
setError(null);
}, [busy]);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send(input);
}
};
return (
<div className="se-ai-panel">
<div className="se-ai-head">
<span className="se-ai-title">AI </span>
<button
type="button"
className="se-ai-reset"
onClick={handleReset}
disabled={busy || turns.length === 0}
>
</button>
</div>
<div className="se-ai-messages" ref={scrollRef}>
{turns.length === 0 && (
<div className="se-ai-empty">
<p> .</p>
<p className="se-ai-empty-sub"> , .</p>
<div className="se-ai-suggestions">
{SUGGESTIONS.map(s => (
<button key={s} type="button" className="se-ai-suggestion" onClick={() => void send(s)} disabled={busy}>
{s}
</button>
))}
</div>
</div>
)}
{turns.map(t => (
<div key={t.id} className={`se-ai-msg se-ai-msg--${t.role}`}>
<div className="se-ai-bubble">
{t.content.split('\n').map((line, i) => <p key={i}>{line || '\u00A0'}</p>)}
</div>
{t.action === 'build' && t.produced && (
<div className="se-ai-apply-row">
{t.reverted ? (
<>
<span className="se-ai-tag se-ai-tag--reverted"></span>
<button type="button" className="se-ai-mini-btn" onClick={() => handleReapply(t.id)} disabled={busy}>
</button>
</>
) : (
<>
<span className="se-ai-tag se-ai-tag--applied"> </span>
{t.snapshot && (
<button type="button" className="se-ai-mini-btn" onClick={() => handleRevert(t.id)} disabled={busy}>
</button>
)}
</>
)}
</div>
)}
</div>
))}
{busy && (
<div className="se-ai-msg se-ai-msg--assistant">
<div className="se-ai-bubble se-ai-bubble--loading"> </div>
</div>
)}
</div>
{error && <div className="se-ai-error">{error}</div>}
<div className="se-ai-input-row">
<textarea
className="se-ai-input"
placeholder="예) 전환선이 기준선을 상향돌파하면 매수 (Enter 전송 · Shift+Enter 줄바꿈)"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
rows={2}
disabled={busy}
/>
<button
type="button"
className="se-ai-send"
onClick={() => void send(input)}
disabled={busy || !input.trim()}
>
</button>
</div>
</div>
);
};
export default AiStrategyPanel;
+125
View File
@@ -1914,6 +1914,131 @@
overflow-x: hidden;
}
/* ── AI 전략 에이전트 탭 ───────────────────────────────────── */
.se-ai-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.se-ai-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px solid var(--se-border);
}
.se-ai-title { font-size: 0.78rem; font-weight: 600; color: var(--se-text); }
.se-ai-reset {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--se-input-border);
background: var(--se-input-bg);
color: var(--se-text-muted);
cursor: pointer;
}
.se-ai-reset:disabled { opacity: 0.5; cursor: default; }
.se-ai-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.se-ai-empty { color: var(--se-text-muted); font-size: 0.76rem; }
.se-ai-empty p { margin: 0 0 6px; line-height: 1.5; }
.se-ai-empty-sub { opacity: 0.8; }
.se-ai-suggestions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.se-ai-suggestion {
text-align: left;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--se-input-border);
background: var(--se-input-bg);
color: var(--se-text);
font-size: 0.73rem;
cursor: pointer;
}
.se-ai-suggestion:hover { border-color: var(--se-tab-active); }
.se-ai-suggestion:disabled { opacity: 0.5; cursor: default; }
.se-ai-msg { display: flex; flex-direction: column; max-width: 92%; }
.se-ai-msg--user { align-self: flex-end; align-items: flex-end; }
.se-ai-msg--assistant { align-self: flex-start; align-items: flex-start; }
.se-ai-bubble {
padding: 8px 11px;
border-radius: 12px;
font-size: 0.76rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.se-ai-bubble p { margin: 0; }
.se-ai-msg--user .se-ai-bubble {
background: var(--se-tab-active);
color: #fff;
border-bottom-right-radius: 4px;
}
.se-ai-msg--assistant .se-ai-bubble {
background: var(--se-input-bg);
border: 1px solid var(--se-input-border);
color: var(--se-text);
border-bottom-left-radius: 4px;
}
.se-ai-bubble--loading { opacity: 0.7; font-style: italic; }
.se-ai-apply-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.se-ai-tag { font-size: 0.68rem; padding: 2px 7px; border-radius: 999px; }
.se-ai-tag--applied { background: color-mix(in srgb, #16a34a 18%, transparent); color: #16a34a; }
.se-ai-tag--reverted { background: color-mix(in srgb, #d97706 18%, transparent); color: #d97706; }
.se-ai-mini-btn {
font-size: 0.68rem;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--se-input-border);
background: transparent;
color: var(--se-text-muted);
cursor: pointer;
}
.se-ai-mini-btn:disabled { opacity: 0.5; cursor: default; }
.se-ai-error {
margin: 0 10px 6px;
padding: 6px 9px;
border-radius: 8px;
font-size: 0.72rem;
background: color-mix(in srgb, #dc2626 14%, transparent);
color: #dc2626;
}
.se-ai-input-row {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--se-border);
}
.se-ai-input {
flex: 1;
resize: none;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--se-input-border);
background: var(--se-input-bg);
color: var(--se-text);
font-size: 0.76rem;
font-family: inherit;
}
.se-ai-send {
align-self: stretch;
padding: 0 14px;
border-radius: 8px;
border: none;
background: var(--se-tab-active);
color: #fff;
font-size: 0.76rem;
cursor: pointer;
}
.se-ai-send:disabled { opacity: 0.5; cursor: default; }
.se-palette-search {
margin: 10px 10px 6px;
padding: 8px 10px;
+57
View File
@@ -1835,6 +1835,63 @@ export async function fetchLlmConnectionTest(): Promise<LlmConnectionTestRespons
);
}
export interface LlmModelsQuery {
host: string;
port: number;
useHttps: boolean;
modelsPath?: string;
timeoutMs?: number;
}
export interface LlmModelsResult {
ok: boolean;
models: string[];
message?: string;
}
/** 설정 화면 — 입력한(미저장) 서버에서 사용 가능한 모델 목록 조회 */
export async function fetchLlmModels(query: LlmModelsQuery): Promise<LlmModelsResult> {
return requestOrThrowWithTimeout<LlmModelsResult>(
'/strategy/evaluation/llm-models',
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify(query),
},
60_000,
);
}
export interface StrategyAgentChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface StrategyAgentChatApiResponse {
reply: string;
action: 'ask' | 'build';
buyCondition?: unknown | null;
sellCondition?: unknown | null;
model?: string;
latencyMs?: number;
}
/** AI 전략 에이전트 — 멀티턴 대화로 전략 DSL 생성/수정 */
export async function postStrategyAgentChat(
messages: StrategyAgentChatMessage[],
context: unknown,
): Promise<StrategyAgentChatApiResponse> {
return requestOrThrowWithTimeout<StrategyAgentChatApiResponse>(
'/strategy/agent/chat',
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify({ messages, context }),
},
620_000,
);
}
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
if (!hasRegisteredUser()) return ['1m'];
+7
View File
@@ -13,6 +13,8 @@ export interface LlmAppSettings {
useHttps: boolean;
/** OpenAI 호환 chat completions 경로 */
chatPath: string;
/** OpenAI 호환 모델 목록 경로 (드롭다운 조회용) */
modelsPath: string;
/** 모델 ID */
model: string;
/** 최대 생성 토큰 */
@@ -29,6 +31,7 @@ export const DEFAULT_LLM_APP_SETTINGS: LlmAppSettings = {
port: 16000,
useHttps: false,
chatPath: '/v1/chat/completions',
modelsPath: '/v1/models',
model: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
maxTokens: 1024,
temperature: 0.1,
@@ -56,6 +59,9 @@ export function resolveLlmAppSettings(
const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim()
? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`)
: d.chatPath;
const modelsPath = typeof raw.modelsPath === 'string' && raw.modelsPath.trim()
? (raw.modelsPath.startsWith('/') ? raw.modelsPath.trim() : `/${raw.modelsPath.trim()}`)
: d.modelsPath;
const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : d.model;
return {
enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled),
@@ -63,6 +69,7 @@ export function resolveLlmAppSettings(
port: clampInt(raw.port, 1, 65535, d.port),
useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps),
chatPath,
modelsPath,
model,
maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens),
temperature: clampFloat(raw.temperature, 0, 2, d.temperature),
+108
View File
@@ -0,0 +1,108 @@
/**
* AI 전략 에이전트 — 멀티턴 대화 API 클라이언트 + 응답 DSL 검증·정규화.
*/
import {
postStrategyAgentChat,
type StrategyAgentChatMessage,
type StrategyAgentChatApiResponse,
} from './backendApi';
import { INDICATOR_CONDITIONS, generateNodeId, type ConditionDSL, type LogicNode, type LogicNodeType } from './strategyTypes';
export type { StrategyAgentChatMessage } from './backendApi';
export interface StrategyAgentResult {
reply: string;
action: 'ask' | 'build';
/** 검증·정규화된 매수 조건 (build이고 유효할 때만) */
buyCondition: LogicNode | null;
/** 검증·정규화된 매도 조건 */
sellCondition: LogicNode | null;
/** action=build인데 DSL이 모두 비어 검증 후 사라진 경우 */
hasStrategy: boolean;
model?: string;
latencyMs?: number;
}
const NODE_TYPES: ReadonlySet<LogicNodeType> = new Set<LogicNodeType>([
'AND', 'OR', 'NOT', 'CONDITION', 'TIMEFRAME',
]);
const WINDOW_MODES = new Set(['EXISTS_IN', 'NOT_EXISTS_IN']);
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/** LLM이 생성한 LogicNode 트리를 편집기/백엔드가 처리 가능한 형태로 검증·정규화. */
export function sanitizeAiLogicNode(raw: unknown): LogicNode | null {
const obj = asRecord(raw);
if (!obj) return null;
const type = obj.type as LogicNodeType;
if (!NODE_TYPES.has(type)) return null;
const id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : generateNodeId();
if (type === 'CONDITION') {
const cond = asRecord(obj.condition);
if (!cond) return null;
const indicatorType = typeof cond.indicatorType === 'string' ? cond.indicatorType.trim() : '';
const conditionType = typeof cond.conditionType === 'string' ? cond.conditionType.trim() : '';
if (!indicatorType || !conditionType) return null;
// 알려진 지표면 conditionType 매트릭스로 검증, 모르는 지표는 백엔드 폴백에 위임
const allowed = INDICATOR_CONDITIONS[indicatorType];
if (allowed && !allowed.includes(conditionType)) return null;
const condition = { ...cond, indicatorType, conditionType } as unknown as ConditionDSL;
// 윈도우 모드일 때 candleRange 보정
if (condition.candleRangeMode && WINDOW_MODES.has(condition.candleRangeMode)) {
const n = Number(condition.candleRange);
condition.candleRange = Number.isFinite(n) && n >= 2 ? Math.floor(n) : 4;
}
return { id, type, condition };
}
// AND / OR / NOT / TIMEFRAME
const children = Array.isArray(obj.children)
? (obj.children.map(sanitizeAiLogicNode).filter(Boolean) as LogicNode[])
: [];
if (type === 'NOT') {
if (children.length === 0) return null;
return { id, type, children: [children[0]] } as LogicNode;
}
if (type === 'TIMEFRAME') {
if (children.length === 0) return null;
const node: LogicNode = { id, type, children };
if (typeof obj.candleType === 'string') node.candleType = obj.candleType;
if (Array.isArray(obj.candleTypes)) {
node.candleTypes = obj.candleTypes.filter((c): c is string => typeof c === 'string');
}
return node;
}
// AND / OR — 자식이 없으면 무의미하므로 제거, 하나뿐이면 그대로 승격하지 않고 유지
if (children.length === 0) return null;
return { id, type, children } as LogicNode;
}
/** 멀티턴 대화 요청 → 응답 파싱·DSL 검증 */
export async function requestStrategyAgentChat(
messages: StrategyAgentChatMessage[],
context: unknown,
): Promise<StrategyAgentResult> {
const res: StrategyAgentChatApiResponse = await postStrategyAgentChat(messages, context);
const action = res.action === 'build' ? 'build' : 'ask';
const buyCondition = action === 'build' ? sanitizeAiLogicNode(res.buyCondition) : null;
const sellCondition = action === 'build' ? sanitizeAiLogicNode(res.sellCondition) : null;
return {
reply: res.reply ?? '',
action,
buyCondition,
sellCondition,
hasStrategy: Boolean(buyCondition || sellCondition),
model: res.model,
latencyMs: res.latencyMs,
};
}