Files
connectai/src/features/datacollect/scheduling/calendarHelpers.ts
T
koriweb 70ea421827 feat(meet): 확신 게이트 등록 + /meet confirm + 데일리 브리핑 (v2.2.216)
캘린더 등록 정책을 "확신 없으면 등록 대신 질문"으로 전환:
- 액션 표에 상태 컬럼(확정/진행미정/기한미정/조건부:선행/반복:주기) — LLM 분류.
- 확정+기한만 자동 등록. 진행미정·기한미정·조건부는 보류 목록으로 질문,
  `/meet confirm 1=6/20 2=ok 3=skip` 답변으로 등록 완결 (/meet pending 재확인).
- 조건부 규칙: ok=날짜 없는 Tasks 로 [조건부] 등록(선행조건 노트 명시),
  날짜=그날을 '조건 확인일'로 등록 — 의존 대상이 제목/노트에서 즉시 인지됨.
- 반복 업무: 반복 등록 없이 첫 1회만(다음 해당 요일) — 까먹음 방지.
- 기한 해석 불가 확정건: 구버전의 +5일 추측 등록 제거 → 보류 질문.
- 과거 날짜(옛 녹취): 과거 날짜 그대로 등록 + "과거자료·완료확인 필요" 표기.
- 중복 방지: 녹취 sha256 해시 레지스트리(.astra/meet_registered.json)로
  같은 녹취 재실행 시 이중 등록 차단.
- tasksApi: due 옵션화(날짜 없는 task 지원).

데일리 브리핑 (신규):
- 평일 KST 09:30(설정 가능) 오늘의 캘린더 일정 + Tasks(오늘 마감/기한 경과/
  조건부 대기)를 텔레그램 발송. 텔레그램·캘린더 미연결 시 조용히 skip.
- g1nation.dailyBriefing.enabled(기본 true) / .time("09:30").

테스트: meetRegistration 15건 (분류 게이트·confirm 파싱·날짜 정규화·중복 키).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:22:19 +09:00

100 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `/meet` 슬래시 명령의 후처리 — 회의록에서 action items 를 뽑아 캘린더 task 일정을
* 계산하는 stateless helpers. slashRouter 의 inline 블록을 분리.
*
* - addBusinessDays(base, n) — 토·일 제외 영업일 n 일 후 날짜
* - toYmd(d) — Date → 'YYYY-MM-DD'
* - extractMeetingDate(report, fallback) — 회의록에서 회의 일자 추출 (없으면 fallback)
* - resolveTaskDate(due, meetingDate, today) — 'D+3' / 'EOW' 같은 due 문구를 절대 날짜로 변환
* - parseActionItems(report) — 회의록 마크다운 표에서 action items 파싱
*/
// ─── /meet 캘린더 등록 헬퍼 ───
/** 토·일을 제외하고 영업일 n일을 더한 날짜 (공휴일은 고려하지 않음). */
export function addBusinessDays(base: Date, n: number): Date {
const r = new Date(base);
let added = 0;
while (added < n) {
r.setDate(r.getDate() + 1);
const day = r.getDay();
if (day !== 0 && day !== 6) added++;
}
return r;
}
/** Date → 'YYYY-MM-DD' (로컬 기준). */
export function toYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 회의록 본문의 "**날짜**: 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*일/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
return fallback;
}
/**
* 액션 아이템 '기한' 텍스트 → 캘린더 등록 날짜. 사용자 정의 규칙:
* - 명시 날짜(YYYY-MM-DD / YYYY년 M월 D일) → 그 날짜
* - "차주 / 다음 주 / 내주" → 회의일 +6일
* - "즉시 / 당일 / 금일 / 바로 / 오늘" → 등록일(오늘)
* - 변환 불가 / 빈 값 → 등록일 +영업일 5일, tentative=true (제목에 "(미확정)")
*/
export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { date: string; tentative: boolean } {
const t = (due || '').trim();
const iso = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
if (iso) {
return { date: `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`, tentative: false };
}
const kor = t.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (kor) {
return { date: toYmd(new Date(Number(kor[1]), Number(kor[2]) - 1, Number(kor[3]))), tentative: false };
}
if (/차주|다음\s*주|내주/.test(t)) {
const d = new Date(meetingDate);
d.setDate(d.getDate() + 6);
return { date: toYmd(d), tentative: false };
}
if (/즉시|당일|금일|바로|오늘/.test(t)) {
return { date: toYmd(today), tentative: false };
}
// 변환 불가 — 등록일 + 영업일 5일, "(미확정)" 꼬리표.
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
}
/**
* 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱.
* 5열 신표(담당 | 작업 내용 | 작업 상세 | 기한 | 상태) · 4열(상태 없음) ·
* 구(舊) 3열 표(담당 | 작업 내용 | 기한)를 모두 지원한다. 누락 컬럼은 빈 문자열.
*/
export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string; status: string }[] {
const rows: { owner: string; work: string; detail: string; due: string; status: string }[] = [];
let inSection = false;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; 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 >= 5) {
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3], status: cells[4] });
} else if (cells.length === 4) {
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3], status: '' });
} else {
rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2], status: '' });
}
}
return rows;
}