v2.2.258: /meet 화자 팀/역할 정규화 + 헤더 전조각 주입 + 검증 5종

STT 화자번호(참석자 N) 박멸→회사 표준 팀/역할 귀속, 회의 헤더 전 청크 주입, 전역 헤드라인 추출, 결정 게이트·담화 상태 태깅, 슬림 6섹션 포맷, 타임스탬프 근거, parseActionItems 헤더명 기반 재작성, 검증 패스 5종. 전체 698 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:10:32 +09:00
parent 5d02a8a56f
commit 6dc5f17dec
8 changed files with 333 additions and 164 deletions
+14
View File
@@ -1,5 +1,19 @@
# Astra Patch Notes
## v2.2.258 (2026-06-22)
### 📝 `/meet` 회의록 — 화자 팀/역할 정규화 + 슬림 포맷 + 타임스탬프 근거
실제 회의록 산출물(자이언츠 이머시브 커머스 데모 리뷰)을 녹취 원문과 대조한 피드백을 반영. *그 회의록 한 건을 고치는 게 아니라*, 앞으로 생성될 모든 회의록의 생성 파이프라인(프롬프트+코드+검증패스)을 개선.
- **화자 정규화(최우선)**: STT 화자번호("참석자 N")는 실명·역할이 아니고 번호↔이름 연결고리가 녹취록에 없다. 최종 문서에서 "참석자 N" 토큰을 **0개로 박멸**하고, 위험한 개인 추측 대신 **회사 표준 팀/역할**(PD·기획·사업·클라이언트·넥서스개발팀·UI·배경팀·캐릭터팀·QA·사운드·개발PM)로 귀속. 개인명은 명단+문맥 확실 시만 병기, 팀조차 불명이면 무귀속 중립 서술. 신규 설정 `g1nation.meetTeamRoster`.
- **회의 헤더 전(全)조각 주입**: 파일 상단 참석자 명단·일시·장소·녹취 길이를 파싱해 **모든 청크 추출 프롬프트에 주입**(자동 용어집과 동일 메커니즘) — 헤더가 첫 조각에만 있어 후반부 화자가 깨지던 구조적 갭 해소. 개요에 장소·회의유형·녹취길이·작성일 자동 반영. ([calendarHelpers.ts](src/features/datacollect/scheduling/calendarHelpers.ts) `extractMeetingHeader`)
- **전역 헤드라인 추출**: 12K 청크 분할의 약점(마지막 청크에만 있는 "고객 핵심 요구 3종"이 묻힘)을 reduce 단계 "전 청크 관통 결론 추출"로 해결 — 핵심 요약 맨 앞에 세움.
- **결정 게이트 강화**: "테스트 후 결정/다시 보고 얘기/고민해보자"류 조건부·미합의를 결정사항에서 빼 오픈이슈로. 담당+행동이 있는 일감은 액션으로(결정엔 순수 방향/정책만) — 결정/액션 중복 제거.
- **담화 상태 태깅**: 추출 단계가 각 제안을 `(합의)`/`(미합의)`/`(반박됨)`/`(철회됨)`으로 표시 → 즉시 일축된 가설(예: "흑인 예시")이 정식 이슈로 격상되는 것 차단. 화자간 수치 충돌(6 vs 8)은 단일값 확정 말고 그대로 명시.
- **슬림 포맷**: 7→6 섹션, 한 사실은 한 섹션에만(중복 제거), **빈 칸은 "—"**(빈 템플릿이 환각 유발), 리스크는 실제 논의됐을 때만 표 띄움. 액션 표 6→5컬럼(`담당|액션|기한|상태|출처`), 작업내용+상세 병합.
- **근거 = 타임스탬프**: STT 원문 통째 인용 대신 `[mm:ss]`로 정제(검증 점프 가능). 추출 노트엔 verbatim 보존(검증용).
- **파서 안전화**: `parseActionItems`를 **헤더명 기반 매핑**으로 재작성 — 컬럼 순서·개수가 바뀌어도(신/구 형식 모두) 캘린더 확신 게이트가 어긋나지 않음. 상태 taxonomy(확정/진행미정/기한미정/조건부/반복)·게이트는 불변.
- **검증 패스 기본 ON**: 단순 근거 존재 확인 → ①근거미확인 ②"참석자 N" 잔존 ③결정 과확정 ④폐기 가설 격상 ⑤액션 중복 **5종 점검**으로 확장(`g1nation.meetVerifyPass` 기본 true).
- 회귀 가드 갱신·신규([meetPrompt.test.ts](tests/meetPrompt.test.ts) · [meetRegistration.test.ts](tests/meetRegistration.test.ts)), 전체 698 통과. 코어 채팅 경로 불변.
## v2.2.257 (2026-06-19)
### 📝 `/meet` 회의록 — 책임 소재·근거 정책 4종 (익명 담당·빈 인용·기한·명단 정합)
실제 회의록 산출물 분석에서 드러난 4개 결함을 프롬프트 정책으로 차단. 공유 출력 형식(단일샷+reduce)과 추출(map) 단계 모두에 적용.
+8 -3
View File
@@ -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.257",
"version": "2.2.258",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -313,8 +313,13 @@
},
"g1nation.meetVerifyPass": {
"type": "boolean",
"default": false,
"markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부. 결정 사항·액션 아이템을 녹취록(또는 추출 노트)과 LLM 으로 대조해, 근거를 못 찾는 항목을 `⚠️ 검증 결과` 섹션으로 표시한다 (날조 검출). LLM 호출이 1회 추가되어 그만큼 느려짐 — 중요한 회의에만 켜는 것을 권장."
"default": true,
"markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부(기본 ON). 완성된 회의록을 녹취록(또는 추출 노트)과 LLM 으로 대조해 ①근거 미확인 ②\"참석자 N\" 화자번호 잔존 ③결정 과확정(테스트후결정·검토필요) ④폐기 가설 격상 ⑤액션 중복 을 점검하고 `⚠️ 검증 결과` 섹션으로 표시한다. LLM 호출이 1회 추가되어 그만큼 느려짐 — 끄려면 false."
},
"g1nation.meetTeamRoster": {
"type": "string",
"default": "",
"markdownDescription": "`/meet` 화자 정규화용 **회사 표준 팀/역할 분류**(쉼표 구분). STT 화자번호(\"참석자 N\")를 실명 추측 대신 이 팀들 중 하나로 귀속해 오귀속을 막는다. 비워두면 기본값(`PD, 기획, 사업, 클라이언트, 넥서스개발팀(서버), UI, 배경팀, 캐릭터팀, QA, 사운드, 개발PM`) 사용. 회사 구조에 맞게 수정하세요."
},
"g1nation.dailyBriefing.enabled": {
"type": "boolean",
+18 -6
View File
@@ -33,13 +33,14 @@ import {
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
savePending, loadPending, classifyAction,
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates,
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster,
type PendingItem, type PendingFile,
} from './scheduling/meetRegistration';
import {
addBusinessDays,
toYmd,
extractMeetingDate,
extractMeetingHeader,
resolveTaskDate,
parseActionItems,
} from './scheduling/calendarHelpers';
@@ -579,12 +580,23 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// 중복 방지 키 — 동일 녹취 재실행 시 이미 등록된 액션을 건너뛰기 위한 해시 (원본 전체 기준).
const tHash = transcriptHash(transcript);
const userMetadata = metadata; // 용어집 후보 추출용 — 자동 보강 전 원본 보존
// [회의 헤더 파싱] STT 본문(첫 "참석자 N mm:ss" 줄) 앞의 헤더 블록(참석자 명단·
// 일시·장소·녹취 길이)을 메타데이터에 끌어올린다. 헤더는 첫 조각에만 있어 후반
// 조각이 명단을 못 보던 문제를, 모든 조각 프롬프트에 주입해 해소한다(화자 정규화·메타 추출).
const headerBlock = extractMeetingHeader(transcript);
if (headerBlock) {
metadata = `${headerBlock}\n${metadata ? '\n' + metadata : ''}`.trim();
}
// [회사 표준 팀 분류] 화자번호 → 팀/역할 귀속의 통제 어휘집. 모든 조각·병합에 주입.
metadata = `${metadata ? metadata + '\n' : ''}[회사 표준 팀/역할 분류 — 화자를 이 팀들 중 하나로 귀속할 것] ${loadTeamRoster()}`;
// [작성일] 회의 개요의 '작성일' 채움용 (모델은 오늘 날짜를 모름).
metadata = `${metadata}\n[작성일] ${toYmd(new Date())}`;
// [자동 용어집] 이전 /meet 들에서 누적된 인명·용어를 메타데이터에 보강 —
// meetPrompt 가 메타데이터를 STT 보정 용어집으로 쓰므로 반복 회의의 표기
// 일관성이 자동으로 좋아진다. 사용자 입력 메타데이터가 항상 우선(앞에 배치).
const glossaryTerms = loadGlossaryTerms();
if (glossaryTerms.length) {
metadata = `${metadata ? metadata + '\n' : ''}[자동 용어집 — 이전 회의에서 누적된 인명·용어 표기] ${glossaryTerms.join(', ')}`;
metadata = `${metadata}\n[자동 용어집 — 이전 회의에서 누적된 인명·용어 표기] ${glossaryTerms.join(', ')}`;
chunk(view, `📚 자동 용어집 ${glossaryTerms.length}개 용어 주입\n`);
}
// v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce).
@@ -696,8 +708,8 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
return true;
}
// ── 검증 패스 (옵션, g1nation.meetVerifyPass) — 결정·액션을 근거 소스와 대조 ──
if (cfg.get<boolean>('meetVerifyPass', false)) {
// ── 검증 패스 (g1nation.meetVerifyPass, 기본 ON) — 결정·액션·화자번호·과확정·중복 점검 ──
if (cfg.get<boolean>('meetVerifyPass', true)) {
try {
chunk(view, `🔍 **검증 패스** — 결정·액션 근거 대조 중…`);
const t2 = Date.now();
@@ -782,7 +794,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, deliverable: task.deliverable, due: task.due,
owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due, source: task.source,
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
});
continue;
@@ -796,7 +808,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
const notes = buildNotes({
detail: task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
dueRaw: task.due, dateLabel: cls.date, extra,
dueRaw: task.due, dateLabel: cls.date, source: task.source, extra,
});
const r = await registerAction(context, {
title: evTitle, date: cls.date, notes,
+138 -106
View File
@@ -1,6 +1,17 @@
/**
* 회의 녹취 텍스트 → 사실 기반 구조화 회의록(Actionable Minutes) LLM 프롬프트.
* 사용자 정의 규칙: Fact/Discussion/Decision/Risk/Action 분류, 메타데이터 우선.
*
* v2.2.258 개선(사용자 피드백 반영):
* - 화자: STT 화자번호("참석자 N")를 **팀/역할**로 정규화(개인명은 확실할 때만 병기).
* 최종 문서에 "참석자 N" 토큰을 절대 노출하지 않는다.
* - 전역 헤드라인: 청크 전체를 관통하는 결론/핵심 요구를 별도 추출해 맨 앞에 세운다.
* - 결정/액션 경계: 일감(담당 있는 것)은 액션으로, 결정사항엔 순수 방향/정책만.
* - 조건부 결정: "테스트 후 결정" 류는 결정이 아니라 오픈 이슈로.
* - 폐기 가설/충돌: 즉시 반박·철회된 제안은 이슈로 격상 금지, 화자간 수치충돌은 그대로 명시.
* - 슬림 포맷: 7→6 섹션, 한 사실은 한 섹션에만(중복 제거), 빈 칸은 "—".
* - 근거: STT 원문 통째 인용 대신 타임스탬프 [mm:ss]로 정제(검증 가능 기록).
* - 리스크 표는 실제로 리스크+완화책이 논의됐을 때만 띄운다(빈 템플릿 = 환각 유발).
*/
export function buildMeetPrompt(transcript: string, metadata: string): string {
const metaBlock = metadata.trim()
@@ -12,45 +23,53 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# 역할 (Role)
- Fact Extractor: 녹취록에 명시적으로 존재하는 사실만 추출
- Attribution Tracker: 누가 무엇을 말했는지 발언 주체를 끝까지 추적해 오귀속을 방지
- Decision Tracker: 결정 여부 구분
- Attribution Tracker: 누가 무엇을 말했는지 추적하되, 화자를 **팀/역할**로 귀속한다(아래 '화자 정규화' 참조)
- Decision Tracker: 결정 여부 구분 (명시적 합의만 결정)
- Action Organizer: 실행 항목 구조화
- Context Filter: 불필요한 발언(잡담) 제거
# 데이터 우선순위 (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
1순위: 메타데이터(회의 헤더·참석자 명단·회사 표준 팀 분류) / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
# STT 오타 보정 (Transcription Noise Handling — 이 녹취록은 음성→텍스트 변환물이라 오타가 많다)
- 발음이 유사한 단어가 잘못 표기돼 있다(예: "Dovrunner"→"Doverunner", "페어플레이"→"페어플래이"). **한 단어의 철자에 집착하지 말고 주변 문맥(앞뒤 키워드)으로 의미를 복원하라.**
- 발음이 유사한 명백한 오타는 문맥상 맞는 기술 용어·고유명사로 **정규화**하라. 흔한 기술 용어(DRM, SDK, 딥링크, API, 렌더링, 페어플레이, 암호화 등)는 도메인 지식으로 보정해도 된다.
- 메타데이터에 인명·기업명·제품명·용어가 주어졌으면 그것을 **정답 표기**로 보고, 녹취록의 유사 오타를 그 표기로 맞춘다(메타데이터는 사실상 용어집 역할).
- **핵심 구분 (절대 혼동 금지)**: '녹취록에 *있는* 단어의 철자를 문맥으로 바로잡는 것'은 허용·권장된다. '녹취록에 *없는* 사실(수치·결정·없던 항목)을 지어내는 것'은 금지다. **철자 보정 ≠ 사실 날조.**
- 철자가 틀려도 문맥상 의미가 분명하면 그 의미를 확정된 것으로 다뤄라 — **오타 하나 때문에 멀쩡한 내용 전체를 "확인 불가"로 막지 말 것.**
- 정규화는 했지만 문맥으로도 정체가 끝내 모호한 용어에 한해, 정규화 표기 옆에 원문을 함께 남긴다: 예) \`Doverunner(원문: "Dovrunner", 표기 불확실)\`.
# 화자 정규화 (Speaker Normalization — 최우선, 환각·오귀속 방지)
이 녹취록의 "참석자 1", "참석자 2" … 는 STT가 임의로 붙인 **화자번호**일 뿐 실명·역할이 아니다. 번호↔이름 연결고리는 녹취록에 없다.
- **최종 문서에 "참석자 N"(또는 "화자 N", "발언자 A") 토큰을 절대 쓰지 말 것.** 0개여야 한다.
- 메타데이터의 **회의 참석자 명단(역할:이름)**과 **회사 표준 팀 분류**를 통제 어휘집으로 삼아, 각 화자번호를 *발언 내용 단서*로 **팀/역할**에 매핑한다. (예: 라이선스·AI작업·폴리싱을 말하는 화자 → [넥서스개발팀], 모든 의사결정을 내리고 다른 사람이 "상무님"으로 부르는 화자 → [클라이언트])
- **같은 화자번호는 회의 내내 같은 팀/역할로 고정**한다(중간에 바꾸지 말 것).
- 개인 실명은 명단+문맥으로 **확실할 때만** 괄호로 병기한다(예: [넥서스개발팀·김상엽]). 확신 없으면 팀/역할까지만.
- 팀/역할조차 단서가 부족하면 **귀속을 생략**하고 "~라는 의견이 제시됨"처럼 주체 없이 중립 서술한다. 번호를 남기느니 주체를 비우는 게 낫다.
- 무리한 매핑(특히 개인 실명 추측)은 오귀속을 낳아 신뢰도를 가장 크게 해친다 — **불확실하면 한 단계 위(개인→팀, 팀→무귀속)로 물러서라.**
# STT 오타 보정 (Transcription Noise Handling — 음성→텍스트 변환물이라 오타가 많다)
- 발음이 유사한 단어가 잘못 표기돼 있다. **한 단어의 철자에 집착하지 말고 주변 문맥으로 의미를 복원하라.**
- 흔한 기술 용어(DRM, SDK, 딥링크, API, 렌더링, 자이로센서 등)·고유명사는 도메인 지식으로 **정규화**하라.
- 메타데이터에 인명·기업명·제품명·용어가 있으면 그것을 **정답 표기**로 본다(메타데이터 = 용어집).
- **핵심 구분 (절대 혼동 금지)**: '녹취록에 *있는* 단어의 철자를 문맥으로 바로잡는 것'은 허용·권장. '녹취록에 *없는* 사실(수치·결정·없던 항목)을 지어내는 것'은 금지. **철자 보정 ≠ 사실 날조.**
- 오타 하나 때문에 멀쩡한 내용 전체를 "확인 불가"로 막지 말 것.
# 처리 절차 (Processing Flow)
1. Speaker Tracking — 발언자 ID/이름을 끝까지 유지한다. **누가 한 말인지를 절대 임의로 바꾸거나 합치지 말 것.**
2. Topic Reclustering — 녹취록은 비선형이다(A→B→Z→다시 A 식으로 주제가 튄다). 녹취록 전체를 훑어 **흩어진 발언을 주제별로 다시 묶은 뒤** 정리한다. 녹취록상 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과·연결 관계로 엮지 말 것(**인접 ≠ 연결**).
3. Deconstruction — 잡담을 제거하고 의미 단위로 분해하되, 각 단위에 발언 주체를 보존한다.
4. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다.
5. Decision Logic
- 명확한 합의 표현 → Decision
1. Speaker Normalization — 위 규칙대로 화자번호를 팀/역할로 매핑(번호 박멸).
2. Topic Reclustering — 녹취록은 비선형이다(주제가 튄다). 전체를 훑어 **흩어진 발언을 주제별로 다시 묶는다.** 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과로 엮지 말 것(**인접 ≠ 연결**).
3. Global Headline — 회의 전체를 관통하는 **결론/핵심 요구**(특히 후반부에 정리된 것)를 별도로 식별해 '핵심 요약'에 세운다.
4. Dialectic Check — 각 제안이 **합의됐는지 / 반박·철회됐는지 / 미결인지**를 대화 흐름 끝까지 보고 판정한다. 즉시 일축·반박된 가설을 정식 이슈로 격상하지 말 것. 화자간 수치·의견이 갈리면 단일값으로 확정하지 말고 충돌을 그대로 적는다(예: "6 vs 8, 기준 미정").
5. Classification — Fact / Discussion / Decision / Risk / Action 분류.
6. Decision Logic
- 명시적 합의 표현 → Decision (단, 담당+행동이 있는 일감은 Action으로 보내고 결정엔 순수 방향/정책만)
- "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자" → Decision 아님 → Open Issue
- 실행 주체 + 행동 → Action
- 제안/의견 → Discussion
- 조건 부족 / 합의 불명확 → Open Issue
6. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다.
7. Dedup & Structuring — 표현이 달라도 의미가 같은 항목은 하나로 병합. **한 사실은 한 섹션에만** 둔다(요약·결정·액션에 같은 내용 중복 금지).
# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지)
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개 발언을 하나의 인과 사슬로 합성하지 말 것.
- **근거 인용 의무**: 모든 '결정 사항''액션 아이템'에는 근거가 된 발언 원문 일부(20자 내외)를 따옴표로 함께 적는다(오타는 보정 표기로 인용 가능). **인용할 원문 발언을 녹취록에서 찾을 수 없는 항목은 결정·액션이 아니다** 그런 항목은 만들지 말거나 오픈 이슈로 내려라. 이 인용은 날조 방지 장치다.
- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다.
- **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다.
- 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다.
- Decision은 명시적 합의 표현이 있을 때만 '결정됨'이다. 합의가 불명확하면 '논의 중' 또는 오픈 이슈로 둔다.
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개 발언을 하나의 인과 사슬로 합성하지 말 것.
- **근거 = 타임스탬프**: 모든 '결정 사항'·'액션 아이템'·리스크 항목 끝에, 근거가 된 발언의 **타임스탬프 [mm:ss]**를 붙인다(녹취록의 "참석자 N mm:ss" 표시에서 가장 가까운 시각). 타임스탬프를 찾을 수 없는 항목은 결정·액션이 아니다 — 만들지 말거나 오픈 이슈로 내려라. 이 타임스탬프는 날조 방지 + 검증 점프 장치다. STT 원문을 통째로 인용하지 말 것(길고 들쭉날쭉해진다).
- **녹취록에 없는 숫자·날짜·금액·결정을 만들어내지 말 것.** (철자만 틀린 용어 정규화는 허용.)
- 내용 자체가 모호하면(=철자 문제가 아니라 사실 불확실) 지어내지 말고 "(확인 필요)"를 붙인다.
- Decision은 명시적 합의 표현이 있을 때만 '결정됨'. 불명확하면 오픈 이슈.
- **빈 칸은 채우지 말 것**: 표에서 값을 모르면 "—"로 둔다. placeholder를 그럴듯한 추측으로 메우지 말 것. 모든 칸이 미정인 항목은 액션이 아니라 오픈 이슈다.
# 출력 검증 (Validation)
출력 전 내부적으로 점검한다: ① 각 발언이 올바른 주체에게 귀속됐는가 인접 발언을 임의 연결하지 않았는가 Decision 실제 합의인가 ④ 녹취록에 없는 정보를 추가하지 않았는가 ⑤ Action은 실행 가능한가.
단, 검증 과정·체크 로그는 출력하지 말 것. 최종 회의록만 출력한다.
# 출력 검증 (Validation — 출력 전 내부 점검, 로그는 출력 금지)
① "참석자 N" 토큰이 0개인가 ② 화자가 팀/역할로 귀속됐고 개인 추측이 없는가 인접 발언을 임의 연결하지 않았는가 Decision 실제 합의인가(테스트후결정·검토필요가 섞이지 않았나) ⑤ 즉시 반박된 가설을 이슈로 격상하지 않았는가 ⑥ 같은 사실이 여러 섹션에 중복되지 않았는가 ⑦ 빈 칸을 추측으로 채우지 않았는가 ⑧ 각 항목에 [mm:ss]가 있는가.
[메타데이터]
${metaBlock}
@@ -65,71 +84,68 @@ ${OUTPUT_FORMAT}`;
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화)
회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. 회의 내용 자체보다 "무엇이 결정됐는가 / 누가 무엇을 해야 하는가 / 무엇이 아직 결정 안 됐는가"를 우선한다.
- **정보 우선순위**: ①결정사항 ②액션아이템 ③오픈 이슈 ④리스크 ⑤논의사항 순으로 중요하다. 분량·정성도 이 순서로 배분한다.
- **사실 중심**: 추정·해석·평가가 아니라 회의에서 실제 논의·확정된 것만 적는다. (예: "API 연동이 가능할 것으로 판단됨"❌ → "API 연동 가능 여부 검토 필요"✅)
- **결정사항 ↔ 논의사항 구분(최우선)**: 명시적으로 합의·확정된 것만 '결정 사항'에 둔다. "검토 필요/정의 필요/활용 검토"처럼 *확정되지 않은 것*은 결정사항이 아니다 — 논의 사항·오픈 이슈로 내린다. 반대로 실제 확정된 것을 결정사항에서 누락하지도 말 것.
- **결과 중심**: 발언 순서·토론 과정·"누가 무슨 말을 했는지"를 나열하지 말고 *결과*를 적는다. (예: "PlayCanvas를 논의함"❌ → "PlayCanvas/Babylon.js 비교 검토 진행 예정"✅) 책임 소재나 입장 차이가 핵심일 때만 발언자를 밝힌다.
- **담당자는 개인 우선**: "개발팀/QA팀" 같은 조직 단위가 아니라 개인 이름(예: 송병준, 김원일 PD)으로 적는다. 실제로 개인이 안 정해졌으면 "개발팀 (담당자 지정 필요)" 형태로 표기.
- **증빙 보존**: 결정되지 않은 사항이라도 중요한 논의·리스크는 빠짐없이 기록한다(향후 이력·분쟁 근거).
- **익명·번호 화자에 책임 배정 금지(최우선)**: "참석자 1", "발언자 A", "(주체 불명확)"처럼 *실명이 확인 안 된 화자*를 액션 아이템 담당으로 확정하지 말 것. 담당 칸은 \`[미지정-확인필요]\`로 두고 근거 발언만 남긴다 — 익명에게 가짜 책임을 붙이는 것이 회의록 신뢰도를 가장 크게 해친다. 또한 **한 익명 화자에게 액션이 여러 건 몰리면** 화자분리 오류일 수 있으니 그 항목들 작업 상세 끝에 "(동일 화자 여부 확인)"을 덧붙인다.
- **내용 없는 인용 금지**: "이렇게 이렇게", "그거", "이거", "저렇게" 처럼 지시대명사뿐이라 *내용이 비어 있는* 발화는 결정·액션의 근거로 인용하지 말 것. 그런 발화만 근거인 항목은 핵심 내용을 \`[내용 확인필요]\`로 표기한다(빈 인용을 근거인 척 달지 말 것). 표기 오타와는 다른, *내용 공백* 문제다.
- **기한 역참조**: 리스크·논의·녹취 어디든 등장한 날짜·마감("다음 주 수요일", "이달 말" 등)은 *직접 결부된 게 분명한* 액션의 기한 칸에 연결한다. 결부가 불확실하면 기한 칸에 "(기한 후보: …)"로 보수적으로 표기한다 — 액션 기한을 전부 "미정"으로 비우면서 본문엔 마감이 떠 있는 모순을 막는다.
- **참석자 명단 정합성**: 본문(액션·결정·논의)에 등장한 발언자가 회의 개요 '참석자'에 없으면 그 항목 옆에 "(명단 외 화자 — 확인필요)"를 붙인다.
회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. "무엇이 결정됐는가 / 누가(어느 팀이) 무엇을 해야 하는가 / 무엇이 아직 미결인가"를 우선한다.
- **한 사실은 한 섹션에만**: 같은 항목을 요약·결정·액션에 중복 기재하지 말 것. 핵심 요약은 미리보기가 아니라 다이제스트다.
- **결정 ↔ 액션 경계**: 담당+행동이 있는 *일감*은 '액션 아이템' 표로 보낸다. '결정 사항'에는 **담당자 없는 순수 방향/정책 결정만** 남긴다(예: "제품 리스트 클릭 이벤트 제거하기로 함"). "구현하기로 함"처럼 누가 할 일이면 액션이다.
- **결정 ≠ 검토요청**: "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자"는 결정이 아니다 → 오픈 이슈. 명시적 합의만 결정.
- **담당은 팀/역할 우선**: STT 화자번호 매핑이 불확실하므로 개인명보다 **팀/역할**(예: 넥서스개발팀, 기획, UI)로 적는다. 개인이 명단+문맥으로 확실할 때만 병기. "참석자 N" 금지.
- **빈 칸은 "—"**: 모르는 값을 추측·placeholder로 채우지 말 것. 통째로 미정인 항목은 액션이 아니라 오픈 이슈로 내린다.
- **근거 = [mm:ss]**: 결정·액션·리스크에 STT 원문을 박지 말고 타임스탬프만 붙인다.
- **사실 중심**: 추정·평가가 아니라 실제 논의·확정된 것만. ("API 연동 가능 판단됨"❌ → "API 연동 가능 여부 검토 필요"✅)
# 출력 형식 (Output Format — 정확히 이 구조를 유지)
# [회의 제목]
# [제품/프로젝트] · 회의유형 — 핵심 안건
(예: "[자이언츠 이머시브 커머스] 데모 리뷰 — 모자 UI·모델/체형 확장". 회의유형은 녹취 성격에서 판단: 데모 리뷰 / 기획 논의 / 정기 점검 등.)
## 1. 회의 개요
- **일시**: [YYYY년 MM월 DD일 | 확인 불가]
- **참석자**: [메타데이터 기준 | 없을 경우: 논의 참여 주체]
- **회의 목적**: [이 회의가 왜 열렸는지 한 문장. 녹취록에서 드러난 목적만 적고, 불명확하면 "확인 필요"]
## 회의 개요
- **일시**: [YYYY년 MM월 DD일 | 확인 불가] ← 반드시 이 표기(자동 등록이 날짜를 읽음)
- **장소**: [메타데이터/헤더 기준 | 확인 불가]
- **회의유형**: [데모 리뷰 / 기획 논의 등]
- **녹취 길이**: [헤더에 있으면 | 없으면 생략]
- **작성일**: [메타데이터의 작성일 | 생략]
- **참석자**: 역할(팀)별로 — 개인명은 명단+문맥 확실 시만 병기. "참석자 N" 절대 금지.
## 2. 주요 결과 (Executive Summary)
회의 *결과*를 3~5줄 글머리표로 요약한다. 회의 내용을 설명하지 말고(예: "Babylon.js를 검토함"❌) 결과를 적는다(예: "테스트 샘플 3종 선정", "CCOC 1차 작업 6/19까지 진행"✅). **참석하지 않은 이해관계자가항목만 읽어도 결과를 파악할 수 있어야 한다.**
## 핵심 요약
한 문단(3~5줄)으로 **무엇을 리뷰/논의했고 / 무엇이 정해졌고 / 다음 단계가 무엇인지**를 압축한다. 회의 전체를 관통하는 **핵심 요구·결론**(특히 후반부 정리분)을 여기 맨 앞에 세운다. 디테일 나열·항목 중복 금지. **참석하지 않은 사람이 이 문단만 읽어도 결과를 파악**할 수 있어야 한다.
## 3. 결정 사항
**명시적으로 합의·확정된 것만** 적는다 (일정 확정·개발 범위 확정·정책 변경·후속 방향 확정 등). 검토·정의·도입여부가 *필요*한 단계의 것은 여기 넣지 말고 '오픈 이슈'나 '논의 사항'으로 내린다.
각 결정 끝에 근거 발언을 인용한다: \`- [결정 내용] — 근거: "발언 원문 일부"\`
(이번 회의에서 확정된 결정이 없으면 "이번 회의에서 확정된 결정사항 없음"이라고 명시한다 — 빈칸·생략 금지.)
## 결정 사항
**명시적으로 합의·확정된 순수 방향/정책 결정만** 적는다(담당 있는 일감은 액션으로). 각 줄 끝에 타임스탬프.
\`- [결정 내용] — [mm:ss]\`
(확정된 결정이 없으면 "이번 회의에서 확정된 결정사항 없음"이라고 명시 — 빈칸·생략 금지.)
## 4. 액션 아이템
회의 후 수행할 업무를 **담당·작업·기한·산출물 4요소**가 모두 드러나게 정리한다. 각 행은 반드시 녹취록 근거로 작성한다. 담당자는 개인 우선(없으면 "OO팀 (담당자 지정 필요)"). **실명이 확인 안 된 번호·익명 화자는 담당으로 확정하지 말고 \`[미지정-확인필요]\`로 둔다**(위 작성 원칙 참조). 회의에서 기한·산출물이 안 나왔으면 해당 "확인 필요"라고 적되, **본문에 마감 날짜가 언급됐고 이 작업에 결부되면 그 날짜를 기한 칸에 반영**한다. 표 셀 안에서 줄바꿈\`|\` 문자를 쓰지 말 것.
| 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 |
| --- | --- | --- | --- | --- | --- |
## 액션 아이템
회의 후 수행할 업무를 표로 정리한다. 담당은 **팀/역할 우선**(개인 확실 시 병기). 값을 모르는 "—". 모든 칸이 미정인 항목은 여기 넣지 말고 '오픈 이슈'로. 셀 안에서 줄바꿈·\`|\` 금지.
| 담당 | 액션 | 기한 | 상태 | 출처 |
| --- | --- | --- | --- | --- |
- **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지)
- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 명시. 단순히 작업명을 반복하지 말 것.
- **산출물**: 이 작업이 끝나면 나오는 구체적 결과물(예: 테스트 URL 목록, 비교 분석 문서, 일정표). 회의에서 안 나왔으면 "확인 필요".
- **상태**: 다음 중 정확히 하나로 분류한다 (캘린더 자동 등록 게이트가 이 값으로 분기하므로 형식 엄수):
- **담당**: 팀/역할(예: 넥서스개발팀, UI, 기획). 불명확하면 "—".
- **액션**: 한 줄 실행문 — 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게(배경·대상·조건 포함, "검토"·"확인" 단독 동사 금지). 작업 상세를 따로 반복하지 말고 이 한 줄에 녹인다.
- **기한**: 회의에서 나온 날짜/시점. 없으면 "—".
- **상태**: 다음 중 정확히 하나 (캘린더 자동 등록 게이트가 이 값으로 분기하므로 형식 엄수):
- \`확정\` — 진행 합의가 명시적이고 기한도 언급됨.
- \`진행미정\` — 작업이 언급됐으나 실제로 진행할지 합의가 명확하지 않음.
- \`기한미정\` — 하기로 확정됐으나 완료일/D-Day 가 정해지지 않음.
- \`조건부: <선행작업>\` — 다른 작업·사건이 끝나야 진행 (선행작업을 짧게 명시. 예: \`조건부: 계약 체결\`).
- \`반복: <주기>\` — 정기 반복 업무 (예: \`반복: 매주 목요일\`).
실제로 합의·기한이 나왔으면 \`확정\`으로 잡고, 정말 안 나온 것만 보수적으로 \`진행미정\`/\`기한미정\`으로 둔다.
- \`진행미정\` — 작업이 언급됐으나 진행 합의가 명확.
- \`기한미정\` — 하기로 확정됐으나 완료일 미정.
- \`조건부: <선행작업>\` — 다른 작업이 끝나야 진행(예: \`조건부: 머리 정돈 테스트\`).
- \`반복: <주기>\` — 정기 반복(예: \`반복: 매주 목요일\`).
- **출처**: 근거 발언의 타임스탬프 \`[mm:ss]\`.
## 5. 오픈 이슈
회의 종료 시점에 **아직 결정되지 않은 미결 사항**을 한곳에 모아 글머리표로 정리한다 (도입 여부·정의 필요·확정 대기 등). 분산시키지 말고 여기서 한눈에 보이게 한다.
- 예) Babylon.js 도입 여부 / 최소 지원 사양 정의 / 데이터 입력 구조 확정 / 가우시안 스플래팅 도입 여부
## 오픈 이슈 / 다음 회의로
회의 종료 시점에 **아직 결정되지 않은 미결 사항**을 한곳에 모은다(도입 여부·정의 필요·테스트 후 결정·화자간 충돌 등). 가능하면 *누가/언제 확인*까지. 각 줄 끝 [mm:ss].
- 예) 사진 노출 개수 6 vs 8 — 기준 미정, 클라이언트 확인 필요 — [58:25]
## 6. 리스크 및 검토 사항
프로젝트 진행에 영향을 줄 수 있는 요소를 표로 정리한다(일정 지연·개발 난이도·정책/보안·외부 의존성 등). 식별된 리스크가 없으면 "현재 식별된 리스크 없음"이라고 적는다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것.
| 리스크 | 영향 | 대응 방안 |
| --- | --- | --- |
## 7. 논의 사항
결정되지 않았으나 오간 논의를 **주제별 글머리표**로 간결하게 정리한다. (현황/핵심 논의/추가 검토 같은 하위 구조로 길게 늘이지 말 것.) 발언 나열 금지, 결과·쟁점 중심.
## 논의 메모
결정되지 않았으나 오간 논의를 **주제별 글머리표**로 간결하게(발언 나열 금지, 결과·쟁점 중심). 각 논점 끝 [mm:ss].
**[주제명]**
- 핵심 논점 한 줄
- 핵심 논점 한 줄
- 핵심 논점 한 줄 — [mm:ss]
**[다른 주제명]**
- 핵심 논점 한 줄
## 리스크 (선택)
**실제로 리스크와 (가능하면) 완화책이 논의됐을 때만** 이 섹션과 표를 만든다. 회의에서 안 다뤄졌으면 이 섹션을 **통째로 생략**한다(빈 템플릿·"[내용 확인 필요]" 골격만 남기지 말 것). '리스크'는 위험 *요인/원인*, '영향'은 현실화 시의 *결과*다 — 두 열에 같은 문장을 반복하지 말 것. 셀 안에서 줄바꿈·\`|\` 금지.
| 리스크 | 영향 | 대응 방안 | 출처 |
| --- | --- | --- | --- |
# 최종 점검 (출력 전 내부 확인 — 체크 로그는 출력하지 말 것)
결정사항에 확정된 것만 있는가(검토 필요 항목이 섞이지 않았나) □ 액션 아이템에 담당(개인)·작업·기한·산출물 4요소가 있는가 □ 익명·번호 화자를 담당으로 확정하지 않고 [미지정-확인필요]로 뒀는가 □ "이렇게/그거" 같은 빈 인용을 근거로 달지 않았는가([내용 확인필요] 처리) □ 본문에 뜬 마감 날짜를 결부된 액션 기한에 반영했는가 □ 명단 외 화자에 (확인필요) 표시를 했는가 □ 오픈 이슈가 한곳에 모였는가 □ 리스크가 영향·대응방안과 함께 정리됐는가 □ 논의사항이 주제별 bullet로 간결한가 □ 요약이 결과 중심인가 □ 결정·액션에 근거 인용이 붙어 있는가
"참석자 N" 토큰 0개 □ 화자가 팀/역할로 귀속(개인 추측 없음) □ 결정사항에 순수 방향/정책만(일감은 액션으로) □ "테스트후결정·검토필요"가 결정에 섞이지 않음 □ 즉시 반박된 가설을 이슈로 격상 안 함 □ 화자간 충돌은 단일값 확정 말고 그대로 표기 □ 같은 사실이 여러 섹션에 중복 안 됨 □ 빈 칸은 "—"(추측 채움 없음) □ 통째 미정 항목은 오픈이슈로 □ 액션 상태가 5종 taxonomy 형식 □ 리스크는 실제 논의됐을 때만 표 띄움 □ 모든 결정·액션·논점에 [mm:ss]
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
@@ -137,6 +153,9 @@ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드
* [세그먼트 추출 단계 — Map] 긴 녹취록(단일 컨텍스트 초과)을 조각으로 나눠
* 각 조각에서 사실만 추출한다. 입력이 짧아 모델이 충실해지고(lost-in-the-middle
* 방지), 60K 자르기로 후반부가 통째로 사라지던 문제를 없앤다.
*
* 이 단계는 최종 출력이 아니라 *검증 가능한 노트*다 — 따라서 근거는 타임스탬프
* **와** 원문 일부를 함께 남겨 병합·검증 단계가 대조할 수 있게 한다(최종 출력만 정제).
*/
export function buildMeetExtractPrompt(segment: string, metadata: string, segIndex: number, segTotal: number): string {
const metaBlock = metadata.trim() || '(메타데이터 없음)';
@@ -145,11 +164,12 @@ export function buildMeetExtractPrompt(segment: string, metadata: string, segInd
최종 회의록은 나중에 모든 조각을 합쳐 작성하므로, 여기서는 요약·해석하지 말고 **누락 없이 추출**하는 것이 임무다.
# 규칙 (할루시네이션 방지)
- 이 조각에 없는 사실·수치·결정을 만들지 말 것. 발언 주체가 불명확하면 "(주체 불명확)"으로 표기.
- STT 오타는 문맥과 메타데이터(용어집 역할)로 정규화하되, 없는 사실을 지어내는 것은 금지.
- 각 항목 끝에 근거 발언 원문 일부(20자 내외)를 \`근거: "…"\` 로 붙인다. 단, "이렇게/그거/이거" 같은 지시대명사뿐인 *내용 공백* 발화는 근거로 쓰지 말고 핵심 내용을 "[내용 확인필요]"로 표기한다.
- 액션 담당이 실명으로 확인 안 된 화자(번호·익명)면 \`[담당]\`에 "[미지정]"으로 적고 임의 배정하지 말 것.
- 조각 경계에서 잘린 문장은 무리하게 해석하지 말고 "(조각 경계에서 잘림)"으로 표기.
- 이 조각에 없는 사실·수치·결정을 만들지 말 것.
- **화자 정규화**: "참석자 N"은 STT 화자번호일 뿐이다. 메타데이터의 참석자 명단·회사 표준 팀 분류 + 발언 내용 단서로 각 화자를 **팀/역할**로 매핑하라. 개인명은 확실할 때만. 팀/역할도 불명이면 "[미상]". (번호는 아래 '화자 역할 매핑'에만 참고용으로 남기고, 본문 항목엔 팀/역할로 적는다.)
- STT 오타는 문맥·메타데이터로 정규화하되 없는 사실을 지어내지 말 것.
- **근거**: 각 항목 끝에 \`[mm:ss] "원문 일부"\` 를 붙인다(타임스탬프 = 가장 가까운 "참석자 N mm:ss" 표시의 시각, 원문 일부 = 검증용 발언 조각 15~20자). "이렇게/그거" 같은 내용 공백 발화는 근거로 쓰지 말고 "[내용 확인필요]".
- **담화 상태**: 제안/결정 항목엔 \`(합의)\` / \`(미합의·제안)\` / \`(반박됨)\` / \`(철회됨)\` 중 하나를 표시. 즉시 반박·일축된 가설은 결정이 아니라 그렇게 표시만.
- 조각 경계에서 잘린 문장은 무리하게 해석하지 말고 "(조각 경계에서 잘림)".
[메타데이터]
${metaBlock}
@@ -160,22 +180,23 @@ ${segment}
\`\`\`
# 출력 형식 (이 조각에 해당 항목이 없으면 "없음")
## 회의 목적
(이 조각에서 회의의 목적·배경이 드러나면 한 줄로. 안 드러나면 "없음")
## 발언자
(이 조각에 등장한 발언자 이름/ID 목록)
## 안건/주제 (이 조각에서 다뤄진 의제 목록 — 누락 점검용)
- 주제명 — [mm:ss]
## 화자 역할 매핑 (이 조각에서 파악된 것)
- 화자 N → [팀/역할] (판단 근거: 무슨 발언으로 그 역할로 봤는지 / 불명이면 "미상")
## 회의 목적 (드러나면 한 줄, 아니면 "없음")
## 사실(Fact)
- [발언자] 내용 — 근거: "…"
- [팀/역할] 내용 — [mm:ss] "원문 일부"
## 논의(Discussion)
- [발언자] 내용 — 근거: "…"
- [팀/역할] 내용 (담화상태) — [mm:ss] "원문 일부"
## 결정(Decision)
- 내용 — 근거: "…"
- 내용 (합의) — [mm:ss] "원문 일부"
## 리스크/이슈
- 내용 — 근거: "…"
- 내용 (+논의된 대응책 있으면 함께) — [mm:ss] "원문 일부"
## 액션(Action)
- [담당(개인 우선)] 작업 내용 (기한: … / 산출물: …) — 근거: "…"
## 언급된 수치·날짜·금액
- 항목: 값 — 근거: "…"`;
- [담당(팀/역할 우선)] 액션 한 줄 (기한: … / 상태후보: 확정·진행미정·기한미정·조건부·반복) — [mm:ss] "원문 일부"
## 언급된 수치·날짜·금액 (화자간 충돌이 있으면 충돌 그대로)
- 항목: 값 — [mm:ss]`;
}
/**
@@ -187,12 +208,17 @@ export function buildMeetReducePrompt(notes: string, metadata: string): string {
return `# 임무
긴 회의 녹취록을 조각별로 추출한 노트들이 아래에 있다. 이 노트만 근거로 최종 회의록(Actionable Minutes)을 작성하라.
# 규칙
# 규칙 (병합 시 반드시 준수)
- **노트에 있는 내용만** 사용한다. 노트에 없는 사실·수치·결정을 추가하지 말 것.
- 같은 주제가 여러 조각에 흩어져 있으면 주제별로 다시 묶는다(Topic Reclustering). 단, 서로 다른 발언을 하나의 인과 사슬로 합성하지 말 것.
- 발언 주체 귀속을 그대로 유지한다. "(주체 불명확)" 항목에 임의로 이름을 붙이지 말 것.
- 중복 항목은 병합하되 근거 인용은 유지한다. 결정(Decision)은 명시적 합의가 노트에 있을 때'결정됨'.
- 메타데이터와 노트가 충돌하면 메타데이터를 우선한다.
- **화자 역할 통일**: 각 조각의 '화자 역할 매핑'을 종합해 같은 팀/역할 라벨로 통일한다. 최종 문서에 "참석자 N"이 단 하나도 남으면 안 된다(0개). 조각마다 매핑이 엇갈리면 다수·문맥으로 결정하고, 끝내 불명이면 주체 없이 중립 서술.
- **전역 헤드라인 추출**: 조각 전체(특히 마지막 조각)를 관통하는 **결론·핵심 요구**를 찾아 '핵심 요약' 맨 앞에 세운다. 한 청크에만 있던 핵심 요구가 묻히지 않게 한다.
- **담화 상태 반영**: \`(반박됨)\`·\`(철회됨)\`으로 표시된 가설은 오픈 이슈·결정으로 격상하지 말 것. \`(미합의)\`는 오픈 이슈/논의로. \`(합의)\`만 결정 후보.
- **결정/액션 경계**: 담당+행동이 있는 일감은 액션 표로, 결정엔 순수 방향/정책만.
- **의미 dedup**: 표현이 달라도 같은 작업·논점은 **하나로 병합**한다(여러 조각에서 중복 추출된 것). 한 사실은 한 섹션에만.
- **충돌 보존**: 수치·의견이 화자간 갈리면 단일값으로 확정하지 말고 "A vs B, 미정"으로 오픈 이슈에 적는다.
- **빈 칸은 "—"**, 모든 칸이 미정인 액션은 오픈 이슈로. 리스크는 실제 논의된 경우만 표를 만든다.
- **안건 누락 점검**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제가 결과 문서에서 누락되지 않았는지 확인한다(특히 회의 도입부 첫 안건).
- 메타데이터와 노트가 충돌하면 메타데이터 우선.
[메타데이터]
${metaBlock}
@@ -204,22 +230,28 @@ ${OUTPUT_FORMAT}`;
}
/**
* [검증 패스 — 옵션] 완성된 회의록의 결정·액션·수치가 근거 소스(녹취록 또는
* 추출 노트)에 실제로 존재하는지 대조한다. 날조 검출용 2차 방어선.
* [검증 패스] 완성된 회의록 근거 소스(녹취록 또는 추출 노트)와 대조한다.
* v2.2.258: 단순 '존재 확인'에서 → ①참석자 N 잔존 ②결정 과확정 ③폐기 가설 격상
* ④액션 중복 까지 점검하는 4종 방어선으로 확장(약한 로컬 모델 대비).
*/
export function buildMeetVerifyPrompt(report: string, source: string): string {
return `# 임무
아래 [회의록]의 '결정 사항'과 '액션 아이템'(및 그 안의 수치·날짜·금액)을 [근거 소스]와 대조하라.
아래 [회의록]을 [근거 소스]와 대조해 결함을 점검하라. 4종을 본다.
# 점검 항목
1. **근거 미확인**: '결정 사항'·'액션 아이템'의 내용(및 수치·날짜)이 근거 소스에서 확인되는가? 못 찾으면 FLAG. (표기·철자 차이는 무시, 의미로 대조.)
2. **화자번호 잔존**: 회의록 본문에 "참석자 N"/"화자 N"/"발언자 A" 같은 STT 화자번호가 남아 있는가? 있으면 FLAG.
3. **결정 과확정**: '결정 사항'에 든 항목이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(오픈 이슈로 내려야 함).
4. **폐기 가설 격상**: 소스에서 즉시 반박·철회된 발언이 회의록의 오픈이슈/리스크/결정으로 격상돼 있는가? 있으면 FLAG.
5. **액션 중복**: 액션 아이템 중 의미가 사실상 동일한데 별개 행으로 중복된 것이 있는가? 있으면 FLAG(병합 필요).
# 규칙
- 각 항목의 내용이 근거 소스에서 확인되면 통과. 찾을 수 없으면 FLAG.
- 표기·철자 차이는 무시하고 의미로 대조한다 (STT 보정 감안).
- 새 해석·제안을 추가하지 말 것. 판정만 한다.
# 출력 형식
- 모든 항목이 확인되면 정확히 한 줄: \`검증 통과\`
- FLAG 가 있으면 항목별로:
- ❗ [결정|액션] "<항목 요약>" — 근거 소스에서 확인 불가: <짧은 사유>
- 결함이 없으면 정확히 한 줄: \`검증 통과\`
- 있으면 항목별로:
- ❗ [근거|화자번호|과확정|폐기가설|중복] "<항목 요약>" — <짧은 사유>
[회의록]
${report}
@@ -31,6 +31,23 @@ export function toYmd(d: Date): string {
return `${y}-${m}-${day}`;
}
/**
* 녹취록 본문(첫 화자 발언) 앞의 헤더 블록을 추출한다.
* STT 다이어라이제이션 줄("참석자 3 00:46" / "화자 1 0:12") 직전까지를 헤더로 본다.
* 참석자 명단·일시·장소·녹취 길이가 보통 여기 있다. 헤더가 없거나 비정상적으로
* 길면(다이어라이제이션 없는 일반 문서) 빈 문자열을 반환한다.
*/
export function extractMeetingHeader(transcript: string): string {
const lines = transcript.split('\n');
let cut = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*(?:참석자|화자|발언자)\s*\d+\s+\d{1,2}:\d{2}/.test(lines[i])) { cut = i; break; }
}
if (cut <= 0) return '';
const header = lines.slice(0, cut).join('\n').trim();
return header.length > 0 && header.length <= 1200 ? `[회의 헤더]\n${header}` : '';
}
/** 회의록 본문의 "**일시**: 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*일/);
@@ -70,34 +87,74 @@ export function resolveTaskDate(due: string, meetingDate: Date, today: Date): {
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
}
export interface ParsedActionRow { owner: string; work: string; detail: string; deliverable: string; due: string; status: string; source: string }
/** 표 머리글 셀 → 표준 필드 키. 컬럼 순서가 바뀌어도 이름으로 안전하게 매핑한다. */
function headerKey(cell: string): keyof ParsedActionRow | null {
const c = cell.replace(/\s+/g, '');
if (c === '담당') return 'owner';
if (c === '액션' || c === '작업내용') return 'work';
if (c === '작업상세') return 'detail';
if (c === '산출물') return 'deliverable';
if (c === '기한') return 'due';
if (c === '상태') return 'status';
if (c === '출처' || c === '근거') return 'source';
return null;
}
/**
* 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱.
* 5열 신표(담당 | 작업 내용 | 작업 상세 | 기한 | 상태) · 4열(상태 없음) ·
* 구(舊) 3열 표(담당 | 작업 내용 | 기한)를 모두 지원한다. 누락 컬럼은 빈 문자열.
* 회의록 본문의 "## 액션 아이템" 마크다운 표에서 행을 파싱.
* **머리글 이름 기반 매핑**이라 컬럼 순서·개수가 달라도 안전하다:
* - 신 형식(v2.2.258): 담당 | 액션 | 기한 | 상태 | 출처
* - 구 형식: 담당 | 작업 내용 | 작업 상세 | (산출물) | 기한 | 상태
* 머리글을 못 찾으면 위치 기반으로 폴백(구형 호환). 누락 컬럼은 빈 문자열.
*/
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 }[] = [];
export function parseActionItems(report: string): ParsedActionRow[] {
const rows: ParsedActionRow[] = [];
const empty = (): ParsedActionRow => ({ owner: '', work: '', detail: '', deliverable: '', due: '', status: '', source: '' });
let inSection = false;
let colMap: Partial<Record<keyof ParsedActionRow, number>> | null = null;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*(?:\d+\.\s*)?액션\s*아이템/.test(line)) { inSection = true; continue; }
if (/^#{1,6}\s*(?:\d+\.\s*)?액션\s*아이템/.test(line)) { inSection = true; colMap = null; continue; }
if (!inSection) continue;
if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료
if (!/^\s*\|/.test(line)) continue;
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
if (cells.length < 3) continue;
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
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], deliverable: '', due: cells[3], status: '' });
} else {
rows.push({ owner: cells[0], work: cells[1], detail: '', deliverable: '', due: cells[2], status: '' });
// 머리글 행 감지 → 컬럼 인덱스 매핑 기록
const isHeader = cells.some((c) => c === '담당') && cells.some((c) => headerKey(c) === 'work');
if (isHeader) {
colMap = {};
cells.forEach((c, i) => { const k = headerKey(c); if (k && colMap![k] === undefined) colMap![k] = i; });
continue;
}
if (colMap) {
// '—'/빈 칸은 미정 신호 → 빈 문자열로 정규화(다운스트림 게이트가 보류 처리).
const get = (k: keyof ParsedActionRow): string => {
const i = colMap![k];
if (i === undefined) return '';
const v = (cells[i] ?? '').trim();
return v === '—' || v === '-' ? '' : v;
};
const row = empty();
row.owner = get('owner'); row.work = get('work'); row.detail = get('detail');
row.deliverable = get('deliverable'); row.due = get('due'); row.status = get('status'); row.source = get('source');
if (row.work) rows.push(row);
continue;
}
// 머리글 없음 — 위치 기반 폴백(구형 회의록 호환)
const row = empty();
if (cells.length >= 6) {
[row.owner, row.work, row.detail, row.deliverable, row.due, row.status] = [cells[0], cells[1], cells[2], cells[3], cells[4], cells[5]];
} else if (cells.length === 5) {
[row.owner, row.work, row.detail, row.due, row.status] = [cells[0], cells[1], cells[2], cells[3], cells[4]];
} else if (cells.length === 4) {
[row.owner, row.work, row.detail, row.due] = [cells[0], cells[1], cells[2], cells[3]];
} else {
[row.owner, row.work, row.due] = [cells[0], cells[1], cells[2]];
}
if (row.work) rows.push(row);
}
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; deliverable: string; due: string; status: string };
export type ActionRow = { owner: string; work: string; detail: string; deliverable: string; due: string; status: string; source?: string };
export type HoldKind = 'undecided' | 'nodate' | 'conditional';
export interface PendingItem {
@@ -39,6 +39,7 @@ export interface PendingItem {
detail: string;
deliverable: string; // 산출물 (없으면 "확인 필요")
due: string;
source?: string; // 근거 타임스탬프 [mm:ss] (있으면 노트에 표기)
kind: HoldKind;
condition?: string; // kind=conditional 의 선행작업
suggestedDate: string; // ok 답변 시 사용할 제안 날짜 (YYYY-MM-DD)
@@ -186,9 +187,10 @@ export async function registerAction(
}
/** 등록 노트 공통 빌더. */
export function buildNotes(p: { detail: string; meetTitle: string; owner: string; deliverable?: string; dueRaw: string; dateLabel: string; extra?: string[] }): string {
const detailLine = p.detail?.trim() || '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)';
export function buildNotes(p: { detail: string; meetTitle: string; owner: string; deliverable?: string; dueRaw: string; dateLabel: string; source?: string; extra?: string[] }): string {
const detailLine = p.detail?.trim() || '(회의록 본문 참조 — 액션 한 줄에 상세가 녹아 있음)';
const deliverable = p.deliverable?.trim();
const source = p.source?.trim();
return [
'■ 작업 상세', detailLine, '',
...(deliverable ? ['■ 산출물', deliverable, ''] : []),
@@ -197,6 +199,7 @@ export function buildNotes(p: { detail: string; meetTitle: string; owner: string
`· 회의록: ${p.meetTitle}`,
`· 담당: ${p.owner || '(미지정)'}`,
`· 기한: ${p.dueRaw?.trim() || '(미표기)'}${p.dateLabel}`,
...(source ? [`· 근거: 녹취 ${source}`] : []),
'', '— Astra /meet 등록',
].join('\n');
}
@@ -329,7 +332,7 @@ export async function processConfirmDecisions(
const notes = buildNotes({
detail: item.detail, meetTitle: pend.meetTitle, owner: item.owner, deliverable: item.deliverable,
dueRaw: item.due, dateLabel: date || '(날짜 없음 — 조건부)', extra,
dueRaw: item.due, dateLabel: date || '(날짜 없음 — 조건부)', source: item.source, extra,
});
const r = await registerAction(context, {
title, date, notes,
@@ -371,6 +374,19 @@ export function loadGlossaryTerms(): string[] {
return (readJson<Glossary>(GLOSSARY_REL)?.terms || []).filter(t => typeof t === 'string' && t.trim());
}
// ── 회사 표준 팀/역할 분류 (화자 정규화용 통제 어휘집) ────────────────────────
// /meet 는 STT 화자번호("참석자 N")를 실명이 아니라 **팀/역할**로 귀속한다.
// 회사의 안정적 팀 구조를 통제 어휘집으로 모든 조각·병합 프롬프트에 주입하면,
// 개인 매핑 추측(오귀속 위험)을 피하면서 일관된 역할 귀속이 가능해진다.
// 설정 `g1nation.meetTeamRoster` 로 회사별 커스터마이즈 가능.
export const DEFAULT_TEAM_ROSTER =
'PD, 기획, 사업, 클라이언트, 넥서스개발팀(서버), UI, 배경팀, 캐릭터팀, QA, 사운드, 개발PM';
export function loadTeamRoster(): string {
const v = vscode.workspace.getConfiguration('g1nation').get<string>('meetTeamRoster', '');
return (v && v.trim()) ? v.trim() : DEFAULT_TEAM_ROSTER;
}
/** 담당자 이름·메타데이터에서 뽑은 용어를 용어집에 누적 (중복 제거, 상한 유지). */
export function updateGlossary(newTerms: string[]): void {
const cleaned = newTerms
+40 -25
View File
@@ -1,11 +1,13 @@
/**
* Regression guard for the /meet prompt policy.
* Regression guard for the /meet prompt policy (v2.2.258).
*
* These 4 rules were added after a real meeting record showed the failure modes:
* 1) anonymous/numbered speakers assigned as action owners (fake accountability)
* 2) content-empty deictic quotes ("이렇게 이렇게") passed off as 근거
* 3) deadlines mentioned in risks/discussion not reflected in action 기한
* 4) speakers in the body who aren't in the attendee list
* After a real meeting record review, the policy shifted to:
* 1) STT speaker numbers ("참석자 N") must NEVER appear in output — speakers are
* normalized to team/role instead (individual names only when certain).
* 2) empty cells are "—", not guessed placeholders; fully-undecided → open issue.
* 3) 근거 is a timestamp [mm:ss], not a raw STT quote.
* 4) decision vs action boundary: 일감→action table, 결정→pure direction only.
* 5) rejected/withdrawn hypotheses must not be promoted to issues/decisions.
*
* Prompts can't be unit-tested for model behavior, but we CAN guard that the
* policy text doesn't silently get dropped in a future refactor.
@@ -17,44 +19,57 @@ import {
buildMeetReducePrompt,
} from '../src/features/datacollect/prompts/meetPrompt';
const transcript = '참석자 1: 이거는 이렇게 이렇게 해주세요. 효희 팀장: 네 수정할게요.';
const transcript = '참석자 1 00:01 이거는 이렇게 이렇게 해주세요. 참석자 2 00:10 네 수정할게요.';
const metadata = '회의명: 테스트';
describe('/meet prompt — accountability & grounding policy', () => {
describe('/meet prompt — speaker normalization & slim format policy', () => {
// The OUTPUT_FORMAT block is shared by the single-shot and reduce paths, so
// both must carry the policy.
const sharedFormatBuilders: Array<[string, string]> = [
['buildMeetPrompt', buildMeetPrompt(transcript, metadata)],
['buildMeetReducePrompt', buildMeetReducePrompt('## 액션\n- [미지정] 작업', metadata)],
['buildMeetReducePrompt', buildMeetReducePrompt('## 액션\n- [넥서스개발팀] 작업', metadata)],
];
test.each(sharedFormatBuilders)('%s forbids assigning anonymous speakers as owners', (_name, prompt) => {
expect(prompt).toContain('[미지정-확인필요]');
expect(prompt).toMatch(/익명·번호 화자/);
test.each(sharedFormatBuilders)('%s bans "참석자 N" tokens in output', (_name, prompt) => {
expect(prompt).toMatch(/"참석자 N"/);
expect(prompt).toMatch(/토큰 0개|절대 금지|절대 쓰지/);
});
test.each(sharedFormatBuilders)('%s rejects content-empty deictic quotes', (_name, prompt) => {
expect(prompt).toContain('[내용 확인필요]');
expect(prompt).toMatch(/이렇게 이렇게/);
test.each(sharedFormatBuilders)('%s attributes speakers by team/role', (_name, prompt) => {
expect(prompt).toMatch(/팀\/역할/);
});
test.each(sharedFormatBuilders)('%s cross-references deadlines into action 기한', (_name, prompt) => {
expect(prompt).toMatch(/기한 역참조/);
test.each(sharedFormatBuilders)('%s requires timestamp 근거 ([mm:ss])', (_name, prompt) => {
expect(prompt).toMatch(/\[mm:ss\]/);
});
test.each(sharedFormatBuilders)('%s flags speakers missing from the attendee list', (_name, prompt) => {
expect(prompt).toMatch(/명단 외 화자|참석자 명단 정합성/);
test.each(sharedFormatBuilders)('%s separates decision from action', (_name, prompt) => {
expect(prompt).toMatch(/결정 ↔ 액션 경계|순수 방향\/정책|결정\/액션 경계/);
});
test('extract (map) stage also avoids anonymous owner assignment & empty quotes', () => {
const p = buildMeetExtractPrompt('참석자 1: 이거 이렇게요', metadata, 1, 3);
expect(p).toContain('[미지정]');
expect(p).toContain('[내용 확인필요]');
test.each(sharedFormatBuilders)('%s blanks unknown cells with "—" instead of guessing', (_name, prompt) => {
expect(prompt).toMatch(/빈 칸은 "—"/);
});
test('extract (map) stage normalizes speakers to role, tags dialectic state & timestamps', () => {
const p = buildMeetExtractPrompt('참석자 1 00:01 이거 이렇게요', metadata, 1, 3);
expect(p).toMatch(/팀\/역할/); // role mapping
expect(p).toMatch(/\[mm:ss\]/); // timestamp grounding
expect(p).toMatch(/반박됨|철회됨/); // dialectic state tags
expect(p).toMatch(/안건\/주제/); // agenda coverage list
expect(p).toContain('[내용 확인필요]'); // content-empty deictic guard retained
});
test('reduce stage extracts a global headline & dedups across chunks', () => {
const p = buildMeetReducePrompt('## 액션\n- [기획] 작업', metadata);
expect(p).toMatch(/전역 헤드라인/);
expect(p).toMatch(/dedup|병합/);
expect(p).toMatch(/반박됨|폐기/); // rejected hypotheses not promoted
});
test('final checklist references the new gates', () => {
const prompt = buildMeetPrompt(transcript, metadata);
expect(prompt).toMatch(/익명·번호 화자를 담당으로 확정하지 않고/);
expect(prompt).toMatch(/빈 인용을 근거로 달지 않았는가/);
expect(prompt).toMatch(/"참석자 N" 토큰 0개/);
expect(prompt).toMatch(/빈 칸은 "—"/);
});
});
+21 -3
View File
@@ -111,8 +111,26 @@ describe('보조 유틸', () => {
});
});
describe('parseActionItems — 액션 표 파싱 (산출물 컬럼 + 하위호환)', () => {
test('신 형식 6컬럼: 담당|작업|상세|산출물|기한|상태', () => {
describe('parseActionItems — 액션 표 파싱 (헤더명 기반 매핑 + 하위호환)', () => {
test('신 형식 5컬럼(v2.2.258): 담당|액션|기한|상태|출처 — 컬럼 순서가 달라도 안전', () => {
const report = [
'## 액션 아이템',
'| 담당 | 액션 | 기한 | 상태 | 출처 |',
'| --- | --- | --- | --- | --- |',
'| 넥서스개발팀 | 모자 헤어스타일 단정 버전 3종 시안 제작 | 6/18 | 확정 | [07:14] |',
'| — | 모델 교체 소요기간 산정 | — | 진행미정 | [53:55] |',
'',
'## 오픈 이슈',
].join('\n');
const rows = parseActionItems(report);
expect(rows).toHaveLength(2);
// 출처가 기한/상태 뒤에 와도 이름으로 매핑 → 어긋나지 않는다
expect(rows[0]).toEqual({ owner: '넥서스개발팀', work: '모자 헤어스타일 단정 버전 3종 시안 제작', detail: '', deliverable: '', due: '6/18', status: '확정', source: '[07:14]' });
// '—' 는 미정 신호 → 빈 문자열로 정규화
expect(rows[1]).toMatchObject({ owner: '', work: '모델 교체 소요기간 산정', due: '', status: '진행미정', source: '[53:55]' });
});
test('구 형식 6컬럼: 담당|작업|상세|산출물|기한|상태', () => {
const report = [
'## 4. 액션 아이템',
'| 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 |',
@@ -123,7 +141,7 @@ describe('parseActionItems — 액션 표 파싱 (산출물 컬럼 + 하위호
].join('\n');
const rows = parseActionItems(report);
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({ owner: '송병준', work: '테스트 샘플 3종 선정', detail: '후보 비교', deliverable: '테스트 URL 목록', due: '6/18', status: '확정' });
expect(rows[0]).toEqual({ owner: '송병준', work: '테스트 샘플 3종 선정', detail: '후보 비교', deliverable: '테스트 URL 목록', due: '6/18', status: '확정', source: '' });
});
test('구 형식 5컬럼(산출물 없음)도 그대로 파싱 — deliverable 빈값', () => {