diff --git a/package-lock.json b/package-lock.json index e0ae145..d80a3a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0ee4a4c..1538e29 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index cf41592..e3707be 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -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('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('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 { diff --git a/src/features/datacollect/prompts/meetPrompt.ts b/src/features/datacollect/prompts/meetPrompt.ts index 3465f88..0a2e5f2 100644 --- a/src/features/datacollect/prompts/meetPrompt.ts +++ b/src/features/datacollect/prompts/meetPrompt.ts @@ -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} +\`\`\``; } diff --git a/src/features/stocks/criteriaEval.ts b/src/features/stocks/criteriaEval.ts new file mode 100644 index 0000000..daceeeb --- /dev/null +++ b/src/features/stocks/criteriaEval.ts @@ -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; +} + +// ── 문자열 → 숫자 파싱 (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 = { + '스윙/중기': ['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) }; +} diff --git a/src/features/stocks/llmJudge.ts b/src/features/stocks/llmJudge.ts index 9d161cd..b05d88e 100644 --- a/src/features/stocks/llmJudge.ts +++ b/src/features/stocks/llmJudge.ts @@ -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 { +/** 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 { 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 }; } diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index 2742ee1..c5ee360 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -136,13 +136,21 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise async function cmdJudge(arg: string, view: Webview | undefined): Promise { 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`); } diff --git a/tests/stocksCriteria.test.ts b/tests/stocksCriteria.test.ts new file mode 100644 index 0000000..34b93d1 --- /dev/null +++ b/tests/stocksCriteria.test.ts @@ -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); + }); +});