feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
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>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user