import * as vscode from 'vscode'; import { AIService } from '../../core/services'; import { logError, logInfo } from '../../utils'; import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands'; import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; import type { DiscoveredCandidate } from './stockDiscovery'; /** * Discover 결과를 LLM 에게 던져 *매력도 Top 5* 추출 + 채팅/텔레그램 발송. * * 호출 흐름 (slashStocks 의 cmdDiscover 끝에 자동 chain): * 1) buildAnalysisPrompt(candidates) — 20개 후보 데이터를 LLM 입력 형식으로 직렬화 * 2) AIService.chat(...) — Astra 의 기본 모델로 평가 * 3) parseTopFive(output) — LLM 응답을 구조화된 5개 항목으로 파싱 (관대) * 4) renderForChat / renderForTelegram — 사용자에게 보일 메시지 두 가지 형식 * 5) sendToTelegram(...) — 텔레그램 chatId 로 발송 (실패 silent) * * 작은 모델 (gemma 4B 등) 이 형식을 흩뜨려도 깨지지 않게 — parseTopFive 는 *느슨한* * 번호 매칭으로 5개 라인만 추출, 실패 시 raw text 그대로 fallback. */ export interface TopFiveItem { rank: number; name: string; symbol: string; /** LLM 이 한 줄로 정리한 매력 포인트. */ pitch: string; /** 원본 후보 (선택적으로 ROE/영업이익률 등 인용용). */ candidate?: DiscoveredCandidate; } export interface TopFiveResult { items: TopFiveItem[]; /** LLM 의 종합 코멘트 (1-2 문장). 없을 수 있음. */ summary?: string; /** LLM 응답 원문 (디버그 / fallback 출력용). */ raw: string; } const SYSTEM_PROMPT = [ '당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.', '제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.', '', '**평가 기준 (우선순위 순):**', ' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)', ' 2. ROE 절대 수치 — 15% 이상 강하게 가산', ' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)', ' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산', ' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check', '', '**출력 형식 (반드시 이대로):**', '🎯 매력도 Top 5', '', '1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>', ' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>', '2. ...', '...', '5. ...', '', '종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>', '', '*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.', ].join('\n'); function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string { const lines: string[] = [ `발굴 후보 ${candidates.length}개. 매력도 Top 5 골라라.`, '', ]; for (const c of candidates) { const f = c.fundamentals; lines.push( `· ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]`, ` ROE ${f.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}% · 부채비율 ${f.debtRatio?.toFixed(1) ?? '-'}% · PER ${f.per?.toFixed(1) ?? '-'} · PBR ${f.pbr?.toFixed(1) ?? '-'}`, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`, '', ); } return lines.join('\n'); } /** * LLM 응답을 5개 항목으로 파싱. 작은 모델이 형식 흩뜨려도 잡힐 수 있게 관대하게: * - "1. <이름> (<6자리>) — <문구>" / "1) <이름> ..." / "1: ..." 모두 받음 * - 종목 매핑은 6자리 심볼 정규식으로 cross-check */ function parseTopFive(raw: string, candidates: DiscoveredCandidate[]): TopFiveResult { const items: TopFiveItem[] = []; const lines = raw.split('\n'); const symbolMap = new Map(candidates.map(c => [c.symbol, c])); const itemRe = /^\s*([1-5])[\.\)\:]\s+(.+?)\s*[\(\[](\d{6})[\)\]]\s*[—\-:]\s*(.+)$/; for (const line of lines) { const m = line.match(itemRe); if (!m) continue; const rank = parseInt(m[1], 10); if (items.find(i => i.rank === rank)) continue; items.push({ rank, name: m[2].trim(), symbol: m[3], pitch: m[4].trim(), candidate: symbolMap.get(m[3]), }); } items.sort((a, b) => a.rank - b.rank); // 종합 코멘트 추출 — "종합:" 또는 "총평:" 라인. const summaryMatch = raw.match(/(?:종합|총평)\s*[::]\s*(.+?)(?:\n\n|$)/s); const summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined; return { items, summary, raw }; } export async function analyzeTopCandidates( candidates: DiscoveredCandidate[], onProgress?: (msg: string) => void, ): Promise { if (candidates.length === 0) { return { items: [], raw: '' }; } onProgress?.('🤖 LLM 분석 시작 — 후보 ' + candidates.length + '개 평가 중...'); const ai = new AIService(); try { const result = await ai.chat({ system: SYSTEM_PROMPT, user: buildAnalysisPrompt(candidates), timeoutMs: 90_000, }); if (result.empty || !result.content.trim()) { logError('Discovery analyzer: LLM 빈 응답.'); return { items: [], raw: '' }; } const parsed = parseTopFive(result.content, candidates); logInfo('Discovery analyzer: parsed Top 5.', { parsedCount: parsed.items.length, model: result.model, }); return parsed; } catch (e: any) { logError('Discovery analyzer: LLM 호출 실패.', { error: e?.message ?? String(e) }); return { items: [], raw: '' }; } } /** 채팅 webview 용 — markdown 친화적 멀티라인. */ export function renderTopFiveForChat(result: TopFiveResult): string { if (result.items.length === 0) { return result.raw ? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n` : '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\n'; } const lines: string[] = ['\n🎯 **Astra 매력도 Top 5**\n']; for (const it of result.items) { lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`); if (it.candidate) { const f = it.candidate.fundamentals; lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`); } } if (result.summary) { lines.push('', `💬 ${result.summary}`); } return lines.join('\n') + '\n'; } /** 텔레그램용 — Markdown V1, 짧고 깔끔. 4096자 제한 안에서. */ function pickChatIdForReport(): number | null { const cfg = vscode.workspace.getConfiguration('g1nation'); const allowed = cfg.get('telegram.allowedChatIds', []) || []; if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) { return allowed[0]; } const fallback = cfg.get('stocks.telegramChatId', 0); return fallback && Number.isFinite(fallback) ? fallback : null; } function formatKstNow(): string { const fmt = new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); const parts = fmt.formatToParts(new Date()); const get = (t: string) => parts.find(p => p.type === t)?.value || ''; return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`; } export function renderTopFiveForTelegram( result: TopFiveResult, rangeLabel: string, ): string { if (result.items.length === 0) { return `🎯 *Astra 발굴 Top 5* (${rangeLabel})\n${formatKstNow()}\n\n⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.`; } const lines: string[] = [ `🎯 *Astra 발굴 Top 5* (${rangeLabel})`, formatKstNow(), '', ]; for (const it of result.items) { lines.push(`${it.rank}\\. *${it.name}* (\`${it.symbol}\`)`); lines.push(` ${it.pitch}`); if (it.candidate) { const f = it.candidate.fundamentals; lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`); } lines.push(''); } if (result.summary) { lines.push(`💬 ${result.summary}`); } const joined = lines.join('\n'); return joined.length > 3800 ? joined.slice(0, 3800) + '\n…(잘림)' : joined; } export async function sendTopFiveToTelegram( context: vscode.ExtensionContext, text: string, ): Promise<{ ok: boolean; reason?: string }> { const chatId = pickChatIdForReport(); if (chatId === null) { return { ok: false, reason: '텔레그램 chatId 미설정 (allowedChatIds 또는 stocks.telegramChatId)' }; } const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; if (!token.trim()) { return { ok: false, reason: '텔레그램 봇 토큰 없음' }; } const client = new TelegramHttpClient({ getToken: () => token }); try { await client.sendMessage({ chatId, text, parseMode: 'Markdown' }); logInfo('Top 5 텔레그램 발송 완료.', { chatId, chars: text.length }); return { ok: true }; } catch (e: any) { logError('Top 5 텔레그램 발송 실패.', { chatId, error: e?.message ?? String(e) }); return { ok: false, reason: e?.message ?? String(e) }; } }