feat(meet): 회의록 가이드 v2 반영 + 모델 출력 붕괴 복원력 (v2.2.253)
회의록 출력물 개선 (실무 회의록 가이드 v2): - 섹션 우선순위 재정렬(①결정 ②액션 ③오픈이슈 ④리스크 ⑤논의) - 논의사항 주제별 bullet 간결화, 오픈 이슈 섹션 복원 - 액션 아이템 산출물 컬럼 추가(담당·작업·기한·산출물 4요소), 담당자 개인 우선 - Executive Summary 결과 중심, 결정사항은 확정된 것만 모델 출력 붕괴(degeneration) 대응: - callLmSynthesis 재시도 내장(repeat_penalty↑/top_k↓로 반복 억제 강화) + looksDegenerate 감지 - 긴 녹취 조각 실패 시 절반 분할 재귀 재시도(12K→6K→3.5K) - 부분 회의록 fallback(한 조각 실패해도 전체 중단 안 함) 하위호환: 액션 표 파서 신6컬럼/구5컬럼 모두 파싱, 섹션 번호 무관 탐지, 회의일 추출 일시/날짜 둘 다 인식. 테스트 +13건(전체 659 통과). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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회)
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -619,16 +619,56 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
chunk(view, `🧩 **긴 녹취록 — 2단계 합성** (조각 ${segments.length}개 × ~${(SEG_SIZE / 1000) | 0}K자, 모델 \`${model}\`)\n`);
|
||||
const extractSystem = '당신은 회의 녹취 사실 추출기입니다. 제공된 조각에 명시된 내용만 형식대로 추출하고, 없는 사실을 만들지 않습니다. 모든 출력은 한국어입니다.';
|
||||
const notes: string[] = [];
|
||||
let failedSegs = 0;
|
||||
// 약한 모델이 큰 조각에서 출력 붕괴(반복/깨짐)할 때, 그 조각을 줄 경계로
|
||||
// 절반씩 쪼개 재귀 재시도한다. 입력을 줄이면 붕괴 확률이 떨어지므로 모델
|
||||
// 교체 없이도 성공률이 오른다. MIN_SEG 이하인데도 실패하면 그 구간만 포기.
|
||||
const MIN_SEG = 3500;
|
||||
const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise<string | null> => {
|
||||
try {
|
||||
// callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다.
|
||||
const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem);
|
||||
if (!note) throw new Error('추출 결과가 비어 있습니다.');
|
||||
return note.trim();
|
||||
} catch (segErr: any) {
|
||||
if (seg.length <= MIN_SEG) {
|
||||
chunk(view, ` ⚠️ 조각 ${label} 추출 실패(최소 크기 도달, 건너뜀): ${segErr?.message || String(segErr)}\n`);
|
||||
return null;
|
||||
}
|
||||
chunk(view, ` ↩︎ 조각 ${label}(${seg.length.toLocaleString()}자) 출력 붕괴 → 절반으로 쪼개 재시도\n`);
|
||||
const lines = seg.split('\n');
|
||||
let cut = 0, acc = 0;
|
||||
for (; cut < lines.length - 1; cut++) { acc += lines[cut].length + 1; if (acc >= seg.length / 2) break; }
|
||||
const left = lines.slice(0, cut + 1).join('\n');
|
||||
const right = lines.slice(cut + 1).join('\n');
|
||||
if (!left.trim() || !right.trim()) return null; // 더는 못 쪼갬
|
||||
const parts = [
|
||||
await extractSeg(left, `${label}a`, idx, total),
|
||||
await extractSeg(right, `${label}b`, idx, total),
|
||||
].filter(Boolean) as string[];
|
||||
return parts.length ? parts.join('\n\n') : null;
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…`);
|
||||
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…\n`);
|
||||
const t0 = Date.now();
|
||||
const note = await callLmSynthesis(
|
||||
buildMeetExtractPrompt(segments[i], metadata, i + 1, segments.length),
|
||||
extractSystem,
|
||||
);
|
||||
if (!note) throw new Error(`조각 ${i + 1} 추출 결과가 비어 있습니다.`);
|
||||
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note.trim()}`);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n`);
|
||||
const note = await extractSeg(segments[i], String(i + 1), i + 1, segments.length);
|
||||
if (note) {
|
||||
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note}`);
|
||||
chunk(view, ` ✓ 조각 ${i + 1} 완료 (${Math.round((Date.now() - t0) / 1000)}s)\n`);
|
||||
} else {
|
||||
// 한 조각이 끝내 실패해도 전체 회의록을 포기하지 않는다 — 누락을
|
||||
// 명시하고 나머지 조각으로 부분 회의록을 만든다.
|
||||
failedSegs++;
|
||||
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n(이 구간은 모델 출력 오류로 추출하지 못했습니다.)`);
|
||||
chunk(view, ` ⚠️ 조각 ${i + 1} 추출 실패(건너뜀)\n`);
|
||||
}
|
||||
}
|
||||
if (failedSegs === segments.length) {
|
||||
throw new Error(`모든 조각(${segments.length}개) 추출에 실패했습니다 — 모델 출력이 계속 붕괴합니다.`);
|
||||
}
|
||||
if (failedSegs > 0) {
|
||||
chunk(view, `\n⚠️ ${segments.length}개 중 ${failedSegs}개 구간을 추출하지 못해 **부분 회의록**으로 진행합니다. 더 큰 모델(예: 27B+) 사용을 권장합니다.\n`);
|
||||
}
|
||||
groundingNotes = notes.join('\n\n');
|
||||
// ── Reduce: 노트 병합 → 최종 회의록 ──
|
||||
@@ -639,7 +679,17 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
const msg = e?.message || String(e);
|
||||
const degen = /붕괴|반복|깨|parse input/i.test(msg);
|
||||
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${msg}\n`);
|
||||
if (degen) {
|
||||
chunk(view, `\n💡 모델 출력이 반복/깨짐(degeneration)으로 붕괴한 것으로 보입니다. 재시도(반복 억제 강화)에도 풀리지 않았습니다.\n`
|
||||
+ ` · 현재 모델 \`${model}\` 이 긴 한국어 녹취록에는 약할 수 있습니다 — **더 큰 모델(27B+급)** 로 \`g1nation.defaultModel\` 변경을 권장합니다.\n`
|
||||
+ ` · 또는 녹취록을 더 짧게 나눠 여러 번 \`/meet\` 하면 성공률이 올라갑니다.\n`);
|
||||
} else {
|
||||
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`);
|
||||
}
|
||||
chunk(view, '\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -729,7 +779,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
if (cls.route === 'hold') {
|
||||
holds.push({
|
||||
idx: holds.length + 1,
|
||||
owner: task.owner, work: task.work, detail: task.detail, due: task.due,
|
||||
owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due,
|
||||
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
|
||||
});
|
||||
continue;
|
||||
@@ -742,7 +792,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.');
|
||||
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
|
||||
const notes = buildNotes({
|
||||
detail: task.detail, meetTitle, owner: task.owner,
|
||||
detail: task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
|
||||
dueRaw: task.due, dateLabel: cls.date, extra,
|
||||
});
|
||||
const r = await registerAction(context, {
|
||||
|
||||
@@ -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<string> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const temperature = Math.max(0, Math.min(2, cfg.get<number>('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<string> {
|
||||
const res = await bridgeFetch<any>(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<string> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const baseTemp = opts?.temperature ?? Math.max(0, Math.min(2, cfg.get<number>('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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
- [담당] 작업 내용 (기한: …) — 근거: "…"
|
||||
- [담당(개인 우선)] 작업 내용 (기한: … / 산출물: …) — 근거: "…"
|
||||
## 언급된 수치·날짜·금액
|
||||
- 항목: 값 — 근거: "…"`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user