v2.2.265: /meet 검수 루프·편집·액션 상세·논의 매듭

/meet 파이프라인 대폭 개선:
- 검수 루프: 회의록 검증 후 지적분만 수정, 통과까지 반복 (전체 재작성 X)
- 편집(Editor): 검수 통과 후 보고서 형태로 구조 정리 (환각 방지 재검증)
- 액션 상세 확장: task 노트에 2~3문장 자기완결 설명 자동 생성
- 주요 논의/쟁점: 각 쟁점을 논점→논의→결론으로 그 자리에서 매듭
- 자동 등록 0건 fix: 기한 없는 확정·기한미정을 작성일(오늘)로 등록 (폴백)

신규 설정:
- g1nation.meetVerifyMaxRounds (기본 2)
- g1nation.meetEditorPass (기본 ON)
- g1nation.meetTaskDateFallback (기본 today)
- g1nation.meetTaskDetailExpand (기본 ON)

신규 프롬프트:
- buildMeetRevisePrompt (지적분만 수정)
- buildMeetEditorPrompt (근거 유지 편집)
- buildTaskDetailPrompt (액션 상세 확장)

신규 파서:
- parseTaskDetails (task 상세 맵핑)

신규 테스트:
- classifyAction dateFallback 동작 5건
- parseTaskDetails 파싱 4건

tsc ✓ · jest 715/715 ✓

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:22:52 +09:00
co-authored by Claude Haiku 4.5
parent 40d003635e
commit 8f1ce4d617
6 changed files with 392 additions and 51 deletions
+121 -26
View File
@@ -27,12 +27,13 @@ import {
build4LensPrompt,
} from './prompts/youtubePrompts';
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt';
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt, buildMeetRevisePrompt, buildMeetEditorPrompt, buildTaskDetailPrompt } from './prompts/meetPrompt';
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
import {
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
savePending, loadPending, classifyAction,
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
parseTaskDetails,
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster,
type PendingItem, type PendingFile,
} from './scheduling/meetRegistration';
@@ -79,7 +80,7 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
}, 10000);
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
BRIDGE_API.web.benchmarkScan,
{
@@ -112,7 +113,15 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
``,
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
'```',
s?.sitemap?.ascii || '(없음)',
// 큰 사이트맵 트리는 채팅을 벽처럼 밀어내므로 상위 25줄만 보이고 나머지는 요약(전체는 저장 파일에).
(() => {
const tree = String(s?.sitemap?.ascii || '').trimEnd();
if (!tree) return '(없음)';
const lines = tree.split('\n');
return lines.length <= 25
? tree
: `${lines.slice(0, 25).join('\n')}\n… (외 ${lines.length - 25}줄 · 총 ${s?.sitemap?.totalPages ?? lines.length}페이지 — 전체 트리는 저장된 보고서 파일 참조)`;
})(),
'```',
].join('\n');
@@ -264,7 +273,7 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
}, 10000);
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
BRIDGE_API.youtube.extract,
@@ -319,7 +328,9 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
const script = fullScriptFromSegments(video?.segments);
chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
// 전체 스크립트는 아래 저장 파일에 그대로 들어가므로, 채팅엔 길이 + 미리보기만 보여 벽 스크롤을 막는다.
const scriptPreview = script.length > 600 ? `${script.slice(0, 600).trimEnd()}…` : script;
chunk(view, `## 📜 전체 스크립트 (${script.length.toLocaleString()}자)\n\n${scriptPreview}\n\n_(전체 스크립트는 저장되는 분석 파일에 포함됩니다.)_\n\n---\n\n`);
const sections: Section[] = [];
if (mode === 'info' || mode === 'both') {
@@ -417,7 +428,7 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
}, 10000);
const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
BRIDGE_API.web.extract,
{ method: 'POST', body: JSON.stringify({ url }) },
@@ -612,6 +623,10 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
// 출력 토큰 상한 — 미지정 시 서버 기본값에 잘려 후반부·긴 표가 통째로 사라지던 "누락"의 주원인.
// 넉넉히 주고(잘리면 callLmSynthesis 가 이어쓰기까지 수행) 완결성을 확보한다. 긴 회의는 설정으로 상향.
const MEET_REPORT_TOKENS = Math.max(2048, cfg.get<number>('meetMaxOutputTokens', 8000) || 8000); // 최종 회의록(단일샷/병합)
const MEET_EXTRACT_TOKENS = 5000; // 세그먼트 추출 노트
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
let report: string;
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
@@ -619,7 +634,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
if (transcript.length <= SINGLE_SHOT_MAX) {
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
const t0 = Date.now();
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem, { maxTokens: MEET_REPORT_TOKENS });
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
} else {
@@ -642,7 +657,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
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);
const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem, { maxTokens: MEET_EXTRACT_TOKENS });
if (!note) throw new Error('추출 결과가 비어 있습니다.');
return note.trim();
} catch (segErr: any) {
@@ -689,7 +704,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// ── Reduce: 노트 병합 → 최종 회의록 ──
chunk(view, ` 🧪 최종 회의록 병합 중…`);
const t1 = Date.now();
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem);
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem, { maxTokens: MEET_REPORT_TOKENS });
if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
}
@@ -708,22 +723,80 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
return true;
}
// ── 검증 패스 (g1nation.meetVerifyPass, 기본 ON) — 결정·액션·화자번호·과확정·중복 점검 ──
// ── 검수 루프 (g1nation.meetVerifyPass, 기본 ON) ─────────────────────────
// 검수자: 녹취록(근거 소스) vs 회의록 대조 → 지적. 지적된 부분만 수정하고 재검수를
// '검증 통과' 또는 최대 라운드까지 반복(전체 재작성 X). 통과 후 편집자가 보고서 형태로 정리.
if (cfg.get<boolean>('meetVerifyPass', true)) {
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`;
const verifySystem = '당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.';
const source = groundingNotes || transcript.slice(0, 28000);
const maxRounds = Math.max(1, Math.min(4, cfg.get<number>('meetVerifyMaxRounds', 2)));
let passed = false;
let lastFlags = '';
for (let round = 1; round <= maxRounds; round++) {
try {
chunk(view, `🔍 **검수 ${round}/${maxRounds}** — 녹취록과 회의록 대조 중…`);
const t2 = Date.now();
const flagged = await callLmSynthesis(buildMeetVerifyPrompt(report, source), verifySystem);
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n`);
if (!flagged || /검증\s*통과/.test(flagged)) {
passed = true;
chunk(view, ` ✅ 검수 통과\n\n`);
break;
}
lastFlags = flagged.trim();
const issueCount = (lastFlags.match(/❗/g) || []).length;
if (round === maxRounds) {
// 마지막 라운드에서도 지적이 남으면 더는 수정하지 않고 미해결로 기록.
chunk(view, ` ⚠️ 지적 ${issueCount || ''}건 미해결 (최대 라운드 도달)\n\n`);
break;
}
chunk(view, ` ✎ 지적 ${issueCount || ''}건 — 해당 부분만 수정 중…`);
const t3 = Date.now();
const revised = await callLmSynthesis(
buildMeetRevisePrompt(report, lastFlags, source), meetSystem, { maxTokens: MEET_REPORT_TOKENS },
);
// 수정본이 비정상적으로 짧으면(모델 붕괴) 이전 회의록을 지킨다.
if (revised && revised.trim().length > report.length * 0.5) {
report = revised.trim();
chunk(view, ` ✓ (${Math.round((Date.now() - t3) / 1000)}s)\n\n`);
} else {
chunk(view, ` ⚠️ 수정본이 비정상적으로 짧아 이전 회의록 유지 → 검수 중단\n\n`);
break;
}
} catch (e: any) {
chunk(view, `\n⚠️ 검수 ${round} 실패(회의록은 유지): ${e?.message || String(e)}\n`);
break;
}
}
if (!passed && lastFlags) {
report += `\n\n---\n## ⚠️ 검수 미해결 지적 (자동 — 확인 필요)\n${lastFlags}\n`;
}
// ── 편집(Editor): 보고서 형태로 구조 정리 (근거 유지·환각 금지) ──
if (cfg.get<boolean>('meetEditorPass', true)) {
try {
chunk(view, `📐 **편집(Editor)** — 보고서 형태로 구조 정리 중…`);
const t4 = Date.now();
const edited = await callLmSynthesis(
buildMeetEditorPrompt(report, source),
'당신은 회의록 편집자입니다. 형식과 구조만 다듬고, 근거 소스에 없는 내용을 새로 만들지 않습니다. 한국어로 출력합니다.',
{ maxTokens: MEET_REPORT_TOKENS },
);
if (edited && edited.trim().length > report.length * 0.6) {
// 편집자가 없던 사실을 넣지 않았는지 1회 재검증(환각 방지 안전망).
const editFlags = await callLmSynthesis(buildMeetVerifyPrompt(edited.trim(), source), verifySystem);
if (!editFlags || /검증\s*통과/.test(editFlags)) {
report = edited.trim();
chunk(view, ` ✓ (${Math.round((Date.now() - t4) / 1000)}s)\n\n`);
} else {
chunk(view, ` ⚠️ 편집본에서 근거 미확인 항목 발견 → 편집 전 회의록 유지\n\n`);
}
} else {
chunk(view, ` ⚠️ 편집본이 비정상적으로 짧아 편집 전 회의록 유지\n\n`);
}
} catch (e: any) {
chunk(view, `\n⚠️ 편집 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
}
} catch (e: any) {
chunk(view, `\n⚠️ 검증 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
}
}
chunk(view, report + '\n\n');
@@ -777,11 +850,33 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만.
// 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단.
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
// 기한 없는 확정·기한미정 액션을 작성일(오늘)로 자동 등록 (기본 ON).
const dateFallbackToday = (gCfg.get<string>('meetTaskDateFallback', 'today') || 'today') === 'today';
const registeredKeys = loadRegisteredKeys(tHash);
const holds: PendingItem[] = [];
let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0;
const newKeys: string[] = [];
// ── 액션 상세 자동 확장 (task 노트용) — v2.2.264 ──
// 슬림 표엔 '작업 상세'가 없어 task 노트가 앙상하던 문제. 배치 LLM 1회로
// 각 액션을 회의 불참자도 이해할 자기완결 설명으로 풀어 씀(근거 기반·환각 금지).
let detailByKey = new Map<string, string>();
if (gCfg.get<boolean>('meetTaskDetailExpand', true)) {
try {
chunk(view, `\n🧾 **액션 상세 정리** — task 설명 풀어쓰는 중…`);
const dsrc = groundingNotes || transcript.slice(0, 28000);
const expanded = await callLmSynthesis(
buildTaskDetailPrompt(tasks, meetTitle, dsrc),
'당신은 회의록에서 각 액션의 실행 설명을 작성합니다. 근거에 없는 내용을 지어내지 않습니다. 한국어로 출력합니다.',
{ maxTokens: MEET_EXTRACT_TOKENS },
);
detailByKey = parseTaskDetails(expanded, tasks);
chunk(view, ` ✓ (${detailByKey.size}/${tasks.length}건)\n`);
} catch (e: any) {
chunk(view, ` ⚠️ 생략(${e?.message || String(e)})\n`);
}
}
chunk(view, `\n📝 **Google ${destLabel} 등록 (확신 게이트)**: 액션 아이템 ${tasks.length}건 분류 중…\n`);
for (const task of tasks) {
const key = taskKey(task.work);
@@ -790,11 +885,11 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`);
continue;
}
const cls = classifyAction(task, meetingDate, today);
const cls = classifyAction(task, meetingDate, today, dateFallbackToday);
if (cls.route === 'hold') {
holds.push({
idx: holds.length + 1,
owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due, source: task.source,
owner: task.owner, work: task.work, detail: detailByKey.get(key) || task.detail, deliverable: task.deliverable, due: task.due, source: task.source,
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
});
continue;
@@ -807,7 +902,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, deliverable: task.deliverable,
detail: detailByKey.get(key) || task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
dueRaw: task.due, dateLabel: cls.date, source: task.source, extra,
});
const r = await registerAction(context, {