Files
connectai/tests/meetRegistration.test.ts
T
koriweb 8f1ce4d617 v2.2.265: /meet 검수 루프·편집·액션 상세·논의 매듭
/meet 파이프라인 대폭 개선:
- 검수 루프: 회의록 검증 후 지적분만 수정, 통과까지 반복 (전체 재작성 X)
- 편집(Editor): 검수 통과 후 보고서 형태로 구조 정리 (환각 방지 재검증)
- 액션 상세 확장: task 노트에 2~3문장 자기완결 설명 자동 생성
- 주요 논의/쟁점: 각 쟁점을 논점→논의→결론으로 그 자리에서 매듭
- 자동 등록 0건 fix: 기한 없는 확정·기한미정을 작성일(오늘)로 등록 (폴백)

신규 설정:
- g1nation.meetVerifyMaxRounds (기본 2)
- g1nation.meetEditorPass (기본 ON)
- g1nation.meetTaskDateFallback (기본 today)
- g1nation.meetTaskDetailExpand (기본 ON)

신규 프롬프트:
- buildMeetRevisePrompt (지적분만 수정)
- buildMeetEditorPrompt (근거 유지 편집)
- buildTaskDetailPrompt (액션 상세 확장)

신규 파서:
- parseTaskDetails (task 상세 맵핑)

신규 테스트:
- classifyAction dateFallback 동작 5건
- parseTaskDetails 파싱 4건

tsc ✓ · jest 715/715 ✓

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-02 18:22:52 +09:00

233 lines
11 KiB
TypeScript

/**
* /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트.
* 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회,
* 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
*/
import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey, parseTaskDetails } from '../src/features/datacollect/scheduling/meetRegistration';
import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers';
import { looksDegenerate } from '../src/features/datacollect/llm';
const MEET = new Date('2026-06-10');
const TODAY = new Date('2026-06-11');
const row = (due: string, status: string) => ({ owner: '나', work: '테스트 작업', detail: '', deliverable: '', due, status });
describe('classifyAction — 등록 게이트 분기', () => {
test('확정 + 명시 기한 → auto', () => {
const c = classifyAction(row('2026-06-20', '확정'), MEET, TODAY);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-20', pastNote: false });
});
test('진행미정 → hold(undecided)', () => {
const c = classifyAction(row('2026-06-20', '진행미정'), MEET, TODAY);
expect(c).toMatchObject({ route: 'hold', kind: 'undecided' });
});
test('기한미정 → hold(nodate) + 제안 날짜 제공', () => {
const c = classifyAction(row('', '기한미정'), MEET, TODAY);
expect(c.route).toBe('hold');
if (c.route === 'hold') {
expect(c.kind).toBe('nodate');
expect(c.suggestedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});
test('조건부 → hold(conditional) + 선행작업 보존', () => {
const c = classifyAction(row('', '조건부: 계약 체결 후'), MEET, TODAY);
expect(c).toMatchObject({ route: 'hold', kind: 'conditional', condition: '계약 체결 후' });
});
test('반복(매주 목요일) → 첫 1회만 auto, 다음 목요일', () => {
const c = classifyAction(row('', '반복: 매주 목요일'), MEET, TODAY);
// 2026-06-11(목) 기준 다음 목요일 = 06-18 (오늘이 그 요일이면 다음 주)
expect(c).toMatchObject({ route: 'auto', date: '2026-06-18', recurNote: '매주 목요일' });
});
test('반복인데 요일 해석 불가 → 보류(추측 등록 금지)', () => {
const c = classifyAction(row('', '반복: 격주'), MEET, TODAY);
expect(c.route).toBe('hold');
});
test('과거 날짜(옛 녹취) → auto + pastNote (완료확인 표기 대상)', () => {
const c = classifyAction(row('2026-05-01', '확정'), MEET, TODAY);
expect(c).toMatchObject({ route: 'auto', date: '2026-05-01', pastNote: true });
});
test('확정이지만 기한 해석 불가 → 보류 (구버전의 +5일 추측 등록 제거)', () => {
const c = classifyAction(row('추후 논의', '확정'), MEET, TODAY);
expect(c.route).toBe('hold');
if (c.route === 'hold') expect(c.kind).toBe('nodate');
});
test('상태 누락(구표 호환) — 기한 있으면 auto', () => {
const c = classifyAction(row('2026-07-01', ''), MEET, TODAY);
expect(c).toMatchObject({ route: 'auto', date: '2026-07-01' });
});
});
describe('classifyAction — dateFallbackToday (작성일 폴백)', () => {
test('확정 + 기한 해석 불가 → 작성일(오늘)로 auto', () => {
const c = classifyAction(row('추후 논의', '확정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-11', pastNote: false });
});
test('기한미정 → 작성일(오늘)로 auto', () => {
const c = classifyAction(row('', '기한미정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-11', pastNote: false });
});
test('폴백 켜져도 명시 기한이 있으면 그 날짜 유지', () => {
const c = classifyAction(row('2026-06-20', '확정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'auto', date: '2026-06-20' });
});
test('폴백 켜져도 진행미정은 여전히 보류', () => {
const c = classifyAction(row('', '진행미정'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'hold', kind: 'undecided' });
});
test('폴백 켜져도 조건부는 여전히 보류', () => {
const c = classifyAction(row('', '조건부: 계약 체결 후'), MEET, TODAY, true);
expect(c).toMatchObject({ route: 'hold', kind: 'conditional' });
});
});
describe('parseTaskDetails — 액션 상세 확장 파싱', () => {
const tasks = [{ work: '국내외 서비스 사례 분석' }, { work: 'API 연동 방식 검토' }];
test('[n] 블록을 taskKey(work)로 매핑', () => {
const raw = '[1] 무신사·트루핏 등 국내외 사례를 조사해 벤치마킹 표로 정리한다.\n[2] 외부 API 연동 가능 여부와 인증 방식을 확인한다.';
const m = parseTaskDetails(raw, tasks);
expect(m.get(taskKey('국내외 서비스 사례 분석'))).toMatch(/무신사/);
expect(m.get(taskKey('API 연동 방식 검토'))).toMatch(/인증 방식/);
});
test('여러 줄 설명은 한 문단으로 합쳐짐', () => {
const raw = '[1] 첫째 줄.\n 둘째 줄 이어짐.\n[2] 두 번째.';
const m = parseTaskDetails(raw, tasks);
expect(m.get(taskKey('국내외 서비스 사례 분석'))).toBe('첫째 줄. 둘째 줄 이어짐.');
});
test('빈/깨진 응답 → 빈 맵(폴백은 buildNotes placeholder)', () => {
expect(parseTaskDetails('', tasks).size).toBe(0);
expect(parseTaskDetails('설명만 있고 번호 없음', tasks).size).toBe(0);
});
test('입력 범위 밖 번호는 무시', () => {
const m = parseTaskDetails('[1] ok\n[9] 범위밖', tasks);
expect(m.size).toBe(1);
});
});
describe('parseConfirmArgs / normalizeDate', () => {
test('혼합 답변 파싱', () => {
const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026);
expect(errors).toEqual([]);
expect(decisions).toEqual([
{ idx: 1, action: 'date', date: '2026-06-20' },
{ idx: 2, action: 'ok' },
{ idx: 3, action: 'skip' },
{ idx: 4, action: 'date', date: '2026-07-01' },
]);
});
test('한글 별칭(등록/취소) + M월D일', () => {
const { decisions } = parseConfirmArgs('1=등록 2=취소 3=7월3일', 2026);
expect(decisions).toEqual([
{ idx: 1, action: 'ok' },
{ idx: 2, action: 'skip' },
{ idx: 3, action: 'date', date: '2026-07-03' },
]);
});
test('형식 오류는 errors 로 분리', () => {
const { decisions, errors } = parseConfirmArgs('1=언젠가 foo', 2026);
expect(decisions).toEqual([]);
expect(errors.length).toBe(2);
});
test('normalizeDate 변형들', () => {
expect(normalizeDate('2026-6-5', 2026)).toBe('2026-06-05');
expect(normalizeDate('6/20', 2026)).toBe('2026-06-20');
expect(normalizeDate('12월3일', 2026)).toBe('2026-12-03');
expect(normalizeDate('내일', 2026)).toBeNull();
});
});
describe('보조 유틸', () => {
test('nextWeekday — 오늘이 해당 요일이면 다음 주', () => {
expect(nextWeekday(new Date('2026-06-11'), '목')!.toISOString().slice(0, 10)).toBe('2026-06-18');
expect(nextWeekday(new Date('2026-06-11'), '금')!.toISOString().slice(0, 10)).toBe('2026-06-12');
expect(nextWeekday(new Date('2026-06-11'), 'X')).toBeNull();
});
test('taskKey — 표기 변형에도 같은 키 (중복 방지)', () => {
expect(taskKey('DRM 라이선스 검토')).toBe(taskKey('drm 라이선스 검토'));
});
});
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. 액션 아이템',
'| 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 |',
'| --- | --- | --- | --- | --- | --- |',
'| 송병준 | 테스트 샘플 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: '확정', source: '' });
});
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);
});
});