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
parent 40d003635e
commit 8f1ce4d617
6 changed files with 392 additions and 51 deletions
+31 -2
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.261", "version": "2.2.265",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -314,13 +314,42 @@
"g1nation.meetVerifyPass": { "g1nation.meetVerifyPass": {
"type": "boolean", "type": "boolean",
"default": true, "default": true,
"markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부(기본 ON). 완성된 회의록을 녹취록(또는 추출 노트)과 LLM 으로 대조해 ①근거 미확인 ②\"참석자 N\" 화자번호 잔존 ③결정 과확정(테스트후결정·검토필요) ④폐기 가설 격상 ⑤액션 중복 을 점검하고 `⚠️ 검증 과` 섹션으로 표시한다. LLM 호출이 1회 추가되어 그만큼 느려짐 — 끄려면 false." "markdownDescription": "`/meet` 회의록 생성 후 **검수 루프** 실행 여부(기본 ON). 검수자가 완성된 회의록을 녹취록(또는 추출 노트)과 LLM 으로 대조해 ①근거 미확인 ②\"참석자 N\" 화자번호 잔존 ③결정 과확정(테스트후결정·검토필요) ④폐기 가설 격상 ⑤액션 중복 을 지적하면, **지적된 부분만** 수정하고 `검증 과` 또는 최대 라운드(`meetVerifyMaxRounds`)까지 재검수한다(전체 재작성 X). 통과 후 편집자(`meetEditorPass`)가 보고서 형태로 정리. LLM 호출이 여러 번 추가되어 느려짐 — 끄려면 false."
},
"g1nation.meetVerifyMaxRounds": {
"type": "number",
"default": 2,
"minimum": 1,
"maximum": 4,
"markdownDescription": "`/meet` 검수 루프의 **최대 반복 횟수**(기본 `2`). 검수자가 지적 → 지적분만 수정 → 재검수를 이 횟수까지 반복하며, 도중에 `검증 통과`가 나오면 즉시 종료한다. 끝까지 지적이 남으면 회의록 끝에 `⚠️ 검수 미해결 지적` 섹션으로 남긴다. 라운드마다 LLM 호출 2회(검수+수정)가 추가되므로 로컬 모델에서는 2~3 권장."
},
"g1nation.meetEditorPass": {
"type": "boolean",
"default": true,
"markdownDescription": "`/meet` 검수 통과 후 **편집(Editor) 패스** 실행 여부(기본 ON). 편집자가 회의록 전체를 **보고서 형태**로 구조·가독성만 다듬는다(섹션 정돈·표 정렬·개조식 통일). 근거 소스에 없는 사실을 새로 만들지 않고 기존 항목을 삭제·축약하지 않으며, 편집 직후 1회 재검증으로 환각이 없는지 확인해 문제가 있으면 편집 전 회의록을 유지한다. LLM 호출 2회 추가 — 끄려면 false."
},
"g1nation.meetTaskDateFallback": {
"type": "string",
"default": "today",
"enum": ["today", "hold"],
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
},
"g1nation.meetTaskDetailExpand": {
"type": "boolean",
"default": true,
"markdownDescription": "`/meet` 등록 시 각 액션의 **작업 상세를 자동으로 풀어 씀**(기본 ON). 슬림 액션 표에는 상세 칸이 없어 Google Task 노트가 액션 한 줄만 담겨 앙상했던 문제를 해소한다 — 등록 직전 배치 LLM 1회로 각 액션을 '회의에 참석하지 않은 담당자도 무엇을·왜·어떤 결과물을 목표로 하는지 이해'할 2~3문장 설명으로 풀어 task 노트의 `■ 작업 상세`에 넣는다(근거 소스에 있는 내용만, 환각 금지). LLM 호출 1회 추가 — 끄려면 false(그러면 액션 한 줄만 등록)."
}, },
"g1nation.meetTeamRoster": { "g1nation.meetTeamRoster": {
"type": "string", "type": "string",
"default": "", "default": "",
"markdownDescription": "`/meet` 화자 정규화용 **회사 표준 팀/역할 분류**(쉼표 구분). STT 화자번호(\"참석자 N\")를 실명 추측 대신 이 팀들 중 하나로 귀속해 오귀속을 막는다. 비워두면 기본값(`PD, 기획, 사업, 클라이언트, 넥서스개발팀(서버), UI, 배경팀, 캐릭터팀, QA, 사운드, 개발PM`) 사용. 회사 구조에 맞게 수정하세요." "markdownDescription": "`/meet` 화자 정규화용 **회사 표준 팀/역할 분류**(쉼표 구분). STT 화자번호(\"참석자 N\")를 실명 추측 대신 이 팀들 중 하나로 귀속해 오귀속을 막는다. 비워두면 기본값(`PD, 기획, 사업, 클라이언트, 넥서스개발팀(서버), UI, 배경팀, 캐릭터팀, QA, 사운드, 개발PM`) 사용. 회사 구조에 맞게 수정하세요."
}, },
"g1nation.meetMaxOutputTokens": {
"type": "number",
"default": 8000,
"minimum": 2048,
"markdownDescription": "`/meet` 최종 회의록(단일샷·병합) 생성 시 **출력 토큰 상한**(max_tokens). 기본 `8000`. 이 값을 명시하지 않으면 LM 서버 기본 상한에 잘려 **회의록 후반부·긴 액션표가 통째로 누락**되던 문제를 막는다. 잘림이 감지되면(finish=length) 끊긴 지점부터 자동 이어쓰기까지 수행하므로 이 값은 '한 번에 뽑는 양'이고 최종 길이는 이보다 길어질 수 있다. 아주 긴 회의(2시간+)에서 여전히 뒷부분이 짧다면 12000~16000 으로 올려보세요. (모델 컨텍스트 창을 넘지 않는 선에서)"
},
"g1nation.dailyBriefing.enabled": { "g1nation.dailyBriefing.enabled": {
"type": "boolean", "type": "boolean",
"default": true, "default": true,
+118 -23
View File
@@ -27,12 +27,13 @@ import {
build4LensPrompt, build4LensPrompt,
} from './prompts/youtubePrompts'; } from './prompts/youtubePrompts';
import { buildWikifyPrompt } from './prompts/wikifyPrompt'; 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 { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
import { import {
transcriptHash, taskKey, loadRegisteredKeys, markRegistered, transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
savePending, loadPending, classifyAction, savePending, loadPending, classifyAction,
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions, registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
parseTaskDetails,
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster, loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster,
type PendingItem, type PendingFile, type PendingItem, type PendingFile,
} from './scheduling/meetRegistration'; } from './scheduling/meetRegistration';
@@ -79,7 +80,7 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
const t0 = Date.now(); const t0 = Date.now();
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000); }, 10000);
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>( const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
BRIDGE_API.web.benchmarkScan, 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?.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'); ].join('\n');
@@ -264,7 +273,7 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
const t0 = Date.now(); const t0 = Date.now();
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000); }, 10000);
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000); const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>( const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
BRIDGE_API.youtube.extract, 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`); if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
const script = fullScriptFromSegments(video?.segments); 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[] = []; const sections: Section[] = [];
if (mode === 'info' || mode === 'both') { if (mode === 'info' || mode === 'both') {
@@ -417,7 +428,7 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef
const t0 = Date.now(); const t0 = Date.now();
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); 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 }>( 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, BRIDGE_API.web.extract,
{ method: 'POST', body: JSON.stringify({ url }) }, { 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 cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim(); 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 = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.'; const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
let report: string; let report: string;
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관 let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
@@ -619,7 +634,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
if (transcript.length <= SINGLE_SHOT_MAX) { if (transcript.length <= SINGLE_SHOT_MAX) {
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`); chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
const t0 = Date.now(); 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 응답이 비어 있습니다.'); if (!report) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`); chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
} else { } 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> => { const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise<string | null> => {
try { try {
// callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다. // 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('추출 결과가 비어 있습니다.'); if (!note) throw new Error('추출 결과가 비어 있습니다.');
return note.trim(); return note.trim();
} catch (segErr: any) { } catch (segErr: any) {
@@ -689,7 +704,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// ── Reduce: 노트 병합 → 최종 회의록 ── // ── Reduce: 노트 병합 → 최종 회의록 ──
chunk(view, ` 🧪 최종 회의록 병합 중…`); chunk(view, ` 🧪 최종 회의록 병합 중…`);
const t1 = Date.now(); 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 응답이 비어 있습니다.'); if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`); 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; return true;
} }
// ── 검증 패스 (g1nation.meetVerifyPass, 기본 ON) — 결정·액션·화자번호·과확정·중복 점검 ── // ── 검수 루프 (g1nation.meetVerifyPass, 기본 ON) ─────────────────────────
// 검수자: 녹취록(근거 소스) vs 회의록 대조 → 지적. 지적된 부분만 수정하고 재검수를
// '검증 통과' 또는 최대 라운드까지 반복(전체 재작성 X). 통과 후 편집자가 보고서 형태로 정리.
if (cfg.get<boolean>('meetVerifyPass', true)) { if (cfg.get<boolean>('meetVerifyPass', true)) {
try { const verifySystem = '당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.';
chunk(view, `🔍 **검증 패스** — 결정·액션 근거 대조 중…`);
const t2 = Date.now();
const source = groundingNotes || transcript.slice(0, 28000); const source = groundingNotes || transcript.slice(0, 28000);
const flagged = await callLmSynthesis( const maxRounds = Math.max(1, Math.min(4, cfg.get<number>('meetVerifyMaxRounds', 2)));
buildMeetVerifyPrompt(report, source), 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 },
); );
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n\n`); // 수정본이 비정상적으로 짧으면(모델 붕괴) 이전 회의록을 지킨다.
if (flagged && !/검증\s*통과/.test(flagged)) { if (revised && revised.trim().length > report.length * 0.5) {
report += `\n\n---\n## ⚠️ 검증 결과 (자동)\n${flagged.trim()}\n`; report = revised.trim();
chunk(view, ` ✓ (${Math.round((Date.now() - t3) / 1000)}s)\n\n`);
} else {
chunk(view, ` ⚠️ 수정본이 비정상적으로 짧아 이전 회의록 유지 → 검수 중단\n\n`);
break;
} }
} catch (e: any) { } catch (e: any) {
chunk(view, `\n⚠️ 검증 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`); 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`);
}
} }
} }
chunk(view, report + '\n\n'); chunk(view, report + '\n\n');
@@ -777,11 +850,33 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만. // `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만.
// 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단. // 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단.
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + '); const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
// 기한 없는 확정·기한미정 액션을 작성일(오늘)로 자동 등록 (기본 ON).
const dateFallbackToday = (gCfg.get<string>('meetTaskDateFallback', 'today') || 'today') === 'today';
const registeredKeys = loadRegisteredKeys(tHash); const registeredKeys = loadRegisteredKeys(tHash);
const holds: PendingItem[] = []; const holds: PendingItem[] = [];
let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0; let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0;
const newKeys: string[] = []; 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`); chunk(view, `\n📝 **Google ${destLabel} 등록 (확신 게이트)**: 액션 아이템 ${tasks.length}건 분류 중…\n`);
for (const task of tasks) { for (const task of tasks) {
const key = taskKey(task.work); const key = taskKey(task.work);
@@ -790,11 +885,11 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`); chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`);
continue; continue;
} }
const cls = classifyAction(task, meetingDate, today); const cls = classifyAction(task, meetingDate, today, dateFallbackToday);
if (cls.route === 'hold') { if (cls.route === 'hold') {
holds.push({ holds.push({
idx: holds.length + 1, 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, kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
}); });
continue; continue;
@@ -807,7 +902,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.'); if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.');
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`); if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
const notes = buildNotes({ 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, dueRaw: task.due, dateLabel: cls.date, source: task.source, extra,
}); });
const r = await registerAction(context, { const r = await registerAction(context, {
+49 -13
View File
@@ -64,14 +64,26 @@ interface LmOpts {
retries?: number; retries?: number;
/** 기본 샘플링 온도 override. */ /** 기본 샘플링 온도 override. */
temperature?: number; temperature?: number;
/** 출력 토큰 상한(max_tokens). 미지정이면 payload 에 넣지 않아 서버 기본값을 따른다.
* 회의록처럼 긴 출력은 크게 지정해 서버 기본 상한에 잘리는 것을 막는다. */
maxTokens?: number;
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
maxContinuations?: number;
} }
/** bridge `/api/lm` 단발 호출 — 샘플링 파라미터까지 받는 내부 코어. */ /** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
interface LmResult { text: string; finish: string; }
const SELF_REFLECT_RE = /\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i;
/** LM 단발 호출 — 샘플링/토큰상한을 받고 (정제 텍스트 + finish_reason)을 돌려주는 내부 코어. */
async function callLmOnce( async function callLmOnce(
lmUrl: string, model: string, sys: string, prompt: string, lmUrl: string, model: string, sys: string, prompt: string,
sampling: { temperature: number; repeat_penalty: number; top_k: number }, sampling: { temperature: number; repeat_penalty: number; top_k: number },
): Promise<string> { maxTokens?: number,
const res = await lmChat(lmUrl, { ): Promise<LmResult> {
const payload: Record<string, unknown> = {
model, model,
messages: [ messages: [
{ role: 'system', content: sys }, { role: 'system', content: sys },
@@ -81,15 +93,25 @@ async function callLmOnce(
top_p: 0.85, top_p: 0.85,
top_k: sampling.top_k, top_k: sampling.top_k,
repeat_penalty: sampling.repeat_penalty, repeat_penalty: sampling.repeat_penalty,
}, 120_000); };
const content = res?.choices?.[0]?.message?.content // max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
?? res?.choices?.[0]?.text if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
?? res?.answer const res = await lmChat(lmUrl, payload, 120_000);
?? res?.response const choice = res?.choices?.[0];
?? ''; const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
return String(content) const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
.replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') const text = String(content).replace(SELF_REFLECT_RE, '').trim();
.trim(); return { text, finish };
}
/** 이어쓰기 프롬프트 — 이미 쓴 뒷부분을 주고, 끊긴 지점부터 중복 없이 이어붙일 텍스트만 요청. */
function buildContinuePrompt(soFarTail: string): string {
return '아래는 지금 작성 중이던 문서(회의록/보고서)의 **마지막 부분**이다. 토큰 한도로 중간에 끊겼다.\n'
+ '끊긴 바로 그 지점부터 **이어서** 계속 작성하라. 규칙:\n'
+ '- 이미 쓴 내용을 반복하거나 요약하지 말 것. 새 서두·인사·머리말 금지.\n'
+ '- 끊긴 문장/표 행/섹션을 자연스럽게 이어붙일 **뒷부분만** 출력한다(중복 헤더 재출력 금지).\n'
+ '- 원문서와 동일한 형식·문체(개조식·명사형 종결)를 유지한다.\n\n'
+ '[지금까지 출력의 마지막 부분]\n' + soFarTail;
} }
/** /**
@@ -114,6 +136,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
+ '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
const maxRetries = Math.max(0, opts?.retries ?? 2); const maxRetries = Math.max(0, opts?.retries ?? 2);
const maxCont = Math.max(0, opts?.maxContinuations ?? 3);
let lastErr: unknown = null; let lastErr: unknown = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
// 재시도할수록 반복을 더 강하게 억제: repeat_penalty ↑, top_k ↓. // 재시도할수록 반복을 더 강하게 억제: repeat_penalty ↑, top_k ↓.
@@ -123,12 +146,25 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10 top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
}; };
try { try {
let out = await callLmOnce(lmUrl, model, sys, prompt, sampling); const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens);
let out = first.text;
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; } if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
if (looksDegenerate(out)) { if (looksDegenerate(out)) {
lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.'); lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.');
continue; // 다음 시도에서 반복 억제 강화 continue; // 다음 시도에서 반복 억제 강화
} }
// ── 이어쓰기: 출력이 토큰 상한(finish=length)으로 잘렸으면 끊긴 지점부터 이어붙인다.
// 회의록 후반부·긴 표가 통째로 사라지던 "누락"을 완결성으로 보완한다.
let finish = first.finish;
for (let c = 0; c < maxCont && finish === 'length'; c++) {
let next: LmResult;
try {
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens);
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
out = `${out}\n${next.text}`.trim();
finish = next.finish;
}
if (/[가-힣][a-z]{2,}/.test(out)) { if (/[가-힣][a-z]{2,}/.test(out)) {
out = await repairKoreanGlitches(out, lmUrl, model); out = await repairKoreanGlitches(out, lmUrl, model);
} }
+105 -7
View File
@@ -92,11 +92,12 @@ ${OUTPUT_FORMAT}`;
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */ /** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화) const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화)
회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. "무엇이 결정됐는가 / 누가(어느 팀이) 무엇을 해야 하는가 / 무엇이 아직 미결인가"를 우선한다. 회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. "무엇이 결정됐는가 / 누가(어느 팀이) 무엇을 해야 하는가 / 무엇이 아직 미결인가"를 우선한다.
- **완전성 (누락 금지 — 최우선)**: 실제로 논의·결정·요청·질문된 항목은 **하나도 빠뜨리지 않는다.** '핵심 요약'만 3~5줄 다이제스트로 압축하고, **'주요 논의/결정/액션/오픈이슈' 본문 섹션은 다뤄진 모든 안건을 담아 상세히** 쓴다. 개조식·군더더기 최소화는 *문체*일 뿐 *내용 삭제가 아니다* — 각 항목은 수치·조건·대상·기한 등 핵심 디테일을 넣어 **그 한 줄만 봐도 이해되게(자기완결)** 적는다(맥락 없는 앙상한 뼈대 금지). 섹션이 길어지는 것은 정상이다.
- **개조식·명사형 종결 (필수 문체)**: 모든 항목을 개조식으로 — 명사형 또는 '~함·됨·필요·예정·합의·완료·확인' 류로 끝맺는다. **서술형 종결어미('~하였다/했다/합니다/입니다/됩니다/할 예정이다/것으로 보인다') 금지** — 회의록은 일기·기사·서간문이 아니다. 한 항목 = 한 줄, 군더더기·접속사("따라서/그리고/하였으며") 최소화. - **개조식·명사형 종결 (필수 문체)**: 모든 항목을 개조식으로 — 명사형 또는 '~함·됨·필요·예정·합의·완료·확인' 류로 끝맺는다. **서술형 종결어미('~하였다/했다/합니다/입니다/됩니다/할 예정이다/것으로 보인다') 금지** — 회의록은 일기·기사·서간문이 아니다. 한 항목 = 한 줄, 군더더기·접속사("따라서/그리고/하였으며") 최소화.
- 좋은 예: \`- CDN 경로는 위버스 운영툴로 관리 — 합의\` / \`- 보안 솔루션 2개사 PoC·비용 검토 완료, 최종 도입안 미정\` - 좋은 예: \`- CDN 경로는 위버스 운영툴로 관리 — 합의\` / \`- 보안 솔루션 2개사 PoC·비용 검토 완료, 최종 도입안 미정\`
- 나쁜 예: \`- CDN 경로 정보를 위버스 운영툴을 통해 관리하는 방식으로 합의하였습니다.\` / \`- …검토를 완료하였으며, 향후 …결정할 예정이다.\` - 나쁜 예: \`- CDN 경로 정보를 위버스 운영툴을 통해 관리하는 방식으로 합의하였습니다.\` / \`- …검토를 완료하였으며, 향후 …결정할 예정이다.\`
- **이슈 → 결정 흐름 (구조 순서)**: 독자가 "무슨 쟁점이 있었고 → 어떤 결정이 났는지"를 따라가도록, '주요 논의 / 쟁점'을 '결정 사항' **앞**에 둔다. **쟁점(질문·대립·선택지)은 '주요 논의'에, 결론(답)은 '결정 사항'에** 나눠 적되 — 같은 *결론*을 양쪽에 반복하지 말 것(쟁점 ≠ 결론, 중복 아님). - **이슈 → 결정 흐름 (구조 순서)**: 독자가 "무슨 쟁점이 있었고 → 어떻게 논의됐고 → 어떻게 됐는지"를 따라가도록, '주요 논의 / 쟁점'을 '결정 사항' **앞**에 둔다. **'주요 논의'는 각 주제를 논점→논의→결론으로 그 자리에서 매듭짓고**(결론은 *상태 한 줄* — 미결/조건부/보류거나, 확정이면 짧게), **'결정 사항'은 그중 확정 결정만** 정본으로 다시 크고 명확하게 모은다. 즉 주요 논의의 '결론'은 *상태 요약*, 결정 사항은 *확정 결정의 정본* — 성격이 달라 상태 수준 겹침은 허용하되, **같은 문장을 통째로 복붙하지 말 것**.
- **한 사실은 한 섹션에만**: 같은 항목을 요약·결정·액션에 복 기재하지 말 것. 핵심 요약은 미리보기가 아니라 다이제스트다. - **한 사실은 한 섹션에만**: 같은 항목을 통째로 여러 섹션에 복 기재하지 말 것(예외: 위처럼 주요 논의 '결론' 상태 한 줄 ↔ 결정 사항 정본은 허용). 핵심 요약은 미리보기가 아니라 다이제스트다.
- **결정 ↔ 액션 경계**: 담당+행동이 있는 *일감*은 '액션 아이템' 표로 보낸다. '결정 사항'에는 **담당자 없는 순수 방향/정책 결정만** 남긴다(예: "제품 리스트 클릭 이벤트 제거하기로 함"). "구현하기로 함"처럼 누가 할 일이면 액션이다. - **결정 ↔ 액션 경계**: 담당+행동이 있는 *일감*은 '액션 아이템' 표로 보낸다. '결정 사항'에는 **담당자 없는 순수 방향/정책 결정만** 남긴다(예: "제품 리스트 클릭 이벤트 제거하기로 함"). "구현하기로 함"처럼 누가 할 일이면 액션이다.
- **결정 ≠ 검토요청**: "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자"는 결정이 아니다 → 오픈 이슈. 명시적 합의만 결정. - **결정 ≠ 검토요청**: "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자"는 결정이 아니다 → 오픈 이슈. 명시적 합의만 결정.
- **담당은 팀/역할 우선**: STT 화자번호 매핑이 불확실하므로 개인명보다 **팀/역할**(예: 넥서스개발팀, 기획, UI)로 적는다. 개인이 명단+문맥으로 확실할 때만 병기. "참석자 N" 금지. - **담당은 팀/역할 우선**: STT 화자번호 매핑이 불확실하므로 개인명보다 **팀/역할**(예: 넥서스개발팀, 기획, UI)로 적는다. 개인이 명단+문맥으로 확실할 때만 병기. "참석자 N" 금지.
@@ -124,9 +125,11 @@ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드
- 예) 보안 솔루션 2개사(라인컴퍼니·스틸리언) PoC·비용 검토 완료, 최종 도입안·일정 미정 - 예) 보안 솔루션 2개사(라인컴퍼니·스틸리언) PoC·비용 검토 완료, 최종 도입안·일정 미정
## 주요 논의 / 쟁점 ## 주요 논의 / 쟁점
**결정에 앞서**, 회의에서 다뤄진 안건·쟁점을 **주제별**로 먼저 제시한다(독자가 "무슨 이슈가 있었고 → 어떤 결정이 났는지" 흐름을 따라가게). 발언 나열이 아니라 **쟁점·대립·선택지 중심** 개조식. 결론 자체는 여기 쓰지 말고 '결정 사항'으로 — 여기엔 *질문/논거/미해결*만.논점 끝 [mm:ss]. **결정에 앞서**, 회의에서 다뤄진 안건을 **주제별**로, 각 주제가 **그 자리에서 매듭지어 보이게 "논점 → 논의 → 결론" 3단**으로 적는다(독자가 이 섹션만 읽어도 "무슨 이슈였고 → 어떻게 오갔고 → 그래서 어떻게 됐는지"를 따라가게). 발언을 그대로 옮기지 말고 **요지로 압축**, 개조식·명사형 종결, 끝 [mm:ss].
**[주제명]** **[주제명]**
- 핵심 쟁점/논거 한 줄 (명사형 종결) — [mm:ss] - **논점**: 핵심 쟁점/질문 한 줄 (명사형 종결) — [mm:ss]
- **논의**: 실제 오간 주장·근거·선택지·대립의 요지 (녹취/노트에 있는 내용만 — 없으면 이 줄 생략, 지어내지 말 것) — [mm:ss]
- **결론**: *그래서 어떻게 됐는지* 한 줄로 매듭 — 다음 중 하나로 표기: \`합의·결정됨\`(핵심 결정이면 아래 '결정 사항'에도 정본으로 크게 표기) / \`미결 — 오픈 이슈로\` / \`조건부: <선행>\` / \`보류·다음 회의로\`. **결정 안 났어도 반드시 이 줄로 현재 상태를 남긴다**(붕 뜨는 쟁점 금지) — [mm:ss]
## 결정 사항 ## 결정 사항
위 쟁점에 대해 **명시적으로 합의·확정된 순수 방향/정책 결정만** 적는다(담당 있는 일감은 액션으로). 개조식·명사형 종결, 각 줄 끝 타임스탬프. 위 쟁점에 대해 **명시적으로 합의·확정된 순수 방향/정책 결정만** 적는다(담당 있는 일감은 액션으로). 개조식·명사형 종결, 각 줄 끝 타임스탬프.
@@ -159,7 +162,7 @@ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드
| --- | --- | --- | --- | | --- | --- | --- | --- |
# 최종 점검 (출력 전 내부 확인 — 체크 로그는 출력하지 말 것) # 최종 점검 (출력 전 내부 확인 — 체크 로그는 출력하지 말 것)
□ 모든 문장이 개조식·명사형 종결(서술형 '~하였다/했습니다/할 예정이다' 0개) □ '주요 논의/쟁점'이 '결정 사항'보다 앞에 위치 □ 참석자 명단에 "(참석자 N)" 병기 0개 □ "참석자 N" 토큰 0개 □ 화자가 팀/역할로 귀속(개인 추측 없음) □ 결정사항에 순수 방향/정책만(일감은 액션으로) □ "테스트후결정·검토필요"가 결정에 섞이지 않음 □ 즉시 반박된 가설을 이슈로 격상 안 함 □ 화자간 충돌은 단일값 확정 말고 그대로 표기 □ 같은 사실이 여러 섹션에 복 안 □ 빈 칸은 "—"(추측 채움 없음) □ 통째 미정 항목은 오픈이슈로 □ 액션 상태가 5종 taxonomy 형식 □ 리스크는 실제 논의됐을 때만 표 띄움 □ 모든 결정·액션·논점에 [mm:ss] □ 모든 문장이 개조식·명사형 종결(서술형 '~하였다/했습니다/할 예정이다' 0개) □ '주요 논의/쟁점'이 '결정 사항'보다 앞에 위치 □ 주요 논의 각 주제가 논점→논의→결론으로 매듭(결론 한 줄 없이 붕 뜬 쟁점 0개) □ 참석자 명단에 "(참석자 N)" 병기 0개 □ "참석자 N" 토큰 0개 □ 화자가 팀/역할로 귀속(개인 추측 없음) □ 결정사항에 순수 방향/정책만(일감은 액션으로) □ "테스트후결정·검토필요"가 결정에 섞이지 않음 □ 즉시 반박된 가설을 이슈로 격상 안 함 □ 화자간 충돌은 단일값 확정 말고 그대로 표기 □ 같은 문장을 통째로 여러 섹션에 복함(주요논의 결론↔결정 정본의 상태 수준 겹침은 예외) □ 빈 칸은 "—"(추측 채움 없음) □ 통째 미정 항목은 오픈이슈로 □ 액션 상태가 5종 taxonomy 형식 □ 리스크는 실제 논의됐을 때만 표 띄움 □ 모든 결정·액션·논점에 [mm:ss]
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`; 위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
@@ -227,11 +230,12 @@ export function buildMeetReducePrompt(notes: string, metadata: string): string {
- **화자 역할 통일**: 각 조각의 '화자 역할 매핑'을 종합해 같은 팀/역할 라벨로 통일한다. 최종 문서에 "참석자 N"이 단 하나도 남으면 안 된다(0개). 조각마다 매핑이 엇갈리면 다수·문맥으로 결정하고, 끝내 불명이면 주체 없이 중립 서술. - **화자 역할 통일**: 각 조각의 '화자 역할 매핑'을 종합해 같은 팀/역할 라벨로 통일한다. 최종 문서에 "참석자 N"이 단 하나도 남으면 안 된다(0개). 조각마다 매핑이 엇갈리면 다수·문맥으로 결정하고, 끝내 불명이면 주체 없이 중립 서술.
- **전역 헤드라인 추출**: 조각 전체(특히 마지막 조각)를 관통하는 **결론·핵심 요구**를 찾아 '핵심 요약' 맨 앞에 세운다. 한 청크에만 있던 핵심 요구가 묻히지 않게 한다. - **전역 헤드라인 추출**: 조각 전체(특히 마지막 조각)를 관통하는 **결론·핵심 요구**를 찾아 '핵심 요약' 맨 앞에 세운다. 한 청크에만 있던 핵심 요구가 묻히지 않게 한다.
- **담화 상태 반영**: \`(반박됨)\`·\`(철회됨)\`으로 표시된 가설은 오픈 이슈·결정으로 격상하지 말 것. \`(미합의)\`는 오픈 이슈/논의로. \`(합의)\`만 결정 후보. - **담화 상태 반영**: \`(반박됨)\`·\`(철회됨)\`으로 표시된 가설은 오픈 이슈·결정으로 격상하지 말 것. \`(미합의)\`는 오픈 이슈/논의로. \`(합의)\`만 결정 후보.
- **주요 논의 = 논점→논의→결론**: 각 주제에 대해 노트의 '논의(Discussion)'·'리스크/이슈'를 *논의* 줄로, '결정'·담화상태(합의/미합의/보류)를 *결론* 줄로 엮어 논점→논의→결론을 완성한다. **결정이 없던 쟁점도 '미결·보류' 결론을 반드시 달아** 붕 뜨지 않게 한다(결론 줄 없는 쟁점 금지).
- **결정/액션 경계**: 담당+행동이 있는 일감은 액션 표로, 결정엔 순수 방향/정책만. - **결정/액션 경계**: 담당+행동이 있는 일감은 액션 표로, 결정엔 순수 방향/정책만.
- **의미 dedup**: 표현이 달라도 같은 작업·논점은 **하나로 병합**한다(여러 조각에서 중복 추출된 것). 한 사실은 한 섹션에만. - **의미 dedup**: 표현이 달라도 같은 작업·논점은 **하나로 병합**한다(여러 조각에서 중복 추출된 것). 한 사실은 한 섹션에만.
- **충돌 보존**: 수치·의견이 화자간 갈리면 단일값으로 확정하지 말고 "A vs B, 미정"으로 오픈 이슈에 적는다. - **충돌 보존**: 수치·의견이 화자간 갈리면 단일값으로 확정하지 말고 "A vs B, 미정"으로 오픈 이슈에 적는다.
- **빈 칸은 "—"**, 모든 칸이 미정인 액션은 오픈 이슈로. 리스크는 실제 논의된 경우만 표를 만든다. - **빈 칸은 "—"**, 모든 칸이 미정인 액션은 오픈 이슈로. 리스크는 실제 논의된 경우만 표를 만든다.
- **안건 누락 점검**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제가 결과 문서에서 누락되지 않았는지 확인한다(특히 회의 도입부 첫 안건). - **안건 누락 점검 (필수)**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제·사실·결정·액션을 **하나도 빠뜨리지 말고** 결과 문서에 반영한다(중복만 병합, 특히 회의 도입부 첫 안건). 출력이 길어지더라도 **항목을 잘라내 분량을 맞추지 말 것** — 완전성이 간결성보다 우선.
- 메타데이터와 노트가 충돌하면 메타데이터 우선. - 메타데이터와 노트가 충돌하면 메타데이터 우선.
[메타데이터] [메타데이터]
@@ -255,7 +259,7 @@ export function buildMeetVerifyPrompt(report: string, source: string): string {
# 점검 항목 # 점검 항목
1. **근거 미확인**: '결정 사항'·'액션 아이템'의 내용(및 수치·날짜)이 근거 소스에서 확인되는가? 못 찾으면 FLAG. (표기·철자 차이는 무시, 의미로 대조.) 1. **근거 미확인**: '결정 사항'·'액션 아이템'의 내용(및 수치·날짜)이 근거 소스에서 확인되는가? 못 찾으면 FLAG. (표기·철자 차이는 무시, 의미로 대조.)
2. **화자번호 잔존**: 회의록 본문에 "참석자 N"/"화자 N"/"발언자 A" 같은 STT 화자번호가 남아 있는가? 있으면 FLAG. 2. **화자번호 잔존**: 회의록 본문에 "참석자 N"/"화자 N"/"발언자 A" 같은 STT 화자번호가 남아 있는가? 있으면 FLAG.
3. **결정 과확정**: '결정 사항'에 든 항목이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(오픈 이슈로 내려야 함). 3. **결정 과확정**: '결정 사항'(또는 '주요 논의'의 **결론** 줄에 \`합의·결정됨\`으로 적힌 것)이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(결론을 '미결'로 내려야 함).
4. **폐기 가설 격상**: 소스에서 즉시 반박·철회된 발언이 회의록의 오픈이슈/리스크/결정으로 격상돼 있는가? 있으면 FLAG. 4. **폐기 가설 격상**: 소스에서 즉시 반박·철회된 발언이 회의록의 오픈이슈/리스크/결정으로 격상돼 있는가? 있으면 FLAG.
5. **액션 중복**: 액션 아이템 중 의미가 사실상 동일한데 별개 행으로 중복된 것이 있는가? 있으면 FLAG(병합 필요). 5. **액션 중복**: 액션 아이템 중 의미가 사실상 동일한데 별개 행으로 중복된 것이 있는가? 있으면 FLAG(병합 필요).
@@ -275,3 +279,97 @@ ${report}
${source} ${source}
\`\`\``; \`\`\``;
} }
/**
* [검수 수정] 검증자가 지적한 부분만 고쳐 '수정된 전체 회의록'을 반환한다.
* 핵심: 지적되지 않은 내용은 그대로 보존(재작성 금지), 소스에 없는 사실 추가 금지.
*/
export function buildMeetRevisePrompt(report: string, flagged: string, source: string): string {
return `# 임무
아래 [회의록]에서 [검수 지적사항]에 해당하는 부분**만** 고쳐, 수정된 **전체 회의록**을 출력하라.
# 절대 규칙
- **지적된 부분만** 수정한다. 지적되지 않은 문장·표·섹션·표현은 **한 글자도 바꾸지 말고 그대로** 둔다(전체 재작성·재요약 금지).
- 수정은 [근거 소스]에 맞춘다. 소스에 **없는 새 사실·수치·날짜·항목을 만들어 넣지 말 것**.
- 지적 유형별 처리:
- **[근거]** 소스에서 확인되면 근거 표기만 보강, 확인 안 되면 그 항목을 삭제하거나 '오픈 이슈'로 강등.
- **[화자번호]** "참석자 N"/"화자 N"/"발언자 A"를 팀/역할로 치환(불명이면 주체 없이 중립 서술).
- **[과확정]** '결정 사항'에서 빼서 '오픈 이슈'로 이동.
- **[폐기가설]** 반박·철회된 항목을 결정/오픈이슈/리스크에서 제거.
- **[중복]** 의미가 같은 액션 행을 하나로 병합.
- 출력은 수정된 **회의록 본문 하나**뿐. 머리말·설명·코드펜스(\`\`\`) 금지.
[검수 지적사항]
${flagged}
[근거 소스]
\`\`\`
${source}
\`\`\`
[회의록]
${report}`;
}
/**
* [편집(Editor)] 검수를 통과한 회의록을 보고서 형태로 구조·가독성만 다듬는다.
* 편집자는 작성자가 아니다 — 소스에 없는 사실을 새로 만들지 않고, 기존 항목을 삭제·축약하지 않는다.
*/
export function buildMeetEditorPrompt(report: string, source: string): string {
return `# 임무
아래 [회의록]을 최종 **보고서** 형태로 다듬어 출력하라. 너는 편집자이며 작성자가 아니다.
# 절대 규칙 (환각·누락 금지)
- **형식·구조·가독성만** 개선한다: 섹션 순서 정돈, 표 정렬, 개조식·명사형 종결 통일, 어색한 문장 다듬기.
- [근거 소스]에 **없는 사실·수치·날짜·이름·항목을 절대 새로 만들지 말 것**(편집 ≠ 창작).
- 기존 결정·액션·오픈이슈·논의 항목을 **임의로 삭제·병합·축약하지 말 것** — 모든 정보를 보존한다(완전성 우선).
- 화자번호("참석자 N" 등)가 남아 있으면 팀/역할로 정리한다.
- 기존 섹션 구조(회의 개요 / 핵심 요약 / 주요 논의·쟁점 / 결정 사항 / 액션 아이템(표) / 오픈 이슈 / 리스크)를 **유지**한다. 없는 섹션을 억지로 만들지 않는다.
- 출력은 다듬어진 **보고서 본문 하나**뿐. 머리말·설명·코드펜스(\`\`\`) 금지.
[근거 소스]
\`\`\`
${source}
\`\`\`
[회의록]
${report}`;
}
/**
* [액션 상세 확장 — task 노트용] 슬림 액션 표에는 '작업 상세'가 없어 Google Task 노트가
* 앙상해진다. 이 프롬프트는 각 액션을 '회의 불참자도 무엇을 해야 하는지 이해할' 자기완결
* 설명으로 풀어 쓴다(근거 소스 기반, 환각 금지). 번호는 입력과 1:1로 매핑해 파싱한다.
*/
export function buildTaskDetailPrompt(
actions: { owner: string; work: string; due: string; source: string }[],
meetTitle: string,
source: string,
): string {
const list = actions
.map((a, i) => `[${i + 1}] (담당: ${a.owner || '미지정'} · 기한: ${a.due || '미정'} · 근거: ${a.source || '—'}) ${a.work}`)
.join('\n');
return `# 임무
아래 [액션 목록]의 각 항목을, 이 회의에 **참석하지 않은 담당자도 무엇을 해야 하는지 이해**할 수 있게 풀어서 설명하라. 각 항목마다 2~3문장.
# 규칙
- 각 설명에 담을 것(가능한 범위에서): **무엇을** 하는 작업인지 / **왜**(배경·목적) / **기대 결과물·완료 기준**.
- **[근거 소스]에서 확인되는 내용만** 쓴다. 소스에 없는 수치·인물·마감·결과를 **지어내지 말 것**. 정보가 부족하면 액션 문장을 더 명확히 풀어 쓰는 선에서 멈춘다(억지 창작 금지).
- 액션 제목을 그대로 반복하지 말고 실질 설명을 준다. 회의 화자번호("참석자 N")를 쓰지 말고 팀/역할로.
- 각 설명은 **한 문단(줄바꿈 없이)**.
# 출력 형식 (정확히 이 형식, 번호는 입력과 1:1 대응)
[1] <설명 한 문단>
[2] <설명 한 문단>
[회의] ${meetTitle}
[액션 목록]
${list}
[근거 소스]
\`\`\`
${source}
\`\`\``;
}
@@ -118,17 +118,25 @@ export function nextWeekday(from: Date, korDay: string): Date | null {
} }
/** 액션 한 행을 등록 경로로 분류한다. */ /** 액션 한 행을 등록 경로로 분류한다. */
export function classifyAction(row: ActionRow, meetingDate: Date, today: Date): Classified { export function classifyAction(row: ActionRow, meetingDate: Date, today: Date, dateFallbackToday = false): Classified {
const status = (row.status || '').trim(); const status = (row.status || '').trim();
const suggested = () => { const suggested = () => {
const r = resolveTaskDate(row.due, meetingDate, today); const r = resolveTaskDate(row.due, meetingDate, today);
return r.tentative ? toYmd(addBusinessDays(today, 5)) : r.date; return r.tentative ? toYmd(addBusinessDays(today, 5)) : r.date;
}; };
// 기한이 해석되면 그 날짜, 해석 불가면 '작성일(오늘)'로 자동 등록.
// (g1nation.meetTaskDateFallback='today' — 확정·기한미정이 매번 0건 보류되는 문제 해소)
const autoWithDue = (): Classified => {
const r = resolveTaskDate(row.due, meetingDate, today);
if (!r.tentative) return { route: 'auto', date: r.date, pastNote: r.date < toYmd(today) };
return { route: 'auto', date: toYmd(today), pastNote: false };
};
if (/^진행\s*미정/.test(status)) { if (/^진행\s*미정/.test(status)) {
return { route: 'hold', kind: 'undecided', suggestedDate: suggested() }; return { route: 'hold', kind: 'undecided', suggestedDate: suggested() };
} }
if (/^기한\s*미정/.test(status)) { if (/^기한\s*미정/.test(status)) {
if (dateFallbackToday) return autoWithDue();
return { route: 'hold', kind: 'nodate', suggestedDate: suggested() }; return { route: 'hold', kind: 'nodate', suggestedDate: suggested() };
} }
const cond = status.match(/^조건부\s*[:]?\s*(.*)$/); const cond = status.match(/^조건부\s*[:]?\s*(.*)$/);
@@ -148,9 +156,10 @@ export function classifyAction(row: ActionRow, meetingDate: Date, today: Date):
return { route: 'hold', kind: 'nodate', suggestedDate: suggested() }; return { route: 'hold', kind: 'nodate', suggestedDate: suggested() };
} }
// 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 기한미정 보류. // 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 작성일 폴백 or 보류.
const r = resolveTaskDate(row.due, meetingDate, today); const r = resolveTaskDate(row.due, meetingDate, today);
if (r.tentative) { if (r.tentative) {
if (dateFallbackToday) return { route: 'auto', date: toYmd(today), pastNote: false };
return { route: 'hold', kind: 'nodate', suggestedDate: toYmd(addBusinessDays(today, 5)) }; return { route: 'hold', kind: 'nodate', suggestedDate: toYmd(addBusinessDays(today, 5)) };
} }
const past = r.date < toYmd(today); const past = r.date < toYmd(today);
@@ -204,6 +213,26 @@ export function buildNotes(p: { detail: string; meetTitle: string; owner: string
].join('\n'); ].join('\n');
} }
/**
* [액션 상세 확장 파싱] buildTaskDetailPrompt 응답("[n] 설명…")을 액션별 설명 맵으로.
* 번호(1-based)를 tasks 인덱스에 대응시키고, 설명 텍스트를 taskKey(work)로 키잉한다.
* 형식이 깨진 응답은 매핑 안 된 항목을 그냥 비워 둔다(폴백: buildNotes 가 placeholder).
*/
export function parseTaskDetails(raw: string, tasks: { work: string }[]): Map<string, string> {
const map = new Map<string, string>();
if (!raw || !raw.trim()) return map;
const re = /\[(\d+)\]\s*([\s\S]*?)(?=\n\s*\[\d+\]\s|$)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const idx = Number(m[1]) - 1;
const desc = m[2].trim().replace(/\s*\n\s*/g, ' ').trim();
if (idx >= 0 && idx < tasks.length && desc) {
map.set(taskKey(tasks[idx].work), desc);
}
}
return map;
}
// ── /meet confirm 답변 파싱 ───────────────────────────────────────────────── // ── /meet confirm 답변 파싱 ─────────────────────────────────────────────────
export type ConfirmDecision = { idx: number; action: 'ok' | 'skip' | 'date'; date?: string }; export type ConfirmDecision = { idx: number; action: 'ok' | 'skip' | 'date'; date?: string };
+55 -1
View File
@@ -3,7 +3,7 @@
* 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, * 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회,
* 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지). * 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
*/ */
import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey } from '../src/features/datacollect/scheduling/meetRegistration'; import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey, parseTaskDetails } from '../src/features/datacollect/scheduling/meetRegistration';
import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers'; import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers';
import { looksDegenerate } from '../src/features/datacollect/llm'; import { looksDegenerate } from '../src/features/datacollect/llm';
@@ -64,6 +64,60 @@ describe('classifyAction — 등록 게이트 분기', () => {
}); });
}); });
describe('classifyAction — dateFallbackToday (작성일 폴백)', () => {
test('확정 + 기한 해석 불가 → 작성일(오늘)로 auto', () => {
const c = classifyAction(row('추후 논의', '확정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-11', pastNote: false });
});
test('기한미정 → 작성일(오늘)로 auto', () => {
const c = classifyAction(row('', '기한미정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-11', pastNote: false });
});
test('폴백 켜져도 명시 기한이 있으면 그 날짜 유지', () => {
const c = classifyAction(row('2026-06-20', '확정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-20' });
});
test('폴백 켜져도 진행미정은 여전히 보류', () => {
const c = classifyAction(row('', '진행미정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'hold', kind: 'undecided' });
});
test('폴백 켜져도 조건부는 여전히 보류', () => {
const c = classifyAction(row('', '조건부: 계약 체결 후'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'hold', kind: 'conditional' });
});
});
describe('parseTaskDetails — 액션 상세 확장 파싱', () => {
const tasks = [{ work: '국내외 서비스 사례 분석' }, { work: 'API 연동 방식 검토' }];
test('[n] 블록을 taskKey(work)로 매핑', () => {
const raw = '[1] 무신사·트루핏 등 국내외 사례를 조사해 벤치마킹 표로 정리한다.\n[2] 외부 API 연동 가능 여부와 인증 방식을 확인한다.';
const m = parseTaskDetails(raw, tasks);
expect(m.get(taskKey('국내외 서비스 사례 분석'))).toMatch(/무신사/);
expect(m.get(taskKey('API 연동 방식 검토'))).toMatch(/인증 방식/);
});
test('여러 줄 설명은 한 문단으로 합쳐짐', () => {
const raw = '[1] 첫째 줄.\n 둘째 줄 이어짐.\n[2] 두 번째.';
const m = parseTaskDetails(raw, tasks);
expect(m.get(taskKey('국내외 서비스 사례 분석'))).toBe('첫째 줄. 둘째 줄 이어짐.');
});
test('빈/깨진 응답 → 빈 맵(폴백은 buildNotes placeholder)', () => {
expect(parseTaskDetails('', tasks).size).toBe(0);
expect(parseTaskDetails('설명만 있고 번호 없음', tasks).size).toBe(0);
});
test('입력 범위 밖 번호는 무시', () => {
const m = parseTaskDetails('[1] ok\n[9] 범위밖', tasks);
expect(m.size).toBe(1);
});
});
describe('parseConfirmArgs / normalizeDate', () => { describe('parseConfirmArgs / normalizeDate', () => {
test('혼합 답변 파싱', () => { test('혼합 답변 파싱', () => {
const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026); const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026);