v2.2.268-291: /email 통합(브리핑·스캔·워처) + stocks 자동편입·매수알림 + 지식문서·메신저 중앙화

[/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>
This commit is contained in:
2026-07-03 18:57:12 +09:00
parent 92d81e38ce
commit fa397d7fa1
44 changed files with 3635 additions and 174 deletions
+80
View File
@@ -0,0 +1,80 @@
/**
* v2.2.268 — 발굴 자동 편입 + 매수 시그널 진입 알림.
* - computeRecommendedBuy: 224일선 기반 매수권장가 자동 산출 규칙
* - detectBuyZoneEntries: 관망/고평가 → 매수사정권 전이 감지 + 중복 알림 방지
*/
import { computeRecommendedBuy } from '../src/features/stocks/stockDiscovery';
import { detectBuyZoneEntries } from '../src/features/stocks/signalClassifier';
import type { Stock } from '../src/features/stocks/types';
describe('computeRecommendedBuy — 224일선 기반 매수권장가', () => {
test('현재가 > MA224 → MA224 (지지선 회귀 시 매수)', () => {
expect(computeRecommendedBuy(12000, 10000)).toBe(10000);
});
test('현재가 ≤ MA224 → 현재가 × 0.97', () => {
expect(computeRecommendedBuy(9000, 10000)).toBe(Math.round(9000 * 0.97));
});
test('MA224 없음(시세 부족) → 현재가 × 0.90 폴백', () => {
expect(computeRecommendedBuy(10000, undefined)).toBe(9000);
});
test('현재가 없음/비정상 → undefined (권장가 미설정)', () => {
expect(computeRecommendedBuy(undefined, 10000)).toBeUndefined();
expect(computeRecommendedBuy(0, 10000)).toBeUndefined();
expect(computeRecommendedBuy(-100)).toBeUndefined();
});
});
describe('detectBuyZoneEntries — 매수사정권 진입 전이 감지', () => {
const stock = (over: Partial<Stock>): Stock => ({
: '테스트', : '000001',
'3/4 필터': '[자동 평가] 충족 (ROE, 성장성, 유동성)',
현재가: 9000, : '10,000',
...over,
});
test('신규 진입 (직전신호 없음 + 현재가 ≤ 권장가) → 알림 대상', () => {
const store = [stock({})];
const entered = detectBuyZoneEntries(store);
expect(entered).toHaveLength(1);
expect(entered[0].signal).toBe('BUY_ZONE');
expect(store[0].).toBe('BUY_ZONE');
});
test('직전에도 매수사정권이었으면 중복 알림 안 함', () => {
const store = [stock({ : 'BUY_ZONE' })];
expect(detectBuyZoneEntries(store)).toHaveLength(0);
expect(store[0].).toBe('BUY_ZONE');
});
test('고평가 → 매수사정권 전이 → 알림 + 직전신호 갱신', () => {
const store = [stock({ : 'OVERVALUED' })];
const entered = detectBuyZoneEntries(store);
expect(entered).toHaveLength(1);
expect(store[0].).toBe('BUY_ZONE');
});
test('필터 미충족(관망)은 가격이 권장가 아래여도 알림 없음', () => {
const store = [stock({ '3/4 필터': '[자동 평가] 미충족 (사유: …)', : 'HOLD' })];
expect(detectBuyZoneEntries(store)).toHaveLength(0);
expect(store[0].).toBe('HOLD');
});
test('매수권장가 없으면 BUY_ZONE 불가 → 알림 없음 (자동 산출의 존재 이유)', () => {
const store = [stock({ 매수권장가: undefined })];
expect(detectBuyZoneEntries(store)).toHaveLength(0);
expect(store[0].).toBe('OVERVALUED');
});
test('매수사정권 이탈(가격 상승) → 직전신호가 OVERVALUED 로 → 재진입 시 다시 알림', () => {
const store = [stock({})];
detectBuyZoneEntries(store); // 1차 진입
store[0]. = 12000; // 권장가 위로 이탈
expect(detectBuyZoneEntries(store)).toHaveLength(0);
expect(store[0].).toBe('OVERVALUED');
store[0]. = 9500; // 재진입
expect(detectBuyZoneEntries(store)).toHaveLength(1);
});
});
+282
View File
@@ -0,0 +1,282 @@
/**
* 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);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState).
* "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단.
*/
import { syncThreads, detectOverdue, setThreadState, waitingHours, type EmailStateFile } from '../src/features/email/emailState';
import type { ThreadGroup } from '../src/features/email/emailBrief';
const NOW = new Date('2026-07-03T12:00:00');
const group = (o: Partial<ThreadGroup>): ThreadGroup => ({
topic: '계약 검토', subject: '계약 검토', participants: ['김철수'],
inCount: 1, outCount: 0, lastDirection: 'in', lastDate: '2026-07-03 10:00',
snippet: '검토 부탁드립니다', hasUnread: true, automated: false, refs: ['O1'],
timeline: [],
ccOnly: false,
...o,
});
const empty = (): EmailStateFile => ({ updatedAt: '', threads: [] });
describe('syncThreads — 관측 병합', () => {
test('새 수신 스레드 → waiting + waitingSince 기록', () => {
const s = syncThreads(empty(), [group({})], NOW);
expect(s.threads[0].state).toBe('waiting');
expect(s.threads[0].waitingSince).toBe('2026-07-03 10:00');
});
test('관측 안 된 기존 스레드는 보존 — 수집 창 밖 미회신이 사라지지 않음', () => {
let s = syncThreads(empty(), [group({ topic: '옛건', subject: '옛건', lastDate: '2026-06-20 09:00' })], NOW);
s = syncThreads(s, [group({})], NOW); // 다음 사이클엔 '옛건' 미관측
expect(s.threads.map(t => t.topic).sort()).toEqual(['계약 검토', '옛건']);
expect(s.threads.find(t => t.topic === '옛건')!.state).toBe('waiting');
});
test('내가 답하면 answered + 대기·알림 리셋', () => {
let s = syncThreads(empty(), [group({})], NOW);
s.threads[0].alertedFor = s.threads[0].waitingSince;
s = syncThreads(s, [group({ lastDirection: 'out', outCount: 1, lastDate: '2026-07-03 11:00' })], NOW);
expect(s.threads[0].state).toBe('answered');
expect(s.threads[0].waitingSince).toBeUndefined();
expect(s.threads[0].alertedFor).toBeUndefined();
});
test('done 스레드에 새 수신 → waiting 자동 재개 (놓침 방지)', () => {
let s = syncThreads(empty(), [group({})], NOW);
setThreadState(s, '계약 검토', 'done');
s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30', inCount: 2 })], NOW);
expect(s.threads[0].state).toBe('waiting');
expect(s.threads[0].waitingSince).toBe('2026-07-03 11:30');
});
test('muted 스레드는 새 수신에도 조용', () => {
let s = syncThreads(empty(), [group({})], NOW);
setThreadState(s, '계약 검토', 'muted');
s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30' })], NOW);
expect(s.threads[0].state).toBe('muted');
});
test('60일 무활동 스레드는 정리', () => {
let s = syncThreads(empty(), [group({ topic: '고대', subject: '고대', lastDate: '2026-04-01 09:00' })], NOW);
s = syncThreads(s, [], NOW);
expect(s.threads).toHaveLength(0);
});
});
describe('detectOverdue — 임계 초과 1회 알림', () => {
test('24시간 초과 → 감지 + alertedFor 마킹, 재호출 시 중복 없음', () => {
const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW); // 26시간 전
const first = detectOverdue(s, 24, NOW);
expect(first).toHaveLength(1);
expect(detectOverdue(s, 24, NOW)).toHaveLength(0); // 같은 대기 기간엔 1회만
});
test('임계 미만·자동발신·answered 는 제외', () => {
const s = syncThreads(empty(), [
group({ topic: '방금', subject: '방금', lastDate: '2026-07-03 11:00' }),
group({ topic: '뉴스', subject: '뉴스', automated: true, lastDate: '2026-07-01 10:00' }),
group({ topic: '답함', subject: '답함', lastDirection: 'out', lastDate: '2026-07-01 10:00' }),
], NOW);
expect(detectOverdue(s, 24, NOW)).toHaveLength(0);
});
test('새 수신으로 대기 리셋되면 다시 알림 가능', () => {
let s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW);
detectOverdue(s, 24, NOW); // 1차 알림
s = syncThreads(s, [group({ lastDate: '2026-07-02 09:00', inCount: 2 })], NOW); // 새 수신 (그래도 27시간 전)
expect(detectOverdue(s, 24, NOW)).toHaveLength(1); // 새 대기 기간 → 재알림
});
test('임계 0 = 끔', () => {
const s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW);
expect(detectOverdue(s, 0, NOW)).toHaveLength(0);
});
});
describe('waitingHours', () => {
test('26시간 전 수신 → ~26', () => {
const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW);
expect(Math.round(waitingHours(s.threads[0], NOW))).toBe(26);
});
});
describe('wikifiedTopicSet — 파일 삭제 감지 (v2.2.280)', () => {
const { wikifiedTopicSet } = require('../src/features/email/emailState');
const topics = {
'구형항목': '2026-07-01T00:00:00Z', // 경로 없는 legacy
'살아있음': { at: '2026-07-03', path: 'E:/wiki/a.md' },
'삭제됨': { at: '2026-07-03', path: 'E:/wiki/deleted.md' },
};
const exists = (p: string) => p === 'E:/wiki/a.md';
test('verify=true → 파일 지워진 항목은 완료에서 제외 (재위키화 대상)', () => {
const s = wikifiedTopicSet(topics, exists, true);
expect(s.has('살아있음')).toBe(true);
expect(s.has('삭제됨')).toBe(false);
expect(s.has('구형항목')).toBe(true); // 경로 없어 검증 불가 — 완료 유지 (reset 으로 해소)
});
test('verify=false → 기록 그대로', () => {
expect(wikifiedTopicSet(topics, exists, false).size).toBe(3);
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* v2.2.284 — 텔레그램 중앙 포맷 (renderTelegram / parseTimesCsv).
* 위키 포맷처럼 메시지 포맷도 단일 모듈 — 모든 발신 빌더가 이 규약을 공유한다.
*/
import { renderTelegram, parseTimesCsv, tgWho, tgSubject, tgCut } from '../src/integrations/telegram/messageFormat';
describe('renderTelegram — 모바일 규약', () => {
test('헤더·번호 항목·보조줄·잘림 표기·푸터', () => {
const out = renderTelegram({
emoji: '📧', title: '답할 이메일', count: 10,
sections: [{
count: 10, maxItems: 2,
items: [
{ main: '계약 검토', sub: '김성환 · 2일째' },
{ main: '견적 문의', sub: '오실묵 · 1일째' },
{ main: '세번째', sub: 'x' },
],
}],
footer: '처리했으면 `/email done O번호`',
});
expect(out).toContain('📧 *답할 이메일* (10)');
expect(out).toContain('1. 계약 검토');
expect(out).toContain(' 김성환 · 2일째');
expect(out).toContain('… 외 8건'); // count 기준 잘림 표기
expect(out).toContain('_처리했으면');
expect(out).not.toContain('세번째'); // maxItems 컷
});
test('3,800자 캡', () => {
const out = renderTelegram({
emoji: 'x', title: 't',
sections: [{ lines: Array.from({ length: 300 }, () => 'a'.repeat(40)) }],
});
expect(out.length).toBeLessThanOrEqual(3810);
expect(out).toContain('…(잘림)');
});
test('보조 유틸 — 발신자 괄호 제거·RE/FW 제거', () => {
expect(tgWho('김성환(칼리버스-칼리버스)')).toBe('김성환');
expect(tgSubject('RE: FW: 계약 검토의 건')).toBe('계약 검토의 건');
expect(tgCut('12345', 3)).toBe('12…');
});
});
describe('parseTimesCsv — 발송 스케줄 파서', () => {
test('정상 파싱 + 정렬', () => {
expect(parseTimesCsv('15:00, 09:00')).toEqual([
{ hour: 9, minute: 0 }, { hour: 15, minute: 0 },
]);
expect(parseTimesCsv('9:30')).toEqual([{ hour: 9, minute: 30 }]);
});
test('잘못된 토큰 무시·빈 값 → []', () => {
expect(parseTimesCsv('25:00, abc, 12:60, 12:30')).toEqual([{ hour: 12, minute: 30 }]);
expect(parseTimesCsv('')).toEqual([]);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* v2.2.278 — 위키 문서 포맷 통일 (봉투 + 공용 빌더).
*/
import { ensureFrontmatter, hasFrontmatter, slugifyId, buildKnowledgeWikiPrompt } from '../src/features/datacollect/wikiFormat';
import { buildWikifyPrompt, buildEmailCanonicalWikifyPrompt } from '../src/features/datacollect/prompts/wikifyPrompt';
describe('ensureFrontmatter — 봉투 통일', () => {
test('frontmatter 없는 보고서형 문서에 정본 봉투 부착', () => {
const out = ensureFrontmatter('회의록 주간회의 2026-07-03', '# 회의록\n내용…', {
docType: 'meeting', sources: ['transcript.txt'],
});
expect(out.startsWith('---')).toBe(true);
expect(out).toContain('title: "회의록 주간회의 2026-07-03"');
expect(out).toContain('category: "meeting"');
expect(out).toContain('tags: ["meeting", "astra"]');
expect(out).toContain('raw_sources: ["transcript.txt"]');
expect(out).toContain('# 회의록'); // 본문 보존
expect(out).toContain('## 📝 변경 이력');
});
test('이미 frontmatter 있는 문서(정본 프롬프트 산출물)는 그대로', () => {
const doc = '---\nid: x\n---\n본문';
expect(ensureFrontmatter('t', doc, { docType: 'wikify' })).toBe(doc);
});
test('코드펜스로 감싼 frontmatter 도 인식 (LLM 출력 변형 대응)', () => {
expect(hasFrontmatter('```markdown\n---\nid: x\n---\n```')).toBe(true);
expect(hasFrontmatter('# 그냥 제목')).toBe(false);
});
test('slugifyId — 한글 유지·특수문자 제거·80자 캡', () => {
expect(slugifyId('회의록 A/B 테스트!', 'x')).toBe('회의록-ab-테스트');
expect(slugifyId('', 'fallback')).toBe('fallback');
});
});
describe('buildKnowledgeWikiPrompt — 단일 정본 빌더', () => {
test('kind별 규칙 주입 + 정본 골격', () => {
const p = buildKnowledgeWikiPrompt({
kind: 'slack', topic: '배포 논의', body: 'BODY',
meta: { : '#dev' }, sourceRef: 'slack:#dev',
extraRules: ['슬랙 특수 규칙 X'], trustHint: '내부 교신이므로 A~B 로',
}, null);
expect(p).toContain("주제: '배포 논의'");
expect(p).toContain('5. 슬랙 특수 규칙 X'); // extraRules 번호 이어붙임
expect(p).toContain('6. 이 문서의 출처는 slack'); // trust 규칙이 그 다음 번호
expect(p).toContain('- 채널: #dev');
expect(p).toContain('## 🎯 한 줄 통찰');
expect(p).toContain('## 🔗 지식 그래프');
expect(p).toContain('raw_sources: ["slack:#dev"]');
});
test('web·email 래퍼가 같은 정본 골격을 공유 (파편화 종료)', () => {
const web = buildWikifyPrompt({ url: 'https://x.com', title: 'T', text: 'B' }, '', null);
const email = buildEmailCanonicalWikifyPrompt('T', 'B', {}, null);
for (const marker of ['[필수 규칙]', '[소스 메타]', '[소스 본문]', '## 🎯 한 줄 통찰', '## 📚 출처', 'P-Reinforce v3.1']) {
expect(web).toContain(marker);
expect(email).toContain(marker);
}
// 종류별 특수 규칙은 각자 유지
expect(web).toContain('JSON Schema');
expect(email).toContain('누가·언제·무엇을');
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* v2.2.273 — 지식 문서 로컬 저장 (순수 로직: 파일명 안전화 + 저장 폴더 우선순위).
*/
import * as path from 'path';
import { sanitizeWikiFileName, pickWikiDir } from '../src/features/datacollect/wikiSave';
describe('sanitizeWikiFileName', () => {
test('금지 문자 치환 + .md 보장', () => {
const f = sanitizeWikiFileName('회의록 a/b:c*d?e<f>g|h 2026-07-03');
expect(f).not.toMatch(/[\\/:*?"<>|]/);
expect(f.endsWith('.md')).toBe(true);
expect(f).toContain('회의록');
});
test('빈 제목 → untitled, 120자 캡', () => {
expect(sanitizeWikiFileName('')).toMatch(/^untitled_\d+\.md$/);
expect(sanitizeWikiFileName('가'.repeat(300)).length).toBeLessThanOrEqual(124);
});
test('.md 중복 방지', () => {
expect(sanitizeWikiFileName('문서.md')).toBe('문서.md');
});
});
describe('pickWikiDir — 저장 폴더 우선순위', () => {
const abs = path.resolve('E:/커스텀/위키');
test('① 설정 절대경로 최우선', () => {
const r = pickWikiDir(abs, 'E:/brain', 'E:/ws');
expect(r).toEqual({ dir: abs, label: '설정 폴더' });
});
test('② 설정 없으면 두뇌/Datacollect_Wiki', () => {
const r = pickWikiDir('', 'E:/brain', 'E:/ws');
expect(r!.label).toBe('두뇌');
expect(r!.dir).toBe(path.join('E:/brain', 'Datacollect_Wiki'));
});
test('③ 두뇌 없으면 워크스페이스/.astra/wiki', () => {
const r = pickWikiDir('', '', 'E:/ws');
expect(r!.label).toBe('워크스페이스');
expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki'));
});
test('상대경로 설정은 무시하고 다음 후보로 (경로 오염 방지)', () => {
const r = pickWikiDir('상대/경로', 'E:/brain', null);
expect(r!.label).toBe('두뇌');
});
test('④ 전부 없으면 null', () => {
expect(pickWikiDir('', '', null)).toBeNull();
});
});
describe('pickWikiDir — 이메일 지식 문서 별도 관리 (kind=email)', () => {
test('기본 폴더가 Email_Wiki 로 분리 — 일반(Datacollect_Wiki)과 다름', () => {
const email = pickWikiDir('', 'E:/brain', 'E:/ws', 'email');
const general = pickWikiDir('', 'E:/brain', 'E:/ws', 'general');
expect(email!.dir).toBe(path.join('E:/brain', 'Email_Wiki'));
expect(general!.dir).toBe(path.join('E:/brain', 'Datacollect_Wiki'));
expect(email!.dir).not.toBe(general!.dir);
});
test('이메일 전용 설정 경로가 최우선', () => {
const abs = path.resolve('E:/이메일위키');
const r = pickWikiDir(abs, 'E:/brain', 'E:/ws', 'email');
expect(r).toEqual({ dir: abs, label: '설정 폴더' });
});
test('두뇌 없으면 워크스페이스 .astra/wiki/email', () => {
const r = pickWikiDir('', '', 'E:/ws', 'email');
expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki', 'email'));
});
});