From 8f1ce4d617bc4064445f4d113284900c86bef28d Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 2 Jul 2026 18:22:52 +0900 Subject: [PATCH] =?UTF-8?q?v2.2.265:=20/meet=20=EA=B2=80=EC=88=98=20?= =?UTF-8?q?=EB=A3=A8=ED=94=84=C2=B7=ED=8E=B8=EC=A7=91=C2=B7=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=83=81=EC=84=B8=C2=B7=EB=85=BC=EC=9D=98=20?= =?UTF-8?q?=EB=A7=A4=EB=93=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- package.json | 33 +++- src/features/datacollect/handlers.ts | 147 ++++++++++++++---- src/features/datacollect/llm.ts | 62 ++++++-- .../datacollect/prompts/meetPrompt.ts | 112 ++++++++++++- .../scheduling/meetRegistration.ts | 33 +++- tests/meetRegistration.test.ts | 56 ++++++- 6 files changed, 392 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index e6ddff0..aa25e0f 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.261", + "version": "2.2.265", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -314,13 +314,42 @@ "g1nation.meetVerifyPass": { "type": "boolean", "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": { "type": "string", "default": "", "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": { "type": "boolean", "default": true, diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index 40dd069..56f3b84 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -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 { 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 { + 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 { 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 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('defaultModel', '') || 'gemma4:e2b').trim(); + // 출력 토큰 상한 — 미지정 시 서버 기본값에 잘려 후반부·긴 표가 통째로 사라지던 "누락"의 주원인. + // 넉넉히 주고(잘리면 callLmSynthesis 가 이어쓰기까지 수행) 완결성을 확보한다. 긴 회의는 설정으로 상향. + const MEET_REPORT_TOKENS = Math.max(2048, cfg.get('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 => { 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('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('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('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('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(); + if (gCfg.get('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, { diff --git a/src/features/datacollect/llm.ts b/src/features/datacollect/llm.ts index a669528..2638712 100644 --- a/src/features/datacollect/llm.ts +++ b/src/features/datacollect/llm.ts @@ -64,14 +64,26 @@ interface LmOpts { retries?: number; /** 기본 샘플링 온도 override. */ 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( lmUrl: string, model: string, sys: string, prompt: string, sampling: { temperature: number; repeat_penalty: number; top_k: number }, -): Promise { - const res = await lmChat(lmUrl, { + maxTokens?: number, +): Promise { + const payload: Record = { model, messages: [ { role: 'system', content: sys }, @@ -81,15 +93,25 @@ async function callLmOnce( top_p: 0.85, top_k: sampling.top_k, repeat_penalty: sampling.repeat_penalty, - }, 120_000); - const content = res?.choices?.[0]?.message?.content - ?? res?.choices?.[0]?.text - ?? res?.answer - ?? res?.response - ?? ''; - return String(content) - .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') - .trim(); + }; + // max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해. + if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens; + const res = await lmChat(lmUrl, payload, 120_000); + const choice = res?.choices?.[0]; + const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? ''; + const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase(); + const text = String(content).replace(SELF_REFLECT_RE, '').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 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; const maxRetries = Math.max(0, opts?.retries ?? 2); + const maxCont = Math.max(0, opts?.maxContinuations ?? 3); let lastErr: unknown = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { // 재시도할수록 반복을 더 강하게 억제: 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 }; 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 (looksDegenerate(out)) { lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.'); 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)) { out = await repairKoreanGlitches(out, lmUrl, model); } diff --git a/src/features/datacollect/prompts/meetPrompt.ts b/src/features/datacollect/prompts/meetPrompt.ts index a0eae80..8df33cb 100644 --- a/src/features/datacollect/prompts/meetPrompt.ts +++ b/src/features/datacollect/prompts/meetPrompt.ts @@ -92,11 +92,12 @@ ${OUTPUT_FORMAT}`; /** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화) 회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. "무엇이 결정됐는가 / 누가(어느 팀이) 무엇을 해야 하는가 / 무엇이 아직 미결인가"를 우선한다. +- **완전성 (누락 금지 — 최우선)**: 실제로 논의·결정·요청·질문된 항목은 **하나도 빠뜨리지 않는다.** '핵심 요약'만 3~5줄 다이제스트로 압축하고, **'주요 논의/결정/액션/오픈이슈' 본문 섹션은 다뤄진 모든 안건을 담아 상세히** 쓴다. 개조식·군더더기 최소화는 *문체*일 뿐 *내용 삭제가 아니다* — 각 항목은 수치·조건·대상·기한 등 핵심 디테일을 넣어 **그 한 줄만 봐도 이해되게(자기완결)** 적는다(맥락 없는 앙상한 뼈대 금지). 섹션이 길어지는 것은 정상이다. - **개조식·명사형 종결 (필수 문체)**: 모든 항목을 개조식으로 — 명사형 또는 '~함·됨·필요·예정·합의·완료·확인' 류로 끝맺는다. **서술형 종결어미('~하였다/했다/합니다/입니다/됩니다/할 예정이다/것으로 보인다') 금지** — 회의록은 일기·기사·서간문이 아니다. 한 항목 = 한 줄, 군더더기·접속사("따라서/그리고/하였으며") 최소화. - 좋은 예: \`- CDN 경로는 위버스 운영툴로 관리 — 합의\` / \`- 보안 솔루션 2개사 PoC·비용 검토 완료, 최종 도입안 미정\` - 나쁜 예: \`- CDN 경로 정보를 위버스 운영툴을 통해 관리하는 방식으로 합의하였습니다.\` / \`- …검토를 완료하였으며, 향후 …결정할 예정이다.\` -- **이슈 → 결정 흐름 (구조 순서)**: 독자가 "무슨 쟁점이 있었고 → 어떤 결정이 났는지"를 따라가도록, '주요 논의 / 쟁점'을 '결정 사항' **앞**에 둔다. **쟁점(질문·대립·선택지)은 '주요 논의'에, 결론(답)은 '결정 사항'에** 나눠 적되 — 같은 *결론*을 양쪽에 반복하지 말 것(쟁점 ≠ 결론, 중복 아님). -- **한 사실은 한 섹션에만**: 같은 항목을 요약·결정·액션에 중복 기재하지 말 것. 핵심 요약은 미리보기가 아니라 다이제스트다. +- **이슈 → 결정 흐름 (구조 순서)**: 독자가 "무슨 쟁점이 있었고 → 어떻게 논의됐고 → 어떻게 됐는지"를 따라가도록, '주요 논의 / 쟁점'을 '결정 사항' **앞**에 둔다. **'주요 논의'는 각 주제를 논점→논의→결론으로 그 자리에서 매듭짓고**(결론은 *상태 한 줄* — 미결/조건부/보류거나, 확정이면 짧게), **'결정 사항'은 그중 확정 결정만** 정본으로 다시 크고 명확하게 모은다. 즉 주요 논의의 '결론'은 *상태 요약*, 결정 사항은 *확정 결정의 정본* — 성격이 달라 상태 수준 겹침은 허용하되, **같은 문장을 통째로 복붙하지는 말 것**. +- **한 사실은 한 섹션에만**: 같은 항목을 통째로 여러 섹션에 반복 기재하지 말 것(예외: 위처럼 주요 논의 '결론' 상태 한 줄 ↔ 결정 사항 정본은 허용). 핵심 요약은 미리보기가 아니라 다이제스트다. - **결정 ↔ 액션 경계**: 담당+행동이 있는 *일감*은 '액션 아이템' 표로 보낸다. '결정 사항'에는 **담당자 없는 순수 방향/정책 결정만** 남긴다(예: "제품 리스트 클릭 이벤트 제거하기로 함"). "구현하기로 함"처럼 누가 할 일이면 액션이다. - **결정 ≠ 검토요청**: "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자"는 결정이 아니다 → 오픈 이슈. 명시적 합의만 결정. - **담당은 팀/역할 우선**: STT 화자번호 매핑이 불확실하므로 개인명보다 **팀/역할**(예: 넥서스개발팀, 기획, UI)로 적는다. 개인이 명단+문맥으로 확실할 때만 병기. "참석자 N" 금지. @@ -124,9 +125,11 @@ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드 - 예) 보안 솔루션 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개). 조각마다 매핑이 엇갈리면 다수·문맥으로 결정하고, 끝내 불명이면 주체 없이 중립 서술. - **전역 헤드라인 추출**: 조각 전체(특히 마지막 조각)를 관통하는 **결론·핵심 요구**를 찾아 '핵심 요약' 맨 앞에 세운다. 한 청크에만 있던 핵심 요구가 묻히지 않게 한다. - **담화 상태 반영**: \`(반박됨)\`·\`(철회됨)\`으로 표시된 가설은 오픈 이슈·결정으로 격상하지 말 것. \`(미합의)\`는 오픈 이슈/논의로. \`(합의)\`만 결정 후보. +- **주요 논의 = 논점→논의→결론**: 각 주제에 대해 노트의 '논의(Discussion)'·'리스크/이슈'를 *논의* 줄로, '결정'·담화상태(합의/미합의/보류)를 *결론* 줄로 엮어 논점→논의→결론을 완성한다. **결정이 없던 쟁점도 '미결·보류' 결론을 반드시 달아** 붕 뜨지 않게 한다(결론 줄 없는 쟁점 금지). - **결정/액션 경계**: 담당+행동이 있는 일감은 액션 표로, 결정엔 순수 방향/정책만. - **의미 dedup**: 표현이 달라도 같은 작업·논점은 **하나로 병합**한다(여러 조각에서 중복 추출된 것). 한 사실은 한 섹션에만. - **충돌 보존**: 수치·의견이 화자간 갈리면 단일값으로 확정하지 말고 "A vs B, 미정"으로 오픈 이슈에 적는다. - **빈 칸은 "—"**, 모든 칸이 미정인 액션은 오픈 이슈로. 리스크는 실제 논의된 경우만 표를 만든다. -- **안건 누락 점검**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제가 결과 문서에서 누락되지 않았는지 확인한다(특히 회의 도입부 첫 안건). +- **안건 누락 점검 (필수)**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제·사실·결정·액션을 **하나도 빠뜨리지 말고** 결과 문서에 반영한다(중복만 병합, 특히 회의 도입부 첫 안건). 출력이 길어지더라도 **항목을 잘라내 분량을 맞추지 말 것** — 완전성이 간결성보다 우선. - 메타데이터와 노트가 충돌하면 메타데이터 우선. [메타데이터] @@ -255,7 +259,7 @@ export function buildMeetVerifyPrompt(report: string, source: string): string { # 점검 항목 1. **근거 미확인**: '결정 사항'·'액션 아이템'의 내용(및 수치·날짜)이 근거 소스에서 확인되는가? 못 찾으면 FLAG. (표기·철자 차이는 무시, 의미로 대조.) 2. **화자번호 잔존**: 회의록 본문에 "참석자 N"/"화자 N"/"발언자 A" 같은 STT 화자번호가 남아 있는가? 있으면 FLAG. -3. **결정 과확정**: '결정 사항'에 든 항목이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(오픈 이슈로 내려야 함). +3. **결정 과확정**: '결정 사항'(또는 '주요 논의'의 **결론** 줄에 \`합의·결정됨\`으로 적힌 것)이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(결론을 '미결'로 내려야 함). 4. **폐기 가설 격상**: 소스에서 즉시 반박·철회된 발언이 회의록의 오픈이슈/리스크/결정으로 격상돼 있는가? 있으면 FLAG. 5. **액션 중복**: 액션 아이템 중 의미가 사실상 동일한데 별개 행으로 중복된 것이 있는가? 있으면 FLAG(병합 필요). @@ -275,3 +279,97 @@ ${report} ${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} +\`\`\``; +} diff --git a/src/features/datacollect/scheduling/meetRegistration.ts b/src/features/datacollect/scheduling/meetRegistration.ts index 83c1705..96d66aa 100644 --- a/src/features/datacollect/scheduling/meetRegistration.ts +++ b/src/features/datacollect/scheduling/meetRegistration.ts @@ -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 suggested = () => { const r = resolveTaskDate(row.due, meetingDate, today); 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)) { return { route: 'hold', kind: 'undecided', suggestedDate: suggested() }; } if (/^기한\s*미정/.test(status)) { + if (dateFallbackToday) return autoWithDue(); return { route: 'hold', kind: 'nodate', suggestedDate: suggested() }; } 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() }; } - // 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 기한미정 보류. + // 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 작성일 폴백 or 보류. const r = resolveTaskDate(row.due, meetingDate, today); if (r.tentative) { + if (dateFallbackToday) return { route: 'auto', date: toYmd(today), pastNote: false }; return { route: 'hold', kind: 'nodate', suggestedDate: toYmd(addBusinessDays(today, 5)) }; } const past = r.date < toYmd(today); @@ -204,6 +213,26 @@ export function buildNotes(p: { detail: string; meetTitle: string; owner: string ].join('\n'); } +/** + * [액션 상세 확장 파싱] buildTaskDetailPrompt 응답("[n] 설명…")을 액션별 설명 맵으로. + * 번호(1-based)를 tasks 인덱스에 대응시키고, 설명 텍스트를 taskKey(work)로 키잉한다. + * 형식이 깨진 응답은 매핑 안 된 항목을 그냥 비워 둔다(폴백: buildNotes 가 placeholder). + */ +export function parseTaskDetails(raw: string, tasks: { work: string }[]): Map { + const map = new Map(); + 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 답변 파싱 ───────────────────────────────────────────────── export type ConfirmDecision = { idx: number; action: 'ok' | 'skip' | 'date'; date?: string }; diff --git a/tests/meetRegistration.test.ts b/tests/meetRegistration.test.ts index 9de0ec6..3f72c33 100644 --- a/tests/meetRegistration.test.ts +++ b/tests/meetRegistration.test.ts @@ -3,7 +3,7 @@ * 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 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 { 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', () => { test('혼합 답변 파싱', () => { const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026);