Files
connectai/src/features/stocks/discoveryAnalyzer.ts
T
g1nation 0a97324f1b 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>
2026-05-25 09:59:32 +09:00

238 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) };
}
}