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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astra",
|
||||
"version": "2.2.210",
|
||||
"version": "2.2.211",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astra",
|
||||
"version": "2.2.210",
|
||||
"version": "2.2.211",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.2.210",
|
||||
"version": "2.2.211",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -279,6 +279,11 @@
|
||||
"default": false,
|
||||
"markdownDescription": "`/meet` 액션 아이템을 **Google Calendar** 일정(all-day)으로도 등록할지 여부. **기본 `false`** — Tasks 단독 등록으로 중복 방지 (Tasks 도 캘린더 사이드바에 같이 보이므로 둘 다 켜면 중복). true 로 켜면 Tasks + Calendar 양쪽 모두 등록."
|
||||
},
|
||||
"g1nation.meetVerifyPass": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부. 결정 사항·액션 아이템을 녹취록(또는 추출 노트)과 LLM 으로 대조해, 근거를 못 찾는 항목을 `⚠️ 검증 결과` 섹션으로 표시한다 (날조 검출). LLM 호출이 1회 추가되어 그만큼 느려짐 — 중요한 회의에만 켜는 것을 권장."
|
||||
},
|
||||
"g1nation.teamVoiceGuide": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
build4LensPrompt,
|
||||
} from './prompts/youtubePrompts';
|
||||
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
|
||||
import { buildMeetPrompt } from './prompts/meetPrompt';
|
||||
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt';
|
||||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||||
import {
|
||||
addBusinessDays,
|
||||
@@ -534,25 +534,83 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
|
||||
return true;
|
||||
}
|
||||
const MAX = 60000;
|
||||
const truncated = transcript.length > MAX;
|
||||
if (truncated) transcript = transcript.slice(0, MAX);
|
||||
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
|
||||
// v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce).
|
||||
// 단일샷 60K 는 로컬 32K 컨텍스트에서 잘리거나 lost-in-the-middle 로 중간
|
||||
// 안건이 증발하던 원인. 12K 조각별 추출은 입력이 짧아 누락·날조 둘 다 준다.
|
||||
const SEG_SIZE = 12000; // 조각 크기 (로컬 컨텍스트에 여유)
|
||||
const SINGLE_SHOT_MAX = 14000; // 이하면 기존 단일샷 경로
|
||||
const MAX_SEGMENTS = 12; // 런타임 상한 (~144K자 — 기존 60K 의 2.4배 커버)
|
||||
const segLimit = SEG_SIZE * MAX_SEGMENTS;
|
||||
const overCap = transcript.length > segLimit;
|
||||
if (overCap) transcript = transcript.slice(0, segLimit);
|
||||
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${overCap ? `, 상한 ${segLimit.toLocaleString()}자 초과로 일부 잘림` : ''})\n\n`);
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
|
||||
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
||||
let report: string;
|
||||
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
|
||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
|
||||
if (transcript.length <= SINGLE_SHOT_MAX) {
|
||||
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
||||
const t0 = Date.now();
|
||||
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
|
||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
|
||||
} else {
|
||||
// ── Map: 줄 경계 기준 조각 분할 → 조각별 사실 추출 ──
|
||||
const segments: string[] = [];
|
||||
let buf = '';
|
||||
for (const line of transcript.split('\n')) {
|
||||
if (buf.length + line.length + 1 > SEG_SIZE && buf) { segments.push(buf); buf = ''; }
|
||||
buf += (buf ? '\n' : '') + line;
|
||||
}
|
||||
if (buf) segments.push(buf);
|
||||
chunk(view, `🧩 **긴 녹취록 — 2단계 합성** (조각 ${segments.length}개 × ~${(SEG_SIZE / 1000) | 0}K자, 모델 \`${model}\`)\n`);
|
||||
const extractSystem = '당신은 회의 녹취 사실 추출기입니다. 제공된 조각에 명시된 내용만 형식대로 추출하고, 없는 사실을 만들지 않습니다. 모든 출력은 한국어입니다.';
|
||||
const notes: string[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…`);
|
||||
const t0 = Date.now();
|
||||
const note = await callLmSynthesis(
|
||||
buildMeetExtractPrompt(segments[i], metadata, i + 1, segments.length),
|
||||
extractSystem,
|
||||
);
|
||||
if (!note) throw new Error(`조각 ${i + 1} 추출 결과가 비어 있습니다.`);
|
||||
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note.trim()}`);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n`);
|
||||
}
|
||||
groundingNotes = notes.join('\n\n');
|
||||
// ── Reduce: 노트 병합 → 최종 회의록 ──
|
||||
chunk(view, ` 🧪 최종 회의록 병합 중…`);
|
||||
const t1 = Date.now();
|
||||
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem);
|
||||
if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 검증 패스 (옵션, g1nation.meetVerifyPass) — 결정·액션을 근거 소스와 대조 ──
|
||||
if (cfg.get<boolean>('meetVerifyPass', false)) {
|
||||
try {
|
||||
chunk(view, `🔍 **검증 패스** — 결정·액션 근거 대조 중…`);
|
||||
const t2 = Date.now();
|
||||
const source = groundingNotes || transcript.slice(0, 28000);
|
||||
const flagged = await callLmSynthesis(
|
||||
buildMeetVerifyPrompt(report, source),
|
||||
'당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.',
|
||||
);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n\n`);
|
||||
if (flagged && !/검증\s*통과/.test(flagged)) {
|
||||
report += `\n\n---\n## ⚠️ 검증 결과 (자동)\n${flagged.trim()}\n`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n⚠️ 검증 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
|
||||
}
|
||||
}
|
||||
chunk(view, report + '\n\n');
|
||||
|
||||
try {
|
||||
|
||||
@@ -42,6 +42,7 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
|
||||
|
||||
# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지)
|
||||
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것.
|
||||
- **근거 인용 의무**: 모든 '결정 사항'과 '액션 아이템'에는 근거가 된 발언 원문 일부(20자 내외)를 따옴표로 함께 적는다(오타는 보정 표기로 인용 가능). **인용할 원문 발언을 녹취록에서 찾을 수 없는 항목은 결정·액션이 아니다** — 그런 항목은 만들지 말거나 오픈 이슈로 내려라. 이 인용은 날조 방지 장치다.
|
||||
- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다.
|
||||
- **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다.
|
||||
- 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다.
|
||||
@@ -59,7 +60,11 @@ ${metaBlock}
|
||||
${transcript}
|
||||
\`\`\`
|
||||
|
||||
# 출력 형식 (Output Format — 정확히 이 구조를 유지)
|
||||
${OUTPUT_FORMAT}`;
|
||||
}
|
||||
|
||||
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
|
||||
const OUTPUT_FORMAT = `# 출력 형식 (Output Format — 정확히 이 구조를 유지)
|
||||
|
||||
# [회의 제목]
|
||||
|
||||
@@ -80,6 +85,7 @@ ${transcript}
|
||||
## 2. 리스크 및 이슈
|
||||
|
||||
## 3. 결정 사항
|
||||
각 결정 끝에 근거 발언을 인용한다: \`- [결정 내용] — 근거: "발언 원문 일부"\`
|
||||
|
||||
## 4. 오픈 이슈
|
||||
|
||||
@@ -89,7 +95,100 @@ ${transcript}
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
- **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지)
|
||||
- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용한다. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것.
|
||||
- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것.
|
||||
|
||||
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
|
||||
|
||||
/**
|
||||
* [세그먼트 추출 단계 — Map] 긴 녹취록(단일 컨텍스트 초과)을 조각으로 나눠
|
||||
* 각 조각에서 사실만 추출한다. 입력이 짧아 모델이 충실해지고(lost-in-the-middle
|
||||
* 방지), 60K 자르기로 후반부가 통째로 사라지던 문제를 없앤다.
|
||||
*/
|
||||
export function buildMeetExtractPrompt(segment: string, metadata: string, segIndex: number, segTotal: number): string {
|
||||
const metaBlock = metadata.trim() || '(메타데이터 없음)';
|
||||
return `# 임무
|
||||
긴 회의 녹취록의 **${segIndex}/${segTotal}번째 조각**이다. 이 조각에 *명시된 내용만* 아래 형식으로 추출하라.
|
||||
최종 회의록은 나중에 모든 조각을 합쳐 작성하므로, 여기서는 요약·해석하지 말고 **누락 없이 추출**하는 것이 임무다.
|
||||
|
||||
# 규칙 (할루시네이션 방지)
|
||||
- 이 조각에 없는 사실·수치·결정을 만들지 말 것. 발언 주체가 불명확하면 "(주체 불명확)"으로 표기.
|
||||
- STT 오타는 문맥과 메타데이터(용어집 역할)로 정규화하되, 없는 사실을 지어내는 것은 금지.
|
||||
- 각 항목 끝에 근거 발언 원문 일부(20자 내외)를 \`근거: "…"\` 로 붙인다.
|
||||
- 조각 경계에서 잘린 문장은 무리하게 해석하지 말고 "(조각 경계에서 잘림)"으로 표기.
|
||||
|
||||
[메타데이터]
|
||||
${metaBlock}
|
||||
|
||||
[녹취록 조각 ${segIndex}/${segTotal}]
|
||||
\`\`\`
|
||||
${segment}
|
||||
\`\`\`
|
||||
|
||||
# 출력 형식 (이 조각에 해당 항목이 없으면 "없음")
|
||||
## 발언자
|
||||
(이 조각에 등장한 발언자 이름/ID 목록)
|
||||
## 사실(Fact)
|
||||
- [발언자] 내용 — 근거: "…"
|
||||
## 논의(Discussion)
|
||||
- [발언자] 내용 — 근거: "…"
|
||||
## 결정(Decision)
|
||||
- 내용 — 근거: "…"
|
||||
## 리스크/이슈
|
||||
- 내용 — 근거: "…"
|
||||
## 액션(Action)
|
||||
- [담당] 작업 내용 (기한: …) — 근거: "…"
|
||||
## 언급된 수치·날짜·금액
|
||||
- 항목: 값 — 근거: "…"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [병합 단계 — Reduce] 조각별 추출 노트를 합쳐 최종 회의록을 작성한다.
|
||||
* 입력은 원문이 아니라 추출 노트이므로, 노트에 없는 내용을 추가하면 안 된다.
|
||||
*/
|
||||
export function buildMeetReducePrompt(notes: string, metadata: string): string {
|
||||
const metaBlock = metadata.trim() || '(메타데이터 미입력 — 노트에서 추론하거나 "확인 불가"로 표기)';
|
||||
return `# 임무
|
||||
긴 회의 녹취록을 조각별로 추출한 노트들이 아래에 있다. 이 노트만 근거로 최종 회의록(Actionable Minutes)을 작성하라.
|
||||
|
||||
# 규칙
|
||||
- **노트에 있는 내용만** 사용한다. 노트에 없는 사실·수치·결정을 추가하지 말 것.
|
||||
- 같은 주제가 여러 조각에 흩어져 있으면 주제별로 다시 묶는다(Topic Reclustering). 단, 서로 다른 발언을 하나의 인과 사슬로 합성하지 말 것.
|
||||
- 발언 주체 귀속을 그대로 유지한다. "(주체 불명확)" 항목에 임의로 이름을 붙이지 말 것.
|
||||
- 중복 항목은 병합하되 근거 인용은 유지한다. 결정(Decision)은 명시적 합의가 노트에 있을 때만 '결정됨'.
|
||||
- 메타데이터와 노트가 충돌하면 메타데이터를 우선한다.
|
||||
|
||||
[메타데이터]
|
||||
${metaBlock}
|
||||
|
||||
[조각별 추출 노트]
|
||||
${notes}
|
||||
|
||||
${OUTPUT_FORMAT}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [검증 패스 — 옵션] 완성된 회의록의 결정·액션·수치가 근거 소스(녹취록 또는
|
||||
* 추출 노트)에 실제로 존재하는지 대조한다. 날조 검출용 2차 방어선.
|
||||
*/
|
||||
export function buildMeetVerifyPrompt(report: string, source: string): string {
|
||||
return `# 임무
|
||||
아래 [회의록]의 '결정 사항'과 '액션 아이템'(및 그 안의 수치·날짜·금액)을 [근거 소스]와 대조하라.
|
||||
|
||||
# 규칙
|
||||
- 각 항목의 내용이 근거 소스에서 확인되면 통과. 찾을 수 없으면 FLAG.
|
||||
- 표기·철자 차이는 무시하고 의미로 대조한다 (STT 보정 감안).
|
||||
- 새 해석·제안을 추가하지 말 것. 판정만 한다.
|
||||
|
||||
# 출력 형식
|
||||
- 모든 항목이 확인되면 정확히 한 줄: \`검증 통과\`
|
||||
- FLAG 가 있으면 항목별로:
|
||||
- ❗ [결정|액션] "<항목 요약>" — 근거 소스에서 확인 불가: <짧은 사유>
|
||||
|
||||
[회의록]
|
||||
${report}
|
||||
|
||||
[근거 소스]
|
||||
\`\`\`
|
||||
${source}
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* criteriaEval — `/stocks judge` 결정론 평가기 테스트.
|
||||
* 픽스처는 옛 LLM 프롬프트에 명시돼 있던 사용자의 실제 분류 예시 3종
|
||||
* (마녀공장/기가비스/엔켐) — 코드 판정이 사용자 패턴과 일치해야 한다.
|
||||
*/
|
||||
import { evaluateCriteria, buildVerdict, marketCapEok } from '../src/features/stocks/criteriaEval';
|
||||
import type { Stock } from '../src/features/stocks/types';
|
||||
|
||||
const NOW = new Date('2026-06-10');
|
||||
|
||||
function judge(stock: Stock, techPass?: boolean) {
|
||||
const ev = evaluateCriteria(stock, undefined, NOW);
|
||||
return { ev, verdict: buildVerdict(ev, stock.투자성향, techPass) };
|
||||
}
|
||||
|
||||
describe('stocks criteriaEval', () => {
|
||||
test('마녀공장 패턴 — 충족 (ROE, 성장성, 유동성)', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '마녀공장', 심볼: '439090', 투자성향: '스윙/중기',
|
||||
'ROE(25E)': '15.6%', '영업이익률(25E)': '18.0%', 유보율: '5,800%',
|
||||
PBR: '1.2', 시가총액: '4,000억',
|
||||
});
|
||||
expect(verdict.text).toBe('충족 (ROE, 성장성, 유동성)');
|
||||
});
|
||||
|
||||
test('기가비스 패턴 — ROE 10% 미만이라 빠지고 수익성 개선 표기', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '기가비스', 심볼: '420770', 투자성향: '스윙/중기',
|
||||
'ROE(25E)': '7.23%', '영업이익률(25E)': '25.7%', 유보율: '4,250%',
|
||||
상장일: '2024-05-24', PBR: '1.3', 시가총액: '3,000억',
|
||||
});
|
||||
// 통과: 성장성(영업이익률≥15), 유동성, 수익성(≥20→개선), PBR — ROE 는 미통과
|
||||
expect(verdict.passed).not.toContain('ROE');
|
||||
expect(verdict.text).toBe('충족 (성장성, 유동성, 수익성 개선)');
|
||||
});
|
||||
|
||||
test('엔켐 패턴 — 통과 2개면 미충족', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '엔켐', 심볼: '348370', 투자성향: '스윙/중기',
|
||||
'ROE(25E)': '12.4%', '영업이익률(25E)': '8.5%', 유보율: '1,250%',
|
||||
PBR: '3.5', 시가총액: '2조 1,000억',
|
||||
});
|
||||
// 통과: ROE, 유동성 (성장성·수익성·영업효율·PBR 미통과, 기술력은 먹거리 미입력→fail)
|
||||
expect(verdict.passed.sort()).toEqual(['ROE', '유동성']);
|
||||
expect(verdict.text).toMatch(/^미충족/);
|
||||
});
|
||||
|
||||
test('저평가우량주 — PBR·ROE 우선 선택', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '가상우량', 심볼: '000001', 투자성향: '저평가우량주',
|
||||
'ROE(25E)': '11%', '영업이익률(25E)': '12%', 유보율: '3,500%',
|
||||
PBR: '0.9', 시가총액: '6,000억',
|
||||
});
|
||||
// 통과: PBR, ROE, 유동성, 수익성, 안정성 → 우선순위로 PBR, ROE 먼저
|
||||
expect(verdict.text).toBe('충족 (PBR, ROE, 수익성)');
|
||||
});
|
||||
|
||||
test('기술력 — 키워드 명중 시 LLM 없이 통과', () => {
|
||||
const { ev } = judge({
|
||||
이름: '테크주', 심볼: '000002', 투자성향: '장기투자',
|
||||
'ROE(25E)': '9%', '영업이익률(25E)': '16%', 유보율: '1,500%',
|
||||
PBR: '2.5', '최대 먹거리': 'AI 반도체 설계',
|
||||
});
|
||||
const tech = ev.results.find(r => r.keyword === '기술력')!;
|
||||
expect(tech.state).toBe('pass');
|
||||
});
|
||||
|
||||
test('기술력 — 도메인 모호하면 llm 상태, techPass 반영', () => {
|
||||
const stock: Stock = {
|
||||
이름: '모호주', 심볼: '000003', 투자성향: '장기투자',
|
||||
'ROE(25E)': '9%', '영업이익률(25E)': '16%', 유보율: '1,500%',
|
||||
PBR: '2.5', '최대 먹거리': '프리미엄 화장품 ODM',
|
||||
};
|
||||
const ev = evaluateCriteria(stock, undefined, NOW);
|
||||
expect(ev.results.find(r => r.keyword === '기술력')!.state).toBe('llm');
|
||||
// techPass=true → 충족에 기여, false → 제외
|
||||
const yes = buildVerdict(ev, '장기투자', true);
|
||||
const no = buildVerdict(ev, '장기투자', false);
|
||||
expect(yes.passed).toContain('기술력');
|
||||
expect(no.passed).not.toContain('기술력');
|
||||
});
|
||||
|
||||
test('데이터 없음(unknown)은 통과로 치지 않는다', () => {
|
||||
const { verdict } = judge({ 이름: '빈데이터', 심볼: '000004' });
|
||||
expect(verdict.text).toMatch(/^미충족/);
|
||||
});
|
||||
|
||||
test('marketCapEok — 조/억 텍스트 파싱', () => {
|
||||
expect(marketCapEok('5,000억')).toBe(5000);
|
||||
expect(marketCapEok('1조 2,000억')).toBe(12000);
|
||||
expect(marketCapEok('2조')).toBe(20000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user