feat: /stocks 판정 결정론화 + /meet 정확도 파이프라인 (v2.2.211)
/stocks judge — 조건 판정 정확도 (P2/P3/P4):
- criteriaEval.ts 신설: 8개 키워드 중 수치 기준 7개("5,800%" 파싱·임계값
비교)와 충족/미충족 판정·투자성향별 대표 3개 선택을 코드로 결정론 계산.
LLM 은 '기술력' 도메인 정성 판단(키워드 모호 시)과 근거 서술만 담당,
실패 시 결정론 폴백 → judge 가 LLM 형식 오류로 실패하는 경로 제거.
- cmdJudge: 판정 전 Naver 실시간 펀더멘털 fetch(실패 시 저장값 폴백) +
결과에 데이터 출처 표기.
- tests/stocksCriteria.test.ts: 사용자 실제 분류 패턴(마녀공장/기가비스/
엔켐) 픽스처 8건 — 코드 판정이 기존 패턴과 일치함을 고정.
/meet — 할루시네이션·문맥 누락 (P1/P5/P6):
- 근거 인용 의무: 결정·액션마다 발언 원문 인용(근거: "…") — 인용 불가
항목은 결정/액션 금지 (날조 구조적 억제).
- 60K 하드 자르기 폐지 → 12K 조각 추출(Map) + 병합(Reduce) 2단계.
lost-in-the-middle·후반부 증발 해소, 커버리지 60K→144K자.
- g1nation.meetVerifyPass(기본 off): 결정·액션을 근거 소스와 LLM 대조해
확인 불가 항목을 '⚠️ 검증 결과' 섹션으로 표시.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* `/stocks judge` 의 결정론적 기준 평가기.
|
||||
*
|
||||
* 기존에는 "유보율: 5,800%" 같은 문자열 파싱과 임계값 비교(ROE ≥ 10% 등)까지
|
||||
* 전부 LLM 에게 맡겼는데, 로컬 소형 모델은 콤마 숫자 파싱·다중 기준 동시 비교에서
|
||||
* 자주 틀린다. 8개 키워드 중 7개는 순수 수치 비교라 코드로 100% 정확하게 계산
|
||||
* 가능하므로 여기서 평가하고, LLM 은 ① '기술력' 도메인 정성 판단(키워드 매칭이
|
||||
* 모호할 때만) ② 근거 문장 서술만 담당한다. 충족/미충족 판정과 대표 키워드
|
||||
* 선택은 사용자가 명시한 규칙(투자성향별 우선순위)을 그대로 코드화했다.
|
||||
*
|
||||
* signalClassifier 의 `.includes("충족")` 계약과 "충족 (A, B, C)" 출력 형식은
|
||||
* 기존 그대로 유지된다.
|
||||
*/
|
||||
import type { Stock } from './types';
|
||||
import type { Fundamentals } from './naverFundamentals';
|
||||
|
||||
export type CriterionState = 'pass' | 'fail' | 'unknown' | 'llm';
|
||||
export interface CriterionResult {
|
||||
keyword: string;
|
||||
state: CriterionState; // unknown = 데이터 없음, llm = 정성 판단 필요(기술력 도메인)
|
||||
detail: string; // 수치 근거 한 줄 (rationale 합성·로그용)
|
||||
/** 대표 키워드 표기 시 사용할 라벨 (예: 영업이익률 ≥ 20% 이면 '수익성 개선'). */
|
||||
label?: string;
|
||||
}
|
||||
export interface CriteriaEvaluation {
|
||||
results: CriterionResult[];
|
||||
/** 데이터 출처 표기 ('Naver 실시간 YYYY-MM-DD' | 'stocks.json 저장값'). */
|
||||
dataSource: string;
|
||||
/** 파싱된 수치 (LLM 프롬프트·rationale 에 인용). */
|
||||
numbers: Record<string, string>;
|
||||
}
|
||||
|
||||
// ── 문자열 → 숫자 파싱 (stocks.json 의 한글 포맷 대응) ──────────────────────
|
||||
function num(raw: string | number | undefined): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
|
||||
const cleaned = String(raw).replace(/[,%\s원]/g, '');
|
||||
if (!cleaned || cleaned === '-') return undefined;
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** "1조 2,000억" / "5,000억" / "5000" → 억 단위 숫자. */
|
||||
export function marketCapEok(raw: string | undefined): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const s = String(raw).replace(/\s/g, '');
|
||||
const jo = s.match(/([\d,.]+)조/);
|
||||
const eok = s.match(/(?:조)?([\d,]+)억/);
|
||||
if (jo || eok) {
|
||||
const j = jo ? parseFloat(jo[1].replace(/,/g, '')) : 0;
|
||||
const e = eok ? parseInt(eok[1].replace(/,/g, ''), 10) : 0;
|
||||
const total = j * 10000 + e;
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
return num(s);
|
||||
}
|
||||
|
||||
/** 상장일 'YYYY-MM-DD' → 상장 후 경과 연수. 파싱 불가면 undefined. */
|
||||
function yearsSinceListing(listed: string | undefined, now: Date): number | undefined {
|
||||
if (!listed) return undefined;
|
||||
const d = new Date(listed);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return (now.getTime() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
||||
}
|
||||
|
||||
// '기술력' 도메인 키워드 — 명백히 기술 영역이면 LLM 호출 없이 통과.
|
||||
const TECH_KEYWORDS = /ai|인공지능|반도체|배터리|2차전지|이차전지|바이오|로봇|소프트웨어|플랫폼|클라우드|데이터|센서|팹리스|디스플레이|통신장비|자율주행|드론|우주|방산레이더|보안솔루션/i;
|
||||
|
||||
const fmt = (v: number | undefined, suffix = '%') => (v === undefined ? '-' : `${v.toLocaleString()}${suffix}`);
|
||||
|
||||
/**
|
||||
* 8개 기준 평가. fresh(나버 실시간)가 있으면 그 수치를 우선 사용하고,
|
||||
* 없으면 stocks.json 의 저장 문자열을 파싱한다.
|
||||
*/
|
||||
export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = new Date()): CriteriaEvaluation {
|
||||
const roe = fresh?.roe ?? num(stock['ROE(25E)']);
|
||||
const opm = fresh?.operatingMargin ?? num(stock['영업이익률(25E)']);
|
||||
const ret = fresh?.retentionRatio ?? num(stock.유보율);
|
||||
const pbr = fresh?.pbr ?? num(stock.PBR);
|
||||
const cap = fresh?.marketCapEok ?? marketCapEok(stock.시가총액);
|
||||
const listedYears = yearsSinceListing(stock.상장일, now);
|
||||
const biz = (stock['최대 먹거리'] || '').trim();
|
||||
|
||||
const R = (keyword: string, cond: boolean | undefined, detail: string, label?: string): CriterionResult =>
|
||||
({ keyword, state: cond === undefined ? 'unknown' : cond ? 'pass' : 'fail', detail, label });
|
||||
|
||||
const results: CriterionResult[] = [];
|
||||
|
||||
results.push(R('ROE', roe === undefined ? undefined : roe >= 10,
|
||||
`ROE ${fmt(roe)} (기준 ≥10%${roe !== undefined && roe >= 15 ? ', 15% 이상 우수' : ''})`));
|
||||
|
||||
const growthByMargin = opm === undefined ? undefined : opm >= 15;
|
||||
const growthByListing = listedYears === undefined ? undefined : listedYears <= 3;
|
||||
const growth = growthByMargin === true || growthByListing === true ? true
|
||||
: growthByMargin === undefined && growthByListing === undefined ? undefined : false;
|
||||
results.push(R('성장성', growth,
|
||||
`영업이익률 ${fmt(opm)} (기준 ≥15%) 또는 상장 ${listedYears === undefined ? '미상' : listedYears.toFixed(1) + '년'} (기준 ≤3년)`));
|
||||
|
||||
results.push(R('유동성', ret === undefined ? undefined : ret >= 1000,
|
||||
`유보율 ${fmt(ret)} (기준 ≥1,000%)`));
|
||||
|
||||
const profitImproved = opm !== undefined && opm >= 20;
|
||||
results.push(R('수익성', opm === undefined ? undefined : opm >= 10,
|
||||
`영업이익률 ${fmt(opm)} (기준 ≥10%${profitImproved ? ', 20% 이상 → 수익성 개선' : ''})`,
|
||||
profitImproved ? '수익성 개선' : undefined));
|
||||
|
||||
const eff = opm === undefined || roe === undefined ? undefined : (opm >= 15 && roe >= 8);
|
||||
results.push(R('영업효율', eff, `영업이익률 ${fmt(opm)} ≥15% AND ROE ${fmt(roe)} ≥8%`));
|
||||
|
||||
// 기술력: PBR ≥ 2 는 결정론. 도메인은 키워드 명중 시 결정론, 아니면 LLM 정성 판단.
|
||||
const pbrOk = pbr === undefined ? undefined : pbr >= 2;
|
||||
let tech: CriterionResult;
|
||||
if (pbrOk === false) tech = R('기술력', false, `PBR ${fmt(pbr, '')} < 2 (기술 프리미엄 미인정)`);
|
||||
else if (pbrOk === undefined) tech = R('기술력', undefined, 'PBR 데이터 없음');
|
||||
else if (!biz) tech = R('기술력', false, `PBR ${fmt(pbr, '')} ≥2 이나 '최대 먹거리' 미입력`);
|
||||
else if (TECH_KEYWORDS.test(biz)) tech = R('기술력', true, `PBR ${fmt(pbr, '')} ≥2 + 최대먹거리 '${biz}' 기술영역`);
|
||||
else tech = { keyword: '기술력', state: 'llm', detail: `PBR ${fmt(pbr, '')} ≥2, 최대먹거리 '${biz}' — 기술영역 여부 정성 판단 필요` };
|
||||
results.push(tech);
|
||||
|
||||
results.push(R('안정성',
|
||||
ret === undefined || cap === undefined ? undefined : (ret >= 3000 && cap >= 5000),
|
||||
`유보율 ${fmt(ret)} ≥3,000% AND 시총 ${cap === undefined ? '-' : cap.toLocaleString() + '억'} ≥5,000억`));
|
||||
|
||||
results.push(R('PBR', pbr === undefined ? undefined : pbr <= 1.5,
|
||||
`PBR ${fmt(pbr, '')} (기준 ≤1.5)`));
|
||||
|
||||
return {
|
||||
results,
|
||||
dataSource: fresh ? `Naver 실시간 ${now.toISOString().slice(0, 10)}` : 'stocks.json 저장값',
|
||||
numbers: {
|
||||
ROE: fmt(roe), 영업이익률: fmt(opm), 유보율: fmt(ret),
|
||||
PBR: fmt(pbr, ''), 시가총액: cap === undefined ? '-' : `${cap.toLocaleString()}억`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 판정 + 대표 키워드 선택 (사용자 명시 규칙의 코드화) ─────────────────────
|
||||
const PRIORITY: Record<string, string[]> = {
|
||||
'스윙/중기': ['ROE', '성장성', '유동성', '수익성'],
|
||||
'장기투자': ['성장성', '유동성', '기술력', '영업효율'],
|
||||
'저평가우량주': ['PBR', 'ROE', '성장성', '수익성', '안정성'],
|
||||
};
|
||||
|
||||
export interface Verdict {
|
||||
/** "충족 (ROE, 성장성, 유동성)" / "미충족 (사유: …)" — signalClassifier 계약 유지. */
|
||||
text: string;
|
||||
passed: string[];
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** 기술력의 LLM 정성 판단 결과(techPass)를 반영해 최종 판정·대표 3개를 결정. */
|
||||
export function buildVerdict(ev: CriteriaEvaluation, style: Stock['투자성향'], techPass?: boolean): Verdict {
|
||||
const state = (r: CriterionResult): CriterionState =>
|
||||
r.keyword === '기술력' && r.state === 'llm' ? (techPass === true ? 'pass' : 'fail') : r.state;
|
||||
const passed = ev.results.filter(r => state(r) === 'pass');
|
||||
const failed = ev.results.filter(r => state(r) === 'fail' || state(r) === 'unknown');
|
||||
const passedNames = passed.map(r => r.keyword);
|
||||
|
||||
if (passed.length < 3) {
|
||||
const weak = failed.slice(0, 2).map(r => r.detail).join(' / ') || '데이터 부족';
|
||||
return { text: `미충족 (사유: ${weak})`, passed: passedNames, failed: failed.map(r => r.keyword) };
|
||||
}
|
||||
|
||||
// 대표 3개: 투자성향 우선 키워드 → 나머지 통과 키워드 순.
|
||||
const prio = PRIORITY[style || '스윙/중기'] || PRIORITY['스윙/중기'];
|
||||
const ordered = [
|
||||
...prio.filter(k => passedNames.includes(k)),
|
||||
...passedNames.filter(k => !prio.includes(k)),
|
||||
];
|
||||
const top3 = ordered.slice(0, 3)
|
||||
.map(k => passed.find(r => r.keyword === k)!)
|
||||
.map(r => r.label || r.keyword);
|
||||
return { text: `충족 (${top3.join(', ')})`, passed: passedNames, failed: failed.map(r => r.keyword) };
|
||||
}
|
||||
@@ -1,127 +1,125 @@
|
||||
import { AIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, updateStock } from './stocksStore';
|
||||
import type { Stock } from './types';
|
||||
import { evaluateCriteria, buildVerdict, type CriteriaEvaluation } from './criteriaEval';
|
||||
import type { Fundamentals } from './naverFundamentals';
|
||||
|
||||
/**
|
||||
* `/stocks judge <심볼>` 의 코어 — 종목의 재무 데이터를 LLM 에게 던져
|
||||
* "3/4 필터" 4-criteria (ROE / 성장성 / 유동성 / 수익성) 평가를 받고
|
||||
* stocks.json 의 `"3/4 필터"` 필드를 업데이트.
|
||||
* `/stocks judge <심볼>` 의 코어 — "3/4 필터" 평가.
|
||||
*
|
||||
* LLM 신뢰도 이슈는 사용자가 인지한 trade-off — 자동화 결과를 *제안* 으로 보고
|
||||
* 결과 텍스트에 "자동 평가" prefix 를 박아 수동 판정과 구분 가능하게.
|
||||
* v2.2.211 재설계: 임계값 비교(ROE ≥ 10% 등)는 더 이상 LLM 에게 맡기지 않는다.
|
||||
* 소형 로컬 모델은 "5,800%" 파싱·다중 수치 비교에서 자주 틀리므로,
|
||||
* - 수치 기준 7개 + 충족/미충족 판정 + 대표 키워드 3개 선택 = criteriaEval(코드, 결정론)
|
||||
* - LLM 역할 = ① '기술력' 도메인 정성 판단(키워드 매칭이 모호할 때만)
|
||||
* ② 평가 근거 2-3문장 서술
|
||||
* LLM 이 실패해도 판정은 항상 나온다(근거만 결정론 폴백) — judge 가 LLM 형식
|
||||
* 오류로 실패하던 경로 자체를 제거.
|
||||
*
|
||||
* 출력 형식 (LLM 에게 강제):
|
||||
* 첫 줄: "충족 (ROE, 성장성, 유동성)" 또는 "미충족"
|
||||
* 둘째 줄 ~ : (선택) 이유 한두 줄 — 호출자가 webview 에 같이 보여줌
|
||||
*
|
||||
* `.includes("충족")` 매칭은 unchanged → signalClassifier 가 자동으로 인식.
|
||||
* `.includes("충족")` 매칭(signalClassifier)과 "[자동 평가] 충족 (A, B, C)"
|
||||
* 텍스트 계약은 기존 그대로.
|
||||
*/
|
||||
|
||||
export interface JudgeResult {
|
||||
ok: boolean;
|
||||
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
|
||||
filterText?: string;
|
||||
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */
|
||||
/** 평가 근거 (사용자에게 표시). LLM 서술 또는 결정론 폴백. */
|
||||
rationale?: string;
|
||||
/** 수치 데이터 출처 ('Naver 실시간 YYYY-MM-DD' | 'stocks.json 저장값'). */
|
||||
dataSource?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 가치 평가 보조 도구다. 사용자가 제공한 종목의 *재무 지표만* 보고',
|
||||
'필터 평가를 내린다. 외부 정보 추측 금지 — 주어진 숫자에서만 판단.',
|
||||
'',
|
||||
'**사용 가능한 8개 평가 키워드** (사용자의 실제 분류 패턴):',
|
||||
' - ROE: ROE(25E) ≥ 10% 이면 통과. 15% 이상은 우수 (통과 강하게).',
|
||||
' - 성장성: 영업이익률(25E) ≥ 15% 또는 상장일이 3년 이내 신규성장주.',
|
||||
' - 유동성: 유보율 ≥ 1,000% 이면 통과 (자기자본 충분).',
|
||||
' - 수익성: 영업이익률(25E) ≥ 10% 이면 통과. 20% 이상이면 "수익성 개선" 표기 가능.',
|
||||
' - 영업효율: 영업이익률(25E) ≥ 15% + ROE ≥ 8% 동시 만족 (효율성 시그널).',
|
||||
' - 기술력: "최대 먹거리" 가 AI / 반도체 / 배터리 / 바이오 / 기술 영역이고 PBR ≥ 2 (시장이 기술 premium 인정).',
|
||||
' - 안정성: 유보율 ≥ 3,000% + 시가총액 ≥ 5,000억 (대형주 안전).',
|
||||
' - PBR: PBR ≤ 1.5 이면 통과 (저평가 시그널, 저평가우량주에서 주로 사용).',
|
||||
'',
|
||||
'**투자성향별 우선 적용 키워드:**',
|
||||
' - 스윙/중기: ROE / 성장성 / 유동성 / 수익성 위주.',
|
||||
' - 장기투자: 성장성 / 유동성 / 기술력 / 영업효율 위주.',
|
||||
' - 저평가우량주: PBR / ROE 필수 + (성장성 또는 수익성 또는 안정성) 중 1개.',
|
||||
'',
|
||||
'**판정 규칙:**',
|
||||
' - 위 8개 키워드 중 *3개 이상* 통과 → "충족 (통과한 항목들 콤마 구분)" 형식.',
|
||||
' 예: "충족 (ROE, 성장성, 유동성)" / "충족 (PBR, ROE, 안정성)" / "충족 (성장성, 유동성, 수익성 개선)"',
|
||||
' - 3개 미만 → "미충족 (사유: 핵심 약점 한 줄)"',
|
||||
'',
|
||||
'**키워드 선택 가이드:**',
|
||||
' - 한 종목에서 통과한 키워드 *모두* 나열하지 말고 *그 종목 성격을 가장 잘 보여주는 3개* 만 선택.',
|
||||
' - 투자성향별 우선 키워드를 먼저 포함시키고, 빠진 부분은 다른 키워드로 메움.',
|
||||
'',
|
||||
'**실제 패턴 예시** (학습용 — 사용자가 이미 분류한 종목들):',
|
||||
' - 마녀공장 (ROE 15.6%, 영업이익률 18.0%, 유보율 5,800%, 스윙/중기) → "충족 (ROE, 성장성, 유동성)"',
|
||||
' - 기가비스 (ROE 7.23%, 영업이익률 25.7%, 유보율 4,250%, 신규 상장 2년차, 스윙/중기) → "충족 (성장성, 유동성, 수익성 개선)" (ROE 가 10% 미만이라 빠짐, 대신 수익성 강함)',
|
||||
' - 엔켐 (ROE 12.4%, 영업이익률 8.5%, 유보율 1,250%, 스윙/중기) → "충족 (성장성, 유동성)" (영업이익률이 10% 미만이라 수익성 빠짐)',
|
||||
'',
|
||||
'**출력 형식 (반드시 이대로):**',
|
||||
' 1번째 줄: 위 형식의 판정 문구만 (다른 텍스트 절대 금지)',
|
||||
' 2번째 줄: 빈 줄',
|
||||
' 3번째 줄 이후: 평가 근거 2-3 문장 — 어떤 지표가 어떤 threshold 를 통과/미통과했는지 *구체 수치 인용*.',
|
||||
'당신은 한국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게',
|
||||
'계산한 결과다 — 숫자를 재계산하거나 통과/미통과 판정을 뒤집지 말 것.',
|
||||
'요청된 출력 형식 외의 텍스트를 절대 추가하지 말 것.',
|
||||
].join('\n');
|
||||
|
||||
function buildUserPrompt(s: Stock): string {
|
||||
function buildUserPrompt(name: string, symbol: string, ev: CriteriaEvaluation, askTech: string | null): string {
|
||||
const table = ev.results
|
||||
.map(r => `- ${r.keyword}: ${r.state === 'pass' ? '통과' : r.state === 'fail' ? '미통과' : r.state === 'llm' ? '판단 필요' : '데이터 없음'} — ${r.detail}`)
|
||||
.join('\n');
|
||||
const lines = [
|
||||
`종목: ${s.이름} (${s.심볼})`,
|
||||
`상장일: ${s.상장일 ?? '미상'}`,
|
||||
`투자성향: ${s.투자성향 ?? '미분류'}`,
|
||||
`유보율: ${s.유보율 ?? '-'}`,
|
||||
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
|
||||
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
|
||||
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
|
||||
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
|
||||
`PBR: ${s.PBR ?? '-'}`,
|
||||
`시가총액: ${s.시가총액 ?? '-'}`,
|
||||
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
|
||||
`특이사항: ${s.특이사항 ?? '-'}`,
|
||||
`종목: ${name} (${symbol})`,
|
||||
'',
|
||||
'[계산 결과 — 코드가 임계값을 이미 비교 완료]',
|
||||
table,
|
||||
'',
|
||||
'위 데이터로 4-criteria 필터 판정.',
|
||||
];
|
||||
if (askTech) {
|
||||
lines.push(
|
||||
`[질문 1] 이 종목의 최대 먹거리 '${askTech}' 가 기술 영역(AI/반도체/배터리/바이오/로봇/소프트웨어 등 기술 프리미엄이 인정되는 사업)에 해당하는가?`,
|
||||
'첫 줄에 정확히 `기술력: YES` 또는 `기술력: NO` 로만 답하라.',
|
||||
'',
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`[질문 ${askTech ? '2' : '1'}] 위 계산 결과를 근거로 이 종목의 평가 근거를 2-3문장으로 서술하라.`,
|
||||
'구체 수치를 인용하되 표의 판정을 그대로 따르고, 새 수치·판정을 만들지 말 것.',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function judgeStock(symbol: string): Promise<JudgeResult> {
|
||||
/** LLM 실패 시에도 판정 근거를 제공하는 결정론 폴백. */
|
||||
function fallbackRationale(ev: CriteriaEvaluation): string {
|
||||
const passed = ev.results.filter(r => r.state === 'pass').map(r => r.detail);
|
||||
const failed = ev.results.filter(r => r.state === 'fail').map(r => r.detail);
|
||||
const parts: string[] = [];
|
||||
if (passed.length) parts.push(`통과: ${passed.join(' · ')}`);
|
||||
if (failed.length) parts.push(`미통과: ${failed.join(' · ')}`);
|
||||
return parts.join('\n') || '데이터 부족으로 세부 근거 없음';
|
||||
}
|
||||
|
||||
export async function judgeStock(symbol: string, opts?: { fresh?: Fundamentals }): Promise<JudgeResult> {
|
||||
const store = readStocksStore();
|
||||
const stock = store.find(s => s.심볼 === symbol);
|
||||
if (!stock) {
|
||||
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` };
|
||||
}
|
||||
|
||||
const ai = new AIService();
|
||||
// 1) 결정론 평가 (fresh 수치가 있으면 우선 사용)
|
||||
const ev = evaluateCriteria(stock, opts?.fresh);
|
||||
const techRow = ev.results.find(r => r.keyword === '기술력');
|
||||
const needTechLlm = techRow?.state === 'llm';
|
||||
|
||||
// 2) LLM — 기술력 정성 판단(필요시) + 근거 서술. 실패해도 판정은 계속.
|
||||
let techPass: boolean | undefined;
|
||||
let rationale: string | undefined;
|
||||
try {
|
||||
const ai = new AIService();
|
||||
const result = await ai.chat({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: buildUserPrompt(stock),
|
||||
user: buildUserPrompt(stock.이름, symbol, ev, needTechLlm ? (stock['최대 먹거리'] || '') : null),
|
||||
});
|
||||
if (result.empty || !result.content.trim()) {
|
||||
return { ok: false, error: 'LLM 이 빈 응답 반환' };
|
||||
const content = (result.content || '').trim();
|
||||
if (!result.empty && content) {
|
||||
if (needTechLlm) {
|
||||
const m = content.match(/기술력\s*[::]\s*(YES|NO)/i);
|
||||
if (m) techPass = m[1].toUpperCase() === 'YES';
|
||||
rationale = content.replace(/^.*기술력\s*[::]\s*(YES|NO).*$/im, '').trim() || undefined;
|
||||
} else {
|
||||
rationale = content;
|
||||
}
|
||||
}
|
||||
const lines = result.content.split('\n');
|
||||
const firstLine = (lines[0] || '').trim();
|
||||
const rationale = lines.slice(2).join('\n').trim() || undefined;
|
||||
|
||||
if (!firstLine) return { ok: false, error: 'LLM 응답 형식 오류 (첫 줄 비어있음)' };
|
||||
|
||||
// 텍스트 형식 검증 — "충족" 또는 "미충족" 으로 시작해야 signalClassifier 가 인식.
|
||||
if (!/^(충족|미충족)/.test(firstLine)) {
|
||||
return { ok: false, error: `LLM 응답 형식 오류 (충족/미충족 prefix 없음): ${firstLine.slice(0, 80)}` };
|
||||
}
|
||||
|
||||
// "자동 평가" prefix 박아서 수동/자동 구분 가능하게.
|
||||
const filterText = `[자동 평가] ${firstLine}`;
|
||||
const wrote = updateStock(symbol, { '3/4 필터': filterText });
|
||||
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
|
||||
|
||||
logInfo('Stocks LLM judge 완료.', { symbol, filterText, model: result.model });
|
||||
return { ok: true, filterText, rationale };
|
||||
} catch (e: any) {
|
||||
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
logError('Stocks judge LLM 보조 호출 실패 — 결정론 폴백 사용.', { symbol, error: e?.message ?? String(e) });
|
||||
}
|
||||
if (needTechLlm && techPass === undefined) {
|
||||
// LLM 무응답/형식불량 → 보수적으로 미통과 처리 (지어내지 않음)
|
||||
techPass = false;
|
||||
}
|
||||
if (!rationale) rationale = fallbackRationale(ev);
|
||||
|
||||
// 3) 판정 + 대표 3개 (코드) → 저장
|
||||
const verdict = buildVerdict(ev, stock.투자성향, techPass);
|
||||
const filterText = `[자동 평가] ${verdict.text}`;
|
||||
const wrote = updateStock(symbol, { '3/4 필터': filterText });
|
||||
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
|
||||
|
||||
logInfo('Stocks judge 완료 (결정론 판정).', {
|
||||
symbol, filterText, dataSource: ev.dataSource,
|
||||
passed: verdict.passed.join(','), techLlm: needTechLlm ? String(techPass) : 'n/a',
|
||||
});
|
||||
return { ok: true, filterText, rationale, dataSource: ev.dataSource };
|
||||
}
|
||||
|
||||
@@ -136,13 +136,21 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise<void>
|
||||
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
|
||||
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
|
||||
const symbol = arg.trim();
|
||||
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`);
|
||||
const r = await judgeStock(symbol);
|
||||
// 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 Naver 실시간 수치를 시도.
|
||||
// 실패하면 stocks.json 저장값으로 폴백(결과에 데이터 출처 표기).
|
||||
chunk(view, `\n📡 Naver 펀더멘털 갱신 중: ${symbol}...\n`);
|
||||
let fresh: Fundamentals | undefined;
|
||||
try {
|
||||
fresh = (await fetchAllFundamentals([symbol])).get(symbol) ?? undefined;
|
||||
} catch { /* 폴백 — 저장값 사용 */ }
|
||||
chunk(view, fresh ? '✅ 실시간 수치 확보\n' : '⚠️ 실시간 조회 실패 — 저장값으로 평가\n');
|
||||
chunk(view, `🤖 필터 평가 중 (수치 판정=코드, 근거 서술=LLM)...\n`);
|
||||
const r = await judgeStock(symbol, { fresh });
|
||||
if (!r.ok) {
|
||||
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n`);
|
||||
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n📅 데이터: ${r.dataSource}\n`);
|
||||
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user