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
+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 빈값', () => {