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
+68 -10
View File
@@ -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 {