0a97324f1b
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
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<TopFiveResult> {
|
||
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<number[]>('telegram.allowedChatIds', []) || [];
|
||
if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) {
|
||
return allowed[0];
|
||
}
|
||
const fallback = cfg.get<number>('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) };
|
||
}
|
||
}
|