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:
2026-06-10 18:51:54 +09:00
parent 6d06311d60
commit a52bf6ee85
8 changed files with 540 additions and 105 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "astra", "name": "astra",
"version": "2.2.210", "version": "2.2.211",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "astra", "name": "astra",
"version": "2.2.210", "version": "2.2.211",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lmstudio/sdk": "^1.5.0", "@lmstudio/sdk": "^1.5.0",
+6 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -279,6 +279,11 @@
"default": false, "default": false,
"markdownDescription": "`/meet` 액션 아이템을 **Google Calendar** 일정(all-day)으로도 등록할지 여부. **기본 `false`** — Tasks 단독 등록으로 중복 방지 (Tasks 도 캘린더 사이드바에 같이 보이므로 둘 다 켜면 중복). true 로 켜면 Tasks + Calendar 양쪽 모두 등록." "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": { "g1nation.teamVoiceGuide": {
"type": "string", "type": "string",
"default": "", "default": "",
+68 -10
View File
@@ -24,7 +24,7 @@ import {
build4LensPrompt, build4LensPrompt,
} from './prompts/youtubePrompts'; } from './prompts/youtubePrompts';
import { buildWikifyPrompt } from './prompts/wikifyPrompt'; 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 { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
import { import {
addBusinessDays, addBusinessDays,
@@ -534,25 +534,83 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`); chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
return true; return true;
} }
const MAX = 60000; // v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce).
const truncated = transcript.length > MAX; // 단일샷 60K 는 로컬 32K 컨텍스트에서 잘리거나 lost-in-the-middle 로 중간
if (truncated) transcript = transcript.slice(0, MAX); // 안건이 증발하던 원인. 12K 조각별 추출은 입력이 짧아 누락·날조 둘 다 준다.
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`); 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 cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim(); const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.'; const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
let report: string; let report: string;
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
try { try {
const t0 = Date.now(); if (transcript.length <= SINGLE_SHOT_MAX) {
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem); chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
if (!report) throw new Error('LLM 응답이 비어 있습니다.'); const t0 = Date.now();
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`); 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) { } catch (e: any) {
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
return true; 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'); chunk(view, report + '\n\n');
try { try {
+101 -2
View File
@@ -42,6 +42,7 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지) # 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지)
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것. - **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것.
- **근거 인용 의무**: 모든 '결정 사항'과 '액션 아이템'에는 근거가 된 발언 원문 일부(20자 내외)를 따옴표로 함께 적는다(오타는 보정 표기로 인용 가능). **인용할 원문 발언을 녹취록에서 찾을 수 없는 항목은 결정·액션이 아니다** — 그런 항목은 만들지 말거나 오픈 이슈로 내려라. 이 인용은 날조 방지 장치다.
- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다. - **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다.
- **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다. - **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다.
- 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다. - 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다.
@@ -59,7 +60,11 @@ ${metaBlock}
${transcript} ${transcript}
\`\`\` \`\`\`
# 출력 형식 (Output Format — 정확히 이 구조를 유지) ${OUTPUT_FORMAT}`;
}
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
const OUTPUT_FORMAT = `# 출력 형식 (Output Format — 정확히 이 구조를 유지)
# [회의 제목] # [회의 제목]
@@ -80,6 +85,7 @@ ${transcript}
## 2. 리스크 및 이슈 ## 2. 리스크 및 이슈
## 3. 결정 사항 ## 3. 결정 사항
각 결정 끝에 근거 발언을 인용한다: \`- [결정 내용] — 근거: "발언 원문 일부"\`
## 4. 오픈 이슈 ## 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}
\`\`\``;
} }
+174
View File
@@ -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) };
}
+85 -87
View File
@@ -1,127 +1,125 @@
import { AIService } from '../../core/services'; import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils'; import { logError, logInfo } from '../../utils';
import { readStocksStore, updateStock } from './stocksStore'; 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 에게 던져 * `/stocks judge <심볼>` 의 코어 — "3/4 필터" 평가.
* "3/4 필터" 4-criteria (ROE / 성장성 / 유동성 / 수익성) 평가를 받고
* stocks.json 의 `"3/4 필터"` 필드를 업데이트.
* *
* LLM 신뢰도 이슈는 사용자가 인지한 trade-off — 자동화 결과를 *제안* 으로 보고 * v2.2.211 재설계: 임계값 비교(ROE ≥ 10% 등)는 더 이상 LLM 에게 맡기지 않는다.
* 결과 텍스트에 "자동 평가" prefix 를 박아 수동 판정과 구분 가능하게. * 소형 로컬 모델은 "5,800%" 파싱·다중 수치 비교에서 자주 틀리므로,
* - 수치 기준 7개 + 충족/미충족 판정 + 대표 키워드 3개 선택 = criteriaEval(코드, 결정론)
* - LLM 역할 = ① '기술력' 도메인 정성 판단(키워드 매칭이 모호할 때만)
* ② 평가 근거 2-3문장 서술
* LLM 이 실패해도 판정은 항상 나온다(근거만 결정론 폴백) — judge 가 LLM 형식
* 오류로 실패하던 경로 자체를 제거.
* *
* 출력 형식 (LLM 에게 강제): * `.includes("충족")` 매칭(signalClassifier)과 "[자동 평가] 충족 (A, B, C)"
* 첫 줄: "충족 (ROE, 성장성, 유동성)" 또는 "미충족" * 텍스트 계약은 기존 그대로.
* 둘째 줄 ~ : (선택) 이유 한두 줄 — 호출자가 webview 에 같이 보여줌
*
* `.includes("충족")` 매칭은 unchanged → signalClassifier 가 자동으로 인식.
*/ */
export interface JudgeResult { export interface JudgeResult {
ok: boolean; ok: boolean;
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */ /** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
filterText?: string; filterText?: string;
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */ /** 평가 근거 (사용자에게 표시). LLM 서술 또는 결정론 폴백. */
rationale?: string; rationale?: string;
/** 수치 데이터 출처 ('Naver 실시간 YYYY-MM-DD' | 'stocks.json 저장값'). */
dataSource?: string;
error?: string; error?: string;
} }
const SYSTEM_PROMPT = [ 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'); ].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 = [ const lines = [
`종목: ${s.} (${s.})`, `종목: ${name} (${symbol})`,
`상장일: ${s. ?? '미상'}`, '',
`투자성향: ${s. ?? '미분류'}`, '[계산 결과 — 코드가 임계값을 이미 비교 완료]',
`유보율: ${s. ?? '-'}`, table,
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
`PBR: ${s.PBR ?? '-'}`,
`시가총액: ${s. ?? '-'}`,
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
`특이사항: ${s. ?? '-'}`,
'', '',
'위 데이터로 4-criteria 필터 판정.',
]; ];
if (askTech) {
lines.push(
`[질문 1] 이 종목의 최대 먹거리 '${askTech}' 가 기술 영역(AI/반도체/배터리/바이오/로봇/소프트웨어 등 기술 프리미엄이 인정되는 사업)에 해당하는가?`,
'첫 줄에 정확히 `기술력: YES` 또는 `기술력: NO` 로만 답하라.',
'',
);
}
lines.push(
`[질문 ${askTech ? '2' : '1'}] 위 계산 결과를 근거로 이 종목의 평가 근거를 2-3문장으로 서술하라.`,
'구체 수치를 인용하되 표의 판정을 그대로 따르고, 새 수치·판정을 만들지 말 것.',
);
return lines.join('\n'); 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 store = readStocksStore();
const stock = store.find(s => s. === symbol); const stock = store.find(s => s. === symbol);
if (!stock) { if (!stock) {
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` }; 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 { try {
const ai = new AIService();
const result = await ai.chat({ const result = await ai.chat({
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
user: buildUserPrompt(stock), user: buildUserPrompt(stock., symbol, ev, needTechLlm ? (stock['최대 먹거리'] || '') : null),
}); });
if (result.empty || !result.content.trim()) { const content = (result.content || '').trim();
return { ok: false, error: 'LLM 이 빈 응답 반환' }; 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) { } catch (e: any) {
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) }); logError('Stocks judge LLM 보조 호출 실패 — 결정론 폴백 사용.', { symbol, error: e?.message ?? String(e) });
return { ok: false, 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 };
} }
+11 -3
View File
@@ -136,13 +136,21 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise<void>
async function cmdJudge(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; } if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
const symbol = arg.trim(); const symbol = arg.trim();
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`); // 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 Naver 실시간 수치를 시도.
const r = await judgeStock(symbol); // 실패하면 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) { if (!r.ok) {
chunk(view, `\n❌ 평가 실패: ${r.error}\n`); chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
return; 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`); if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
} }
+93
View File
@@ -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);
});
});