feat(meet): 회의록 가이드 v2 반영 + 모델 출력 붕괴 복원력 (v2.2.253)

회의록 출력물 개선 (실무 회의록 가이드 v2):
- 섹션 우선순위 재정렬(①결정 ②액션 ③오픈이슈 ④리스크 ⑤논의)
- 논의사항 주제별 bullet 간결화, 오픈 이슈 섹션 복원
- 액션 아이템 산출물 컬럼 추가(담당·작업·기한·산출물 4요소), 담당자 개인 우선
- Executive Summary 결과 중심, 결정사항은 확정된 것만

모델 출력 붕괴(degeneration) 대응:
- callLmSynthesis 재시도 내장(repeat_penalty↑/top_k↓로 반복 억제 강화) + looksDegenerate 감지
- 긴 녹취 조각 실패 시 절반 분할 재귀 재시도(12K→6K→3.5K)
- 부분 회의록 fallback(한 조각 실패해도 전체 중단 안 함)

하위호환: 액션 표 파서 신6컬럼/구5컬럼 모두 파싱, 섹션 번호 무관 탐지,
회의일 추출 일시/날짜 둘 다 인식. 테스트 +13건(전체 659 통과).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:16:55 +09:00
parent 53953fb5f8
commit 64d8093080
8 changed files with 281 additions and 68 deletions
+61 -11
View File
@@ -619,16 +619,56 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
chunk(view, `🧩 **긴 녹취록 — 2단계 합성** (조각 ${segments.length}× ~${(SEG_SIZE / 1000) | 0}K자, 모델 \`${model}\`)\n`);
const extractSystem = '당신은 회의 녹취 사실 추출기입니다. 제공된 조각에 명시된 내용만 형식대로 추출하고, 없는 사실을 만들지 않습니다. 모든 출력은 한국어입니다.';
const notes: string[] = [];
let failedSegs = 0;
// 약한 모델이 큰 조각에서 출력 붕괴(반복/깨짐)할 때, 그 조각을 줄 경계로
// 절반씩 쪼개 재귀 재시도한다. 입력을 줄이면 붕괴 확률이 떨어지므로 모델
// 교체 없이도 성공률이 오른다. MIN_SEG 이하인데도 실패하면 그 구간만 포기.
const MIN_SEG = 3500;
const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise<string | null> => {
try {
// callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다.
const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem);
if (!note) throw new Error('추출 결과가 비어 있습니다.');
return note.trim();
} catch (segErr: any) {
if (seg.length <= MIN_SEG) {
chunk(view, ` ⚠️ 조각 ${label} 추출 실패(최소 크기 도달, 건너뜀): ${segErr?.message || String(segErr)}\n`);
return null;
}
chunk(view, ` ↩︎ 조각 ${label}(${seg.length.toLocaleString()}자) 출력 붕괴 → 절반으로 쪼개 재시도\n`);
const lines = seg.split('\n');
let cut = 0, acc = 0;
for (; cut < lines.length - 1; cut++) { acc += lines[cut].length + 1; if (acc >= seg.length / 2) break; }
const left = lines.slice(0, cut + 1).join('\n');
const right = lines.slice(cut + 1).join('\n');
if (!left.trim() || !right.trim()) return null; // 더는 못 쪼갬
const parts = [
await extractSeg(left, `${label}a`, idx, total),
await extractSeg(right, `${label}b`, idx, total),
].filter(Boolean) as string[];
return parts.length ? parts.join('\n\n') : null;
}
};
for (let i = 0; i < segments.length; i++) {
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…`);
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…\n`);
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`);
const note = await extractSeg(segments[i], String(i + 1), i + 1, segments.length);
if (note) {
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note}`);
chunk(view, ` ✓ 조각 ${i + 1} 완료 (${Math.round((Date.now() - t0) / 1000)}s)\n`);
} else {
// 한 조각이 끝내 실패해도 전체 회의록을 포기하지 않는다 — 누락을
// 명시하고 나머지 조각으로 부분 회의록을 만든다.
failedSegs++;
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n(이 구간은 모델 출력 오류로 추출하지 못했습니다.)`);
chunk(view, ` ⚠️ 조각 ${i + 1} 추출 실패(건너뜀)\n`);
}
}
if (failedSegs === segments.length) {
throw new Error(`모든 조각(${segments.length}개) 추출에 실패했습니다 — 모델 출력이 계속 붕괴합니다.`);
}
if (failedSegs > 0) {
chunk(view, `\n⚠️ ${segments.length}개 중 ${failedSegs}개 구간을 추출하지 못해 **부분 회의록**으로 진행합니다. 더 큰 모델(예: 27B+) 사용을 권장합니다.\n`);
}
groundingNotes = notes.join('\n\n');
// ── Reduce: 노트 병합 → 최종 회의록 ──
@@ -639,7 +679,17 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
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`);
const msg = e?.message || String(e);
const degen = /붕괴|반복|깨|parse input/i.test(msg);
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${msg}\n`);
if (degen) {
chunk(view, `\n💡 모델 출력이 반복/깨짐(degeneration)으로 붕괴한 것으로 보입니다. 재시도(반복 억제 강화)에도 풀리지 않았습니다.\n`
+ ` · 현재 모델 \`${model}\` 이 긴 한국어 녹취록에는 약할 수 있습니다 — **더 큰 모델(27B+급)** 로 \`g1nation.defaultModel\` 변경을 권장합니다.\n`
+ ` · 또는 녹취록을 더 짧게 나눠 여러 번 \`/meet\` 하면 성공률이 올라갑니다.\n`);
} else {
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`);
}
chunk(view, '\n');
return true;
}
@@ -729,7 +779,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
if (cls.route === 'hold') {
holds.push({
idx: holds.length + 1,
owner: task.owner, work: task.work, detail: task.detail, due: task.due,
owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due,
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
});
continue;
@@ -742,7 +792,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.');
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
const notes = buildNotes({
detail: task.detail, meetTitle, owner: task.owner,
detail: task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
dueRaw: task.due, dateLabel: cls.date, extra,
});
const r = await registerAction(context, {