From 64d809308092fc2aa9cecdc6aba627bf54844fdd Mon Sep 17 00:00:00 2001 From: g1nation Date: Wed, 17 Jun 2026 17:16:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(meet):=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20v2=20=EB=B0=98=EC=98=81=20+=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=9C=EB=A0=A5=20=EB=B6=95=EA=B4=B4=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=EB=A0=A5=20(v2.2.253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회의록 출력물 개선 (실무 회의록 가이드 v2): - 섹션 우선순위 재정렬(①결정 ②액션 ③오픈이슈 ④리스크 ⑤논의) - 논의사항 주제별 bullet 간결화, 오픈 이슈 섹션 복원 - 액션 아이템 산출물 컬럼 추가(담당·작업·기한·산출물 4요소), 담당자 개인 우선 - Executive Summary 결과 중심, 결정사항은 확정된 것만 모델 출력 붕괴(degeneration) 대응: - callLmSynthesis 재시도 내장(repeat_penalty↑/top_k↓로 반복 억제 강화) + looksDegenerate 감지 - 긴 녹취 조각 실패 시 절반 분할 재귀 재시도(12K→6K→3.5K) - 부분 회의록 fallback(한 조각 실패해도 전체 중단 안 함) 하위호환: 액션 표 파서 신6컬럼/구5컬럼 모두 파싱, 섹션 번호 무관 탐지, 회의일 추출 일시/날짜 둘 다 인식. 테스트 +13건(전체 659 통과). Co-Authored-By: Claude Opus 4.8 (1M context) --- PATCHNOTES.md | 21 ++++ package.json | 2 +- src/features/datacollect/handlers.ts | 72 +++++++++++-- src/features/datacollect/llm.ts | 100 ++++++++++++++---- .../datacollect/prompts/meetPrompt.ts | 71 +++++++++---- .../datacollect/scheduling/calendarHelpers.ts | 22 ++-- .../scheduling/meetRegistration.ts | 9 +- tests/meetRegistration.test.ts | 52 ++++++++- 8 files changed, 281 insertions(+), 68 deletions(-) diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 6788ed8..f798842 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,26 @@ # Astra Patch Notes +## v2.2.253 (2026-06-17) +### 🪓 /meet 조각 실패 시 절반 분할 재시도 (약한 모델 성공률↑) +- v2.2.252 의 재시도(반복 억제 강화)에도 조각이 계속 붕괴하면, 그 조각을 **줄 경계로 절반씩 쪼개 재귀 재시도**한다(12K→6K→3.5K). 입력이 작아질수록 약한 모델의 출력 붕괴 확률이 떨어지므로, **모델 교체 없이도** 추출 성공률이 오른다. 최소 크기(3.5K) 이하인데도 실패하는 구간만 건너뛴다. ([handlers.ts](src/features/datacollect/handlers.ts)) +- 진행 로그에 분할 재시도 과정을 노출(`↩︎ 조각 1(12,000자) 출력 붕괴 → 절반으로 쪼개 재시도`). +- ⚠️ 근본 원인은 모델(`gemma-4-26b-a4b-it`, 활성 ~4B)이 긴 한국어 처리에 약한 것 — 분할로 완화될 뿐 완치는 **27B+급 또는 한국어 특화 모델(EXAONE/Qwen 등)** 전환 권장. + +## v2.2.252 (2026-06-17) +### 🛡️ /meet 모델 출력 붕괴(degeneration) 대응 + 회의록 가이드 v2 반영 +- **출력 붕괴 복원력**: 약한 로컬 모델이 긴 한국어 녹취록에서 반복 루프·토큰 깨짐("톤을 톤을 톤을…", 깨진 유니코드)에 빠져 LM 서버가 `Failed to parse input`을 던지던 문제. `callLmSynthesis`에 **재시도 내장**(시도마다 `repeat_penalty`↑ 1.1→1.25→1.4, `top_k`↓ 20→15→10으로 반복 억제 강화) + **degeneration 감지**(`looksDegenerate`) 추가. 모든 datacollect LLM 호출이 혜택. ([llm.ts](src/features/datacollect/llm.ts)) +- **부분 회의록 fallback**: 긴 녹취 2단계 합성에서 한 조각이 끝내 실패해도 전체를 중단하지 않고 해당 구간만 표시 후 나머지로 **부분 회의록** 생성. 전 조각 실패 시에만 중단하고, 더 큰 모델(27B+) 사용을 안내. ([handlers.ts](src/features/datacollect/handlers.ts)) +- **회의록 가이드 v2**: 섹션 우선순위 재정렬(①결정 ②액션 ③오픈이슈 ④리스크 ⑤논의), **논의사항을 주제별 bullet**로 간결화, **오픈 이슈 섹션 복원**, 액션 아이템에 **산출물 컬럼 추가**(담당·작업·기한·산출물 4요소), 담당자 개인 우선(조직 단위 지양), Executive Summary 결과 중심. ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts)) +- **하위호환**: 액션 표 파서가 신6컬럼/구5컬럼 모두 파싱, 섹션 번호 무관 탐지. 캘린더 확신 게이트·근거 인용 유지. 테스트 +13건(파서·degeneration 감지), 전체 659 통과. + +## v2.2.251 (2026-06-17) +### 📝 /meet 회의록 출력물 개선 (실무 회의록 가이드 반영) +- **결정사항 ↔ 논의사항 분리(최우선 원칙)**: 기존 "주요 논의 사항" 안에 결정이 섞이고 "결정 사항"과 중복되던 구조를, **결정 사항(명시 합의만) / 논의 사항(추가 검토 필요)**로 명확히 분리. +- **구조 재편**: ① 회의 개요(+**회의 목적** 신규) → ② 주요 결과(Executive Summary, "비참석자가 이것만 읽고 결과 파악" 기준) → ③ 결정 사항 → ④ 논의 사항(안건별 현황/핵심논의/추가검토) → ⑤ **리스크 및 검토 사항(리스크·영향도·대응방안 표)** → ⑥ 액션 아이템. +- **결과 중심 서술**: 발언 나열 금지(누가 무슨 말 했는지 X), 책임 소재·입장 차이가 핵심일 때만 발언자 표기. 사실 중심·증빙 보존 원칙과 출력 전 품질 체크리스트를 프롬프트에 내재화. ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts)) +- **다운스트림 하위호환**: 회의일 추출 `**날짜**`→`**일시**`(둘 다 인식), 액션 섹션 파서를 번호 무관으로 변경(`(?:\d+\.)?액션 아이템`) — 캘린더 확신 게이트·근거 인용·발언 귀속 안전장치 전부 유지. ([calendarHelpers.ts](src/features/datacollect/scheduling/calendarHelpers.ts)) +- 회귀 테스트 32건(meetRegistration·calendarApi) 통과, 타입체크 0 에러. + ## v2.2.213 (2026-06-11) ### 🧠 Self-Evolving Digital Employee OS P0~P6 (신규 모듈 17종, `src/intelligence/`) - **신뢰성 코어**: Requirement Graph(업무별 필수 요소 체크리스트 주입 + 답변 커버리지 footer) · Confidence Engine(확신도 0~100 footer) · Escalation Engine(저확신·충돌·출처누락 시 검토 요청) · Epistemic Guard(모름/추정/확실 3분류 강제) · Provenance(출처 수정일·오래됨 경고) · Critic Loop(문제 신호 turn 만 LLM 검수 1회) diff --git a/package.json b/package.json index 128c45c..d83d5b0 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.250", + "version": "2.2.253", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index cf84233..81a0ae9 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -619,16 +619,56 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. chunk(view, `🧩 **긴 녹취록 — 2단계 합성** (조각 ${segments.length}개 × ~${(SEG_SIZE / 1000) | 0}K자, 모델 \`${model}\`)\n`); const extractSystem = '당신은 회의 녹취 사실 추출기입니다. 제공된 조각에 명시된 내용만 형식대로 추출하고, 없는 사실을 만들지 않습니다. 모든 출력은 한국어입니다.'; const notes: string[] = []; + let failedSegs = 0; + // 약한 모델이 큰 조각에서 출력 붕괴(반복/깨짐)할 때, 그 조각을 줄 경계로 + // 절반씩 쪼개 재귀 재시도한다. 입력을 줄이면 붕괴 확률이 떨어지므로 모델 + // 교체 없이도 성공률이 오른다. MIN_SEG 이하인데도 실패하면 그 구간만 포기. + const MIN_SEG = 3500; + const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise => { + try { + // callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다. + const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem); + if (!note) throw new Error('추출 결과가 비어 있습니다.'); + return note.trim(); + } catch (segErr: any) { + if (seg.length <= MIN_SEG) { + chunk(view, ` ⚠️ 조각 ${label} 추출 실패(최소 크기 도달, 건너뜀): ${segErr?.message || String(segErr)}\n`); + return null; + } + chunk(view, ` ↩︎ 조각 ${label}(${seg.length.toLocaleString()}자) 출력 붕괴 → 절반으로 쪼개 재시도\n`); + const lines = seg.split('\n'); + let cut = 0, acc = 0; + for (; cut < lines.length - 1; cut++) { acc += lines[cut].length + 1; if (acc >= seg.length / 2) break; } + const left = lines.slice(0, cut + 1).join('\n'); + const right = lines.slice(cut + 1).join('\n'); + if (!left.trim() || !right.trim()) return null; // 더는 못 쪼갬 + const parts = [ + await extractSeg(left, `${label}a`, idx, total), + await extractSeg(right, `${label}b`, idx, total), + ].filter(Boolean) as string[]; + return parts.length ? parts.join('\n\n') : null; + } + }; for (let i = 0; i < segments.length; i++) { - chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…`); + chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…\n`); const t0 = Date.now(); - const note = await callLmSynthesis( - buildMeetExtractPrompt(segments[i], metadata, i + 1, segments.length), - extractSystem, - ); - if (!note) throw new Error(`조각 ${i + 1} 추출 결과가 비어 있습니다.`); - notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note.trim()}`); - chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n`); + const note = await extractSeg(segments[i], String(i + 1), i + 1, segments.length); + if (note) { + notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note}`); + chunk(view, ` ✓ 조각 ${i + 1} 완료 (${Math.round((Date.now() - t0) / 1000)}s)\n`); + } else { + // 한 조각이 끝내 실패해도 전체 회의록을 포기하지 않는다 — 누락을 + // 명시하고 나머지 조각으로 부분 회의록을 만든다. + failedSegs++; + notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n(이 구간은 모델 출력 오류로 추출하지 못했습니다.)`); + chunk(view, ` ⚠️ 조각 ${i + 1} 추출 실패(건너뜀)\n`); + } + } + if (failedSegs === segments.length) { + throw new Error(`모든 조각(${segments.length}개) 추출에 실패했습니다 — 모델 출력이 계속 붕괴합니다.`); + } + if (failedSegs > 0) { + chunk(view, `\n⚠️ ${segments.length}개 중 ${failedSegs}개 구간을 추출하지 못해 **부분 회의록**으로 진행합니다. 더 큰 모델(예: 27B+) 사용을 권장합니다.\n`); } groundingNotes = notes.join('\n\n'); // ── Reduce: 노트 병합 → 최종 회의록 ── @@ -639,7 +679,17 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`); } } catch (e: any) { - chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); + const msg = e?.message || String(e); + const degen = /붕괴|반복|깨|parse input/i.test(msg); + chunk(view, `\n\n⚠️ 회의록 합성 실패: ${msg}\n`); + if (degen) { + chunk(view, `\n💡 모델 출력이 반복/깨짐(degeneration)으로 붕괴한 것으로 보입니다. 재시도(반복 억제 강화)에도 풀리지 않았습니다.\n` + + ` · 현재 모델 \`${model}\` 이 긴 한국어 녹취록에는 약할 수 있습니다 — **더 큰 모델(27B+급)** 로 \`g1nation.defaultModel\` 변경을 권장합니다.\n` + + ` · 또는 녹취록을 더 짧게 나눠 여러 번 \`/meet\` 하면 성공률이 올라갑니다.\n`); + } else { + chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`); + } + chunk(view, '\n'); return true; } @@ -729,7 +779,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. if (cls.route === 'hold') { holds.push({ idx: holds.length + 1, - owner: task.owner, work: task.work, detail: task.detail, due: task.due, + owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due, kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate, }); continue; @@ -742,7 +792,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.'); if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`); const notes = buildNotes({ - detail: task.detail, meetTitle, owner: task.owner, + detail: task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable, dueRaw: task.due, dateLabel: cls.date, extra, }); const r = await registerAction(context, { diff --git a/src/features/datacollect/llm.ts b/src/features/datacollect/llm.ts index 6b8038f..f8af14a 100644 --- a/src/features/datacollect/llm.ts +++ b/src/features/datacollect/llm.ts @@ -14,20 +14,35 @@ import * as vscode from 'vscode'; import { bridgeFetch, BRIDGE_API } from './bridgeClient'; /** - * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출. - * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다. + * 모델 출력 붕괴(degeneration) 감지 — 작은/약한 모델이 긴 한국어 입력에서 + * 반복 루프나 토큰 깨짐(예: "톤을 톤을 톤을…", "서비스 기프트 서비스 기프트…", + * 깨진 유니코드)에 빠지는 경우를 잡는다. 이런 출력은 사용 불가이므로 재시도 트리거. */ -export async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise { - const cfg = vscode.workspace.getConfiguration('g1nation'); - const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, ''); - const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); - const temperature = Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1)); - const baseSys = systemPrompt - || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.'; - const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n' - + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n' - + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n' - + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; +export function looksDegenerate(text: string): boolean { + if (!text) return false; + // 1) 대체문자(���) — 인코딩/토큰 깨짐의 확실한 신호 + if (/�/.test(text)) return true; + const compact = text.replace(/\s+/g, ' '); + // 2) 같은 구절(3~20자)이 5회 이상 반복(사이 공백 허용) — 단, 표 구분선·기호 반복은 제외 + const rep = compact.match(/(.{3,20}?)(?:\s*\1){4,}/); + if (rep && !/^[\s\-|=_.·•*#]+$/.test(rep[1])) return true; + // 3) 한글+영문 깨짐 합성 표기가 과다 (정상 교정 한도를 크게 초과) + if ((text.match(/[가-힣][a-z]{2,}/gi) || []).length > 12) return true; + return false; +} + +interface LmOpts { + /** 실패·빈응답·degeneration 시 재시도 횟수 (기본 2 = 최대 3회 시도). */ + retries?: number; + /** 기본 샘플링 온도 override. */ + temperature?: number; +} + +/** bridge `/api/lm` 단발 호출 — 샘플링 파라미터까지 받는 내부 코어. */ +async function callLmOnce( + lmUrl: string, model: string, sys: string, prompt: string, + sampling: { temperature: number; repeat_penalty: number; top_k: number }, +): Promise { const res = await bridgeFetch(BRIDGE_API.lm.proxy, { method: 'POST', body: JSON.stringify({ @@ -38,10 +53,10 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string): Pr { role: 'system', content: sys }, { role: 'user', content: prompt }, ], - temperature, + temperature: sampling.temperature, top_p: 0.85, - top_k: 20, - repeat_penalty: 1.1, + top_k: sampling.top_k, + repeat_penalty: sampling.repeat_penalty, }, }), }, { timeoutMs: 120_000 }); @@ -50,13 +65,58 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string): Pr ?? res?.answer ?? res?.response ?? ''; - let out = String(content) + return String(content) .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') .trim(); - if (/[가-힣][a-z]{2,}/.test(out)) { - out = await repairKoreanGlitches(out, lmUrl, model); +} + +/** + * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 호출. + * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다. + * + * v2.2.252: 약한 로컬 모델의 출력 붕괴(반복·깨짐)와 LM 서버 일시 오류에 대비해 + * 재시도를 내장한다. 재시도할수록 repeat_penalty 를 올리고 top_k 를 좁혀 반복 + * 루프를 깬다. 모든 시도가 실패하면 마지막 에러를 그대로 throw (호출부가 처리). + */ +export async function callLmSynthesis(prompt: string, systemPrompt?: string, opts?: LmOpts): Promise { + const cfg = vscode.workspace.getConfiguration('g1nation'); + const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, ''); + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + const baseTemp = opts?.temperature ?? Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1)); + const baseSys = systemPrompt + || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.'; + const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n' + + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n' + + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n' + + '- 같은 단어·구절을 반복하지 말고, 한 항목을 다 쓰면 다음 항목으로 진행하시오 (반복 루프 금지).\n' + + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; + + const maxRetries = Math.max(0, opts?.retries ?? 2); + let lastErr: unknown = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + // 재시도할수록 반복을 더 강하게 억제: repeat_penalty ↑, top_k ↓. + const sampling = { + temperature: baseTemp, + repeat_penalty: 1.1 + attempt * 0.15, // 1.1 → 1.25 → 1.4 + top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10 + }; + try { + let out = await callLmOnce(lmUrl, model, sys, prompt, sampling); + if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; } + if (looksDegenerate(out)) { + lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.'); + continue; // 다음 시도에서 반복 억제 강화 + } + if (/[가-힣][a-z]{2,}/.test(out)) { + out = await repairKoreanGlitches(out, lmUrl, model); + } + return out; + } catch (e) { + lastErr = e; + // bridge/LM 서버 오류 — 다음 시도로 (degeneration 으로 인한 서버측 parse 실패 포함) + } } - return out; + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); } /** diff --git a/src/features/datacollect/prompts/meetPrompt.ts b/src/features/datacollect/prompts/meetPrompt.ts index f7f3c69..6837f9b 100644 --- a/src/features/datacollect/prompts/meetPrompt.ts +++ b/src/features/datacollect/prompts/meetPrompt.ts @@ -64,45 +64,68 @@ ${OUTPUT_FORMAT}`; } /** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */ -const OUTPUT_FORMAT = `# 출력 형식 (Output Format — 정확히 이 구조를 유지) +const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화) +회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. 회의 내용 자체보다 "무엇이 결정됐는가 / 누가 무엇을 해야 하는가 / 무엇이 아직 결정 안 됐는가"를 우선한다. +- **정보 우선순위**: ①결정사항 ②액션아이템 ③오픈 이슈 ④리스크 ⑤논의사항 순으로 중요하다. 분량·정성도 이 순서로 배분한다. +- **사실 중심**: 추정·해석·평가가 아니라 회의에서 실제 논의·확정된 것만 적는다. (예: "API 연동이 가능할 것으로 판단됨"❌ → "API 연동 가능 여부 검토 필요"✅) +- **결정사항 ↔ 논의사항 구분(최우선)**: 명시적으로 합의·확정된 것만 '결정 사항'에 둔다. "검토 필요/정의 필요/활용 검토"처럼 *확정되지 않은 것*은 결정사항이 아니다 — 논의 사항·오픈 이슈로 내린다. 반대로 실제 확정된 것을 결정사항에서 누락하지도 말 것. +- **결과 중심**: 발언 순서·토론 과정·"누가 무슨 말을 했는지"를 나열하지 말고 *결과*를 적는다. (예: "PlayCanvas를 논의함"❌ → "PlayCanvas/Babylon.js 비교 검토 진행 예정"✅) 책임 소재나 입장 차이가 핵심일 때만 발언자를 밝힌다. +- **담당자는 개인 우선**: "개발팀/QA팀" 같은 조직 단위가 아니라 개인 이름(예: 송병준, 김원일 PD)으로 적는다. 실제로 개인이 안 정해졌으면 "개발팀 (담당자 지정 필요)" 형태로 표기. +- **증빙 보존**: 결정되지 않은 사항이라도 중요한 논의·리스크는 빠짐없이 기록한다(향후 이력·분쟁 근거). + +# 출력 형식 (Output Format — 정확히 이 구조를 유지) # [회의 제목] -- **날짜**: [YYYY년 MM월 DD일 | 확인 불가] +## 1. 회의 개요 +- **일시**: [YYYY년 MM월 DD일 | 확인 불가] - **참석자**: [메타데이터 기준 | 없을 경우: 논의 참여 주체] -- **주제 요약**: [한 문장 요약] +- **회의 목적**: [이 회의가 왜 열렸는지 한 문장. 녹취록에서 드러난 목적만 적고, 불명확하면 "확인 필요"] -## 🔹 요약 보고 -핵심 논의 요약 3~5개를 글머리표로 작성. - -## 1. 주요 논의 사항 -각 안건마다 아래 구조로: -### [안건 제목] -- **현황**: -- **핵심 논의**: 쟁점이 되거나 주체가 중요한 발언은 "OOO: ~" 형태로 발언자를 밝힌다. 주체가 불명확하면 이름을 붙이지 말 것. -- **결론**: [결정됨 / 논의 중 / 보류] - -## 2. 리스크 및 이슈 +## 2. 주요 결과 (Executive Summary) +회의 *결과*를 3~5줄 글머리표로 요약한다. 회의 내용을 설명하지 말고(예: "Babylon.js를 검토함"❌) 결과를 적는다(예: "테스트 샘플 3종 선정", "CCOC 1차 작업 6/19까지 진행"✅). **참석하지 않은 이해관계자가 이 항목만 읽어도 결과를 파악할 수 있어야 한다.** ## 3. 결정 사항 +**명시적으로 합의·확정된 것만** 적는다 (일정 확정·개발 범위 확정·정책 변경·후속 방향 확정 등). 검토·정의·도입여부가 *필요*한 단계의 것은 여기 넣지 말고 '오픈 이슈'나 '논의 사항'으로 내린다. 각 결정 끝에 근거 발언을 인용한다: \`- [결정 내용] — 근거: "발언 원문 일부"\` +(이번 회의에서 확정된 결정이 없으면 "이번 회의에서 확정된 결정사항 없음"이라고 명시한다 — 빈칸·생략 금지.) -## 4. 오픈 이슈 - -## 5. 액션 아이템 -각 행은 반드시 녹취록 근거로 작성한다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것. -| 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 | -| --- | --- | --- | --- | --- | +## 4. 액션 아이템 +회의 후 수행할 업무를 **담당·작업·기한·산출물 4요소**가 모두 드러나게 정리한다. 각 행은 반드시 녹취록 근거로 작성한다. 담당자는 개인 우선(없으면 "OO팀 (담당자 지정 필요)"). 회의에서 기한·산출물이 안 나왔으면 해당 칸에 "확인 필요"라고 적는다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것. +| 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 | +| --- | --- | --- | --- | --- | --- | - **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지) -- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것. +- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 명시. 단순히 작업명을 반복하지 말 것. +- **산출물**: 이 작업이 끝나면 나오는 구체적 결과물(예: 테스트 URL 목록, 비교 분석 문서, 일정표). 회의에서 안 나왔으면 "확인 필요". - **상태**: 다음 중 정확히 하나로 분류한다 (캘린더 자동 등록 게이트가 이 값으로 분기하므로 형식 엄수): - \`확정\` — 진행 합의가 명시적이고 기한도 언급됨. - \`진행미정\` — 작업이 언급됐으나 실제로 진행할지 합의가 명확하지 않음. - \`기한미정\` — 하기로 확정됐으나 완료일/D-Day 가 정해지지 않음. - \`조건부: <선행작업>\` — 다른 작업·사건이 끝나야 진행 (선행작업을 짧게 명시. 예: \`조건부: 계약 체결 후\`). - \`반복: <주기>\` — 정기 반복 업무 (예: \`반복: 매주 목요일\`). - 확신이 없으면 \`확정\`이 아니라 \`진행미정\`/\`기한미정\` 쪽으로 보수적으로 분류한다. + 실제로 합의·기한이 나왔으면 \`확정\`으로 잡고, 정말 안 나온 것만 보수적으로 \`진행미정\`/\`기한미정\`으로 둔다. + +## 5. 오픈 이슈 +회의 종료 시점에 **아직 결정되지 않은 미결 사항**을 한곳에 모아 글머리표로 정리한다 (도입 여부·정의 필요·확정 대기 등). 분산시키지 말고 여기서 한눈에 보이게 한다. +- 예) Babylon.js 도입 여부 / 최소 지원 사양 정의 / 데이터 입력 구조 확정 / 가우시안 스플래팅 도입 여부 + +## 6. 리스크 및 검토 사항 +프로젝트 진행에 영향을 줄 수 있는 요소를 표로 정리한다(일정 지연·개발 난이도·정책/보안·외부 의존성 등). 식별된 리스크가 없으면 "현재 식별된 리스크 없음"이라고 적는다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것. +| 리스크 | 영향 | 대응 방안 | +| --- | --- | --- | + +## 7. 논의 사항 +결정되지 않았으나 오간 논의를 **주제별 글머리표**로 간결하게 정리한다. (현황/핵심 논의/추가 검토 같은 하위 구조로 길게 늘이지 말 것.) 발언 나열 금지, 결과·쟁점 중심. +**[주제명]** +- 핵심 논점 한 줄 +- 핵심 논점 한 줄 + +**[다른 주제명]** +- 핵심 논점 한 줄 + +# 최종 점검 (출력 전 내부 확인 — 체크 로그는 출력하지 말 것) +□ 결정사항에 확정된 것만 있는가(검토 필요 항목이 섞이지 않았나) □ 액션 아이템에 담당(개인)·작업·기한·산출물 4요소가 있는가 □ 오픈 이슈가 한곳에 모였는가 □ 리스크가 영향·대응방안과 함께 정리됐는가 □ 논의사항이 주제별 bullet로 간결한가 □ 요약이 결과 중심인가 □ 결정·액션에 근거 인용이 붙어 있는가 위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`; @@ -132,6 +155,8 @@ ${segment} \`\`\` # 출력 형식 (이 조각에 해당 항목이 없으면 "없음") +## 회의 목적 +(이 조각에서 회의의 목적·배경이 드러나면 한 줄로. 안 드러나면 "없음") ## 발언자 (이 조각에 등장한 발언자 이름/ID 목록) ## 사실(Fact) @@ -143,7 +168,7 @@ ${segment} ## 리스크/이슈 - 내용 — 근거: "…" ## 액션(Action) -- [담당] 작업 내용 (기한: …) — 근거: "…" +- [담당(개인 우선)] 작업 내용 (기한: … / 산출물: …) — 근거: "…" ## 언급된 수치·날짜·금액 - 항목: 값 — 근거: "…"`; } diff --git a/src/features/datacollect/scheduling/calendarHelpers.ts b/src/features/datacollect/scheduling/calendarHelpers.ts index 694aeb5..477ee6a 100644 --- a/src/features/datacollect/scheduling/calendarHelpers.ts +++ b/src/features/datacollect/scheduling/calendarHelpers.ts @@ -31,9 +31,9 @@ export function toYmd(d: Date): string { return `${y}-${m}-${day}`; } -/** 회의록 본문의 "**날짜**: 2026년 05월 08일"에서 회의 날짜 추출. 없으면 fallback. */ +/** 회의록 본문의 "**일시**: 2026년 05월 08일"(구 형식 "**날짜**:" 도 호환)에서 회의 날짜 추출. 없으면 fallback. */ export function extractMeetingDate(report: string, fallback: Date): Date { - const m = report.match(/날짜\*{0,2}\s*[::]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/); + const m = report.match(/(?:일시|날짜)\*{0,2}\s*[::]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/); if (m) { const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); if (!isNaN(d.getTime())) return d; @@ -75,11 +75,11 @@ export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { * 5열 신표(담당 | 작업 내용 | 작업 상세 | 기한 | 상태) · 4열(상태 없음) · * 구(舊) 3열 표(담당 | 작업 내용 | 기한)를 모두 지원한다. 누락 컬럼은 빈 문자열. */ -export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string; status: string }[] { - const rows: { owner: string; work: string; detail: string; due: string; status: string }[] = []; +export function parseActionItems(report: string): { owner: string; work: string; detail: string; deliverable: string; due: string; status: string }[] { + const rows: { owner: string; work: string; detail: string; deliverable: string; due: string; status: string }[] = []; let inSection = false; for (const line of report.split('\n')) { - if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; } + if (/^#{1,6}\s*(?:\d+\.\s*)?액션\s*아이템/.test(line)) { inSection = true; continue; } if (!inSection) continue; if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료 if (!/^\s*\|/.test(line)) continue; @@ -87,12 +87,16 @@ export function parseActionItems(report: string): { owner: string; work: string; if (cells.length < 3) continue; if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선 if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더 - if (cells.length >= 5) { - rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3], status: cells[4] }); + if (cells.length >= 6) { + // 신 형식: 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 + rows.push({ owner: cells[0], work: cells[1], detail: cells[2], deliverable: cells[3], due: cells[4], status: cells[5] }); + } else if (cells.length === 5) { + // 구 형식: 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 (산출물 컬럼 없음) + rows.push({ owner: cells[0], work: cells[1], detail: cells[2], deliverable: '', due: cells[3], status: cells[4] }); } else if (cells.length === 4) { - rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3], status: '' }); + rows.push({ owner: cells[0], work: cells[1], detail: cells[2], deliverable: '', due: cells[3], status: '' }); } else { - rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2], status: '' }); + rows.push({ owner: cells[0], work: cells[1], detail: '', deliverable: '', due: cells[2], status: '' }); } } return rows; diff --git a/src/features/datacollect/scheduling/meetRegistration.ts b/src/features/datacollect/scheduling/meetRegistration.ts index cf999c6..7bdcacc 100644 --- a/src/features/datacollect/scheduling/meetRegistration.ts +++ b/src/features/datacollect/scheduling/meetRegistration.ts @@ -29,7 +29,7 @@ import { resolveTaskDate, toYmd, addBusinessDays } from './calendarHelpers'; import { logInfo } from '../../../utils'; // ── 타입 ──────────────────────────────────────────────────────────────────── -export type ActionRow = { owner: string; work: string; detail: string; due: string; status: string }; +export type ActionRow = { owner: string; work: string; detail: string; deliverable: string; due: string; status: string }; export type HoldKind = 'undecided' | 'nodate' | 'conditional'; export interface PendingItem { @@ -37,6 +37,7 @@ export interface PendingItem { owner: string; work: string; detail: string; + deliverable: string; // 산출물 (없으면 "확인 필요") due: string; kind: HoldKind; condition?: string; // kind=conditional 의 선행작업 @@ -185,10 +186,12 @@ export async function registerAction( } /** 등록 노트 공통 빌더. */ -export function buildNotes(p: { detail: string; meetTitle: string; owner: string; dueRaw: string; dateLabel: string; extra?: string[] }): string { +export function buildNotes(p: { detail: string; meetTitle: string; owner: string; deliverable?: string; dueRaw: string; dateLabel: string; extra?: string[] }): string { const detailLine = p.detail?.trim() || '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)'; + const deliverable = p.deliverable?.trim(); return [ '■ 작업 상세', detailLine, '', + ...(deliverable ? ['■ 산출물', deliverable, ''] : []), ...(p.extra && p.extra.length ? [...p.extra, ''] : []), '■ 맥락', `· 회의록: ${p.meetTitle}`, @@ -325,7 +328,7 @@ export async function processConfirmDecisions( } const notes = buildNotes({ - detail: item.detail, meetTitle: pend.meetTitle, owner: item.owner, + detail: item.detail, meetTitle: pend.meetTitle, owner: item.owner, deliverable: item.deliverable, dueRaw: item.due, dateLabel: date || '(날짜 없음 — 조건부)', extra, }); const r = await registerAction(context, { diff --git a/tests/meetRegistration.test.ts b/tests/meetRegistration.test.ts index 4be115a..69c0bb4 100644 --- a/tests/meetRegistration.test.ts +++ b/tests/meetRegistration.test.ts @@ -4,10 +4,12 @@ * 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지). */ import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey } from '../src/features/datacollect/scheduling/meetRegistration'; +import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers'; +import { looksDegenerate } from '../src/features/datacollect/llm'; const MEET = new Date('2026-06-10'); const TODAY = new Date('2026-06-11'); -const row = (due: string, status: string) => ({ owner: '나', work: '테스트 작업', detail: '', due, status }); +const row = (due: string, status: string) => ({ owner: '나', work: '테스트 작업', detail: '', deliverable: '', due, status }); describe('classifyAction — 등록 게이트 분기', () => { test('확정 + 명시 기한 → auto', () => { @@ -108,3 +110,51 @@ describe('보조 유틸', () => { expect(taskKey('DRM 라이선스 검토')).toBe(taskKey('drm 라이선스 검토')); }); }); + +describe('parseActionItems — 액션 표 파싱 (산출물 컬럼 + 하위호환)', () => { + test('신 형식 6컬럼: 담당|작업|상세|산출물|기한|상태', () => { + const report = [ + '## 4. 액션 아이템', + '| 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 |', + '| --- | --- | --- | --- | --- | --- |', + '| 송병준 | 테스트 샘플 3종 선정 | 후보 비교 | 테스트 URL 목록 | 6/18 | 확정 |', + '', + '## 5. 오픈 이슈', + ].join('\n'); + const rows = parseActionItems(report); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ owner: '송병준', work: '테스트 샘플 3종 선정', detail: '후보 비교', deliverable: '테스트 URL 목록', due: '6/18', status: '확정' }); + }); + + test('구 형식 5컬럼(산출물 없음)도 그대로 파싱 — deliverable 빈값', () => { + const report = [ + '## 5. 액션 아이템', + '| 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 |', + '| --- | --- | --- | --- | --- |', + '| 나 | DRM 검토 | 상세 | 6/20 | 확정 |', + ].join('\n'); + const rows = parseActionItems(report); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ owner: '나', work: 'DRM 검토', deliverable: '', due: '6/20', status: '확정' }); + }); + + test('섹션 번호가 바뀌어도(번호 무관) 탐지', () => { + const report = '## 9. 액션 아이템\n| 담당 | 작업 내용 | 기한 |\n| --- | --- | --- |\n| 나 | 작업 | 6/20 |'; + expect(parseActionItems(report)).toHaveLength(1); + }); +}); + +describe('looksDegenerate — 모델 출력 붕괴 감지', () => { + test('정상 회의록은 통과', () => { + expect(looksDegenerate('## 결정 사항\n- 테스트 샘플 3종 선정 — 근거: "샘플 3개로 가자"')).toBe(false); + }); + test('구절 반복 루프 감지', () => { + expect(looksDegenerate('서비스 기프트 서비스 기프트 서비스 기프트 서비스 기프트 서비스 기프트')).toBe(true); + }); + test('대체문자(깨짐) 감지', () => { + expect(looksDegenerate('정상 텍스트 �� 다략한서 량한서')).toBe(true); + }); + test('표 구분선 반복은 오탐 아님', () => { + expect(looksDegenerate('| --- | --- | --- | --- | --- | --- |')).toBe(false); + }); +});