fa397d7fa1
[/email — Gmail + 클래식 Outlook (신규 기능군)]
- 기본 명령: brief(PM 업무 허브 대시보드)·list·read·wikify·reply(Drafts만)·open(Outlook 창 열기)
- Gmail: 자체 OAuth gmail.readonly 스코프 추가 (재연결 1회 필요) / Outlook: COM(PowerShell), 전 계정(Store)·하위 폴더(120개 가드) 수집, To/CC 판별
- 브리핑: 상태 3분류(결정/회신/확인만)·P등급 영향·프로젝트 위험도·Today Focus·오늘 완료 예상·기간 명시 시 표시 범위 제한
- 영속 상태(email_state.json): 회신대기/공넘김 상태머신, 창 밖 미회신 보존, done old N 일괄정리
- 워처: 주기 자동 수집 + 미회신 임계 알림(기본 끔) + 데일리 브리핑 '답할 이메일' 섹션
- 전체 스캔(/email scan): 자동 위키화(회차 15건·레지스트리·파일삭제 감지 재생성)·진행 표시(streamProgress)
- 자동발신 필터 확장(Confluence·AWS·광고·[Notification]) + 라벨 오타 후처리(polishBriefLabels)
[/stocks]
- discover 자동 편입(stocks.json) + 매수권장가 224일선 자동 산출
- 매수사정권 진입 전이 감지 → 텔레그램 1회 알림 (직전신호 추적)
- 버그 수정: '미충족'이 .includes('충족')에 오탐되던 필터 판정
[지식 문서 파이프라인 통일 (wikiFormat/wikiSave)]
- PC 로컬 직접 저장 (NAS 볼륨 미아 문제 해소) — 설정 폴더 → 두뇌 → 워크스페이스
- 이메일 전용 Email_Wiki 분리(email.wikifySavePath) + 봉투(frontmatter) 자동 통일
- 정본 프롬프트 단일 빌더(buildKnowledgeWikiPrompt) — web/email 래퍼화, 템플릿 fetch 일원화
- 이메일 문서 시점 3중 방어: 기준시점 헤더·제목=메일 최신일·날짜 없는 현재형 금지
- docs/WIKI_DOC_PIPELINE.md 개발 규약
[텔레그램 메신저 중앙화 (messageFormat)]
- 단일 렌더러 renderTelegram — 5개 발신 빌더(주식보고·매수시그널·Top5·데일리·미회신) 통일
- 주식 보고 시각 하드코딩 제거(messenger.stocksReportTimes CSV) + 알림 토글 2종
- 설정 패널 '메신저' 탭 신설 · '이메일' 토글 섹션(Gmail/Outlook on/off)
테스트 803개 통과 (이메일 상태머신·CC판별·포맷·매수전이·봉투 등 60+ 신규)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
283 lines
14 KiB
TypeScript
283 lines
14 KiB
TypeScript
/**
|
|
* v2.2.269 — /email (Gmail + Outlook) 순수 로직 테스트.
|
|
* COM/네트워크는 통합 영역이라 제외 — 참조 파싱·프롬프트·트랜스크립트 컷만 고정.
|
|
*/
|
|
import { parseRef } from '../src/features/email/handlers';
|
|
import { buildEmailTranscript, buildEmailWikifyPrompt, buildReplyDraftPrompt } from '../src/features/email/emailPrompts';
|
|
import { normalizeSubject, isAutomated, groupThreads, renderBriefFallback, type MailLite } from '../src/features/email/emailBrief';
|
|
|
|
describe('parseRef — G3/O2 참조 파싱', () => {
|
|
test('G/O 대소문자 + 공백 허용', () => {
|
|
expect(parseRef('G3')).toEqual({ provider: 'gmail', idx: 2 });
|
|
expect(parseRef('o2')).toEqual({ provider: 'outlook', idx: 1 });
|
|
expect(parseRef('G 12')).toEqual({ provider: 'gmail', idx: 11 });
|
|
});
|
|
|
|
test('형식 오류 → null', () => {
|
|
expect(parseRef('')).toBeNull();
|
|
expect(parseRef('3')).toBeNull();
|
|
expect(parseRef('X2')).toBeNull();
|
|
expect(parseRef('G0')).toBeNull();
|
|
expect(parseRef('gmail')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildEmailTranscript — 직렬화·컷', () => {
|
|
const mail = (body: string, i = 1) => ({
|
|
subject: '테스트', from: `sender${i}@x.com`, date: '2026-07-02 10:00', body,
|
|
});
|
|
|
|
test('메시지 구분자 + 발신자 포함', () => {
|
|
const t = buildEmailTranscript([mail('안녕하세요', 1), mail('회신입니다', 2)]);
|
|
expect(t).toContain('--- 메시지 1');
|
|
expect(t).toContain('--- 메시지 2');
|
|
expect(t).toContain('sender2@x.com');
|
|
});
|
|
|
|
test('메시지당 4,000자·전체 16,000자 컷', () => {
|
|
const long = 'a'.repeat(10000);
|
|
const t1 = buildEmailTranscript([mail(long)]);
|
|
expect(t1.length).toBeLessThan(4200);
|
|
const t5 = buildEmailTranscript([1, 2, 3, 4, 5].map(i => mail(long, i)));
|
|
expect(t5.length).toBeLessThanOrEqual(16000);
|
|
});
|
|
});
|
|
|
|
describe('프롬프트 — 환각 방지 규칙 포함', () => {
|
|
test('wikify — 섹션 구조 + 원문 보존 규칙', () => {
|
|
const p = buildEmailWikifyPrompt('계약 조건 변경', 'transcript');
|
|
expect(p).toContain('# 계약 조건 변경');
|
|
expect(p).toContain('한 줄 통찰');
|
|
expect(p).toContain('외부 지식 추가 금지');
|
|
expect(p).toContain('원문 그대로 보존');
|
|
});
|
|
|
|
test('reply — 지시사항 반영 + 사실 창작 금지', () => {
|
|
const mail = { subject: 'S', from: 'a@b.c', date: '2026-07-02', body: '본문' };
|
|
const withInst = buildReplyDraftPrompt(mail, '다음 주 미팅 제안으로 회신');
|
|
expect(withInst).toContain('다음 주 미팅 제안으로 회신');
|
|
expect(withInst).toContain('만들지 말 것');
|
|
const noInst = buildReplyDraftPrompt(mail, '');
|
|
expect(noInst).toContain('지시사항 없음');
|
|
});
|
|
});
|
|
|
|
describe('normalizeSubject — RE:/FW: 겹겹이 제거', () => {
|
|
test('여러 겹 접두 + 한글 접두', () => {
|
|
expect(normalizeSubject('RE: FW: RE: 계약 검토')).toBe('계약 검토');
|
|
expect(normalizeSubject('회신: 전달: 견적서')).toBe('견적서');
|
|
expect(normalizeSubject('[QA] 결과 보고')).toBe('[qa] 결과 보고'); // 태그는 보존
|
|
});
|
|
test('빈 제목 → (제목 없음)', () => {
|
|
expect(normalizeSubject(' ')).toBe('(제목 없음)');
|
|
});
|
|
});
|
|
|
|
describe('isAutomated — 자동발신 분리', () => {
|
|
test('노리플라이·서비스 발신 감지', () => {
|
|
expect(isAutomated('no-reply@apple.com', 'WWDC 안내')).toBe(true);
|
|
expect(isAutomated('App Store Connect', 'Version approved')).toBe(true);
|
|
expect(isAutomated('AWS Marketplace', 'Product Update')).toBe(true);
|
|
});
|
|
test('사람 발신은 통과', () => {
|
|
expect(isAutomated('김원일(칼리버스)', 'FW: 문의사항입니다')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('groupThreads — 주제 묶기 + 답변 여부', () => {
|
|
const mail = (o: Partial<MailLite>): MailLite => ({
|
|
subject: '계약 검토', from: '김철수', received: '2026-07-01 10:00', ...o,
|
|
});
|
|
|
|
test('RE:/FW: 가 같은 스레드로 묶이고 참조번호 보존', () => {
|
|
const t = groupThreads([
|
|
mail({ ref: 'O1', subject: 'RE: 계약 검토', received: '2026-07-02 09:00' }),
|
|
mail({ ref: 'O2', subject: 'FW: 계약 검토', received: '2026-07-01 08:00' }),
|
|
], []);
|
|
expect(t).toHaveLength(1);
|
|
expect(t[0].inCount).toBe(2);
|
|
expect(t[0].refs).toEqual(['O1', 'O2']);
|
|
});
|
|
|
|
test('마지막이 상대방 → 회신대기(in), 내 발신이 더 최근 → 공넘김(out)', () => {
|
|
const inbox = [mail({ ref: 'O1', received: '2026-07-01 10:00' })];
|
|
const waiting = groupThreads(inbox, [])[0];
|
|
expect(waiting.lastDirection).toBe('in');
|
|
const answered = groupThreads(inbox, [
|
|
mail({ subject: 'RE: 계약 검토', from: '나', received: '2026-07-01 11:00', outgoing: true }),
|
|
])[0];
|
|
expect(answered.lastDirection).toBe('out');
|
|
});
|
|
|
|
test('발신만 있는 주제는 제외, 정렬 = 회신대기 → 공넘김 → 자동발신', () => {
|
|
const t = groupThreads([
|
|
mail({ ref: 'O1', subject: '뉴스레터 7월호', from: 'no-reply@x.com', received: '2026-07-02 12:00' }),
|
|
mail({ ref: 'O2', subject: '견적 문의', received: '2026-07-01 09:00' }),
|
|
mail({ ref: 'O3', subject: '일정 확인', received: '2026-07-02 08:00' }),
|
|
], [
|
|
mail({ subject: 'RE: 일정 확인', from: '나', received: '2026-07-02 09:00', outgoing: true }),
|
|
mail({ subject: '단독 발신 건', from: '나', received: '2026-07-02 10:00', outgoing: true }),
|
|
]);
|
|
expect(t).toHaveLength(3); // '단독 발신 건' 제외
|
|
expect(t[0].subject).toBe('견적 문의'); // 회신대기
|
|
expect(t[1].subject).toContain('일정 확인'); // 공넘김
|
|
expect(t[2].automated).toBe(true); // 자동발신 맨 뒤
|
|
});
|
|
|
|
test('renderBriefFallback — 섹션·마크다운 리스트 생성', () => {
|
|
const t = groupThreads([mail({ ref: 'O1' })], []);
|
|
const out = renderBriefFallback(t);
|
|
expect(out).toContain('🔴 회신 대기');
|
|
expect(out).toContain('- ');
|
|
expect(out).toContain('[O1]');
|
|
});
|
|
});
|
|
|
|
describe('groupThreads — 타임라인 (경과 재료)', () => {
|
|
const m = (o: Partial<MailLite>): MailLite => ({
|
|
subject: '계약 검토', from: '김철수', received: '2026-07-01 10:00', ...o,
|
|
});
|
|
|
|
test('수신+발신 시간순 흐름, 발신자는 "나"로', () => {
|
|
const t = groupThreads([
|
|
m({ ref: 'O1', snippet: '검토 부탁드립니다', received: '2026-06-30 09:00' }),
|
|
m({ ref: 'O2', snippet: '재질문 드립니다', received: '2026-07-02 10:00' }),
|
|
], [
|
|
m({ subject: 'RE: 계약 검토', from: '나', received: '2026-07-01 11:00', outgoing: true }),
|
|
])[0];
|
|
expect(t.timeline.map(e => e.from)).toEqual(['김철수', '나', '김철수']);
|
|
expect(t.timeline[0].snippet).toContain('검토 부탁');
|
|
expect(t.timeline[1].outgoing).toBe(true);
|
|
});
|
|
|
|
test('타임라인은 최근 6개로 캡', () => {
|
|
const inbox = Array.from({ length: 9 }, (_, i) =>
|
|
m({ ref: `O${i + 1}`, received: `2026-07-0${(i % 3) + 1} 0${i}:00` }));
|
|
const t = groupThreads(inbox, [])[0];
|
|
expect(t.timeline.length).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('buildInboxBriefPrompt — PM 업무 허브 형식 (v2.2.288)', () => {
|
|
test('정합성·결정/액션 분리·P등급·장기 구분·완료 예상 포함', () => {
|
|
const { buildInboxBriefPrompt } = require('../src/features/email/emailPrompts');
|
|
const p = buildInboxBriefPrompt('[1] 상태=회신대기 | 주제="테스트"');
|
|
expect(p).toContain('상태: 🟣 결정 필요');
|
|
expect(p).toContain('우선순위: 🔥 오늘');
|
|
expect(p).toContain('1:1 동일'); // Today Focus 숫자 정합성
|
|
expect(p).toContain('Yes/No 또는 방향'); // 결정 ≠ 액션
|
|
expect(p).toContain('P1 🚨 고객'); // P등급 영향
|
|
expect(p).toContain('상위 5건'); // 확인만 컷
|
|
expect(p).toContain('상대 답변 대기'); // 장기 대기 성격 구분
|
|
expect(p).toContain('내 후속 지연');
|
|
expect(p).toContain('오늘·3'); // 프로젝트 건수 병기
|
|
expect(p).toContain('최근 업데이트'); // Timeline 1건 대체
|
|
expect(p).toContain('글자 그대로 복사'); // 인명 오타 방지
|
|
expect(p).toContain('오늘 완료 예상'); // 결과물 중심 요약
|
|
expect(p).toContain('메일 명시 기한은 그대로'); // 기한 환각 방지 유지
|
|
expect(p).toContain('737시간 같은 표기 금지');
|
|
expect(p).toContain('오늘 안 하면');
|
|
});
|
|
});
|
|
|
|
describe('polishBriefLabels — 라벨 오타 후처리 (v2.2.288)', () => {
|
|
const { polishBriefLabels } = require('../src/features/email/emailPrompts');
|
|
|
|
test('알려진 오타 패턴 교정', () => {
|
|
const out = polishBriefLabels('- 내 액점: 회신\n- 권 량: 오늘\n🔴 회신_필요\n## ⚠ 이번 처리 (3건)');
|
|
expect(out).toContain('내 액션:');
|
|
expect(out).toContain('권장기한:');
|
|
expect(out).toContain('회신 필요');
|
|
expect(out).toContain('⚠ 이번주 처리');
|
|
expect(out).not.toContain('액점');
|
|
});
|
|
|
|
test('정상 텍스트는 무변경', () => {
|
|
const ok = '- 내 액션: 회신\n- 권장기한: 오늘\n🔴 회신 필요\n## ⚠ 이번주 처리 (3건)\n본문 내용은 그대로';
|
|
expect(polishBriefLabels(ok)).toBe(ok);
|
|
});
|
|
});
|
|
|
|
describe('buildEmailCanonicalWikifyPrompt — /wikify 정본 포맷 사용 (v2.2.277)', () => {
|
|
test('frontmatter + 정본 섹션 + 이메일 특수 규칙 포함', () => {
|
|
const { buildEmailCanonicalWikifyPrompt } = require('../src/features/datacollect/prompts/wikifyPrompt');
|
|
const p = buildEmailCanonicalWikifyPrompt('QA 결과 보고', 'transcript-body', { participants: ['최성연'], dateRange: '2026-07-01 ~ 2026-07-02' }, null);
|
|
// 정본 골격
|
|
expect(p).toContain('id: ');
|
|
expect(p).toContain('raw_sources');
|
|
expect(p).toContain('## 🎯 한 줄 통찰');
|
|
expect(p).toContain('## 🔗 지식 그래프');
|
|
expect(p).toContain('## 📚 출처');
|
|
// 이메일 특수 규칙
|
|
expect(p).toContain('누가·언제·무엇을');
|
|
expect(p).toContain('| 누가 | 무엇을 | 기한 |');
|
|
expect(p).toContain('최성연');
|
|
expect(p).toContain('transcript-body');
|
|
// 환각 방지
|
|
expect(p).toContain('지어내지');
|
|
});
|
|
});
|
|
|
|
describe('injectTimeHeader — 기준 시점 헤더 (v2.2.282)', () => {
|
|
const { injectTimeHeader } = require('../src/features/email/emailPrompts');
|
|
|
|
test('frontmatter + H1 아래에 시점 헤더 삽입', () => {
|
|
const doc = '---\nid: x\n---\n# [[제목]]\n\n## 🎯 한 줄 통찰\n내용';
|
|
const out = injectTimeHeader(doc, '2026-06-28 10:00 ~ 2026-07-02 20:02', '2026-07-03');
|
|
const lines = out.split('\n');
|
|
const h1 = lines.findIndex((l: string) => l.startsWith('# '));
|
|
expect(lines[h1 + 2]).toContain('🕒 **기준 시점**');
|
|
expect(out).toContain('2026-07-02 20:02');
|
|
expect(out).toContain('문서 생성 2026-07-03');
|
|
expect(out).toContain('변했을 수 있음');
|
|
});
|
|
|
|
test('H1 없으면 frontmatter 뒤에, 기간 없으면 원문 그대로', () => {
|
|
const noH1 = injectTimeHeader('본문뿐', '2026-07-01 ~ 2026-07-02', '2026-07-03');
|
|
expect(noH1.split('\n')[1]).toContain('기준 시점');
|
|
expect(injectTimeHeader('doc', '', '2026-07-03')).toBe('doc');
|
|
});
|
|
|
|
test('프롬프트에 날짜 병기 규칙 포함 (시점 없는 현재형 금지)', () => {
|
|
const { buildEmailCanonicalWikifyPrompt } = require('../src/features/datacollect/prompts/wikifyPrompt');
|
|
const p = buildEmailCanonicalWikifyPrompt('T', 'B', {}, null);
|
|
expect(p).toContain('날짜 없는 현재형 서술 금지');
|
|
expect(p).toContain('기준 진행 중');
|
|
});
|
|
});
|
|
|
|
describe('isAutomated — v2.2.283 노이즈 필터 확장 (텔레그램 미회신 오탐)', () => {
|
|
test('Confluence 다이제스트·AWS·광고·[Notification] 잡힘', () => {
|
|
expect(isAutomated('Confluence', '한예성 님, 일일 다이제스트를 놓치지 마세요')).toBe(true);
|
|
expect(isAutomated('Amazon Web Services', 'Upcoming routine retirement')).toBe(true);
|
|
expect(isAutomated('Apple', '(광고) WWDC26에서 발표된 최신 소식')).toBe(true);
|
|
expect(isAutomated('김성환(칼리버스)', 'FW: [Notification] Upcoming routine retirement')).toBe(true);
|
|
expect(isAutomated('플래티어 IDT', '[플래티어IDT 뉴스레터] 소식')).toBe(true);
|
|
});
|
|
test('사람 발신 업무 메일은 여전히 통과', () => {
|
|
expect(isAutomated('김성환(칼리버스-칼리버스)', 'RE: 메타버스 기술 취약점 진단 견적서 및 계약서 송부의 건')).toBe(false);
|
|
expect(isAutomated('오실묵(롯데이노베이트)', 'AWS 미사용 서비스 종료 검토의 건')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('groupThreads — CC 수신 판별 (v2.2.290)', () => {
|
|
const m = (o: Partial<MailLite>): MailLite => ({
|
|
subject: '참조 공유', from: '김철수', received: '2026-07-03 10:00', ...o,
|
|
});
|
|
|
|
test('수신 전부 CC → ccOnly=true (확인만 분류 대상)', () => {
|
|
const t = groupThreads([m({ ref: 'O1', ccOnly: true }), m({ ref: 'O2', ccOnly: true, received: '2026-07-02 09:00' })], []);
|
|
expect(t[0].ccOnly).toBe(true);
|
|
});
|
|
|
|
test('하나라도 To 수신이면 ccOnly=false (회신 대상 유지)', () => {
|
|
const t = groupThreads([m({ ref: 'O1', ccOnly: true }), m({ ref: 'O2', ccOnly: false, received: '2026-07-02 09:00' })], []);
|
|
expect(t[0].ccOnly).toBe(false);
|
|
});
|
|
|
|
test('ccOnly 미수집(undefined)이면 false — 오탐 방지', () => {
|
|
const t = groupThreads([m({ ref: 'O1' })], []);
|
|
expect(t[0].ccOnly).toBe(false);
|
|
});
|
|
});
|