Files
connectai/src/features/datacollect/scheduling/calendarHelpers.ts
T
koriweb 6dc5f17dec v2.2.258: /meet 화자 팀/역할 정규화 + 헤더 전조각 주입 + 검증 5종
STT 화자번호(참석자 N) 박멸→회사 표준 팀/역할 귀속, 회의 헤더 전 청크 주입, 전역 헤드라인 추출, 결정 게이트·담화 상태 태깅, 슬림 6섹션 포맷, 타임스탬프 근거, parseActionItems 헤더명 기반 재작성, 검증 패스 5종. 전체 698 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:10:32 +09:00

161 lines
7.8 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}`;
}
/**
* 녹취록 본문(첫 화자 발언) 앞의 헤더 블록을 추출한다.
* STT 다이어라이제이션 줄("참석자 3 00:46" / "화자 1 0:12") 직전까지를 헤더로 본다.
* 참석자 명단·일시·장소·녹취 길이가 보통 여기 있다. 헤더가 없거나 비정상적으로
* 길면(다이어라이제이션 없는 일반 문서) 빈 문자열을 반환한다.
*/
export function extractMeetingHeader(transcript: string): string {
const lines = transcript.split('\n');
let cut = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*(?:참석자|화자|발언자)\s*\d+\s+\d{1,2}:\d{2}/.test(lines[i])) { cut = i; break; }
}
if (cut <= 0) return '';
const header = lines.slice(0, cut).join('\n').trim();
return header.length > 0 && header.length <= 1200 ? `[회의 헤더]\n${header}` : '';
}
/** 회의록 본문의 "**일시**: 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 };
}
export interface ParsedActionRow { owner: string; work: string; detail: string; deliverable: string; due: string; status: string; source: string }
/** 표 머리글 셀 → 표준 필드 키. 컬럼 순서가 바뀌어도 이름으로 안전하게 매핑한다. */
function headerKey(cell: string): keyof ParsedActionRow | null {
const c = cell.replace(/\s+/g, '');
if (c === '담당') return 'owner';
if (c === '액션' || c === '작업내용') return 'work';
if (c === '작업상세') return 'detail';
if (c === '산출물') return 'deliverable';
if (c === '기한') return 'due';
if (c === '상태') return 'status';
if (c === '출처' || c === '근거') return 'source';
return null;
}
/**
* 회의록 본문의 "## 액션 아이템" 마크다운 표에서 행을 파싱.
* **머리글 이름 기반 매핑**이라 컬럼 순서·개수가 달라도 안전하다:
* - 신 형식(v2.2.258): 담당 | 액션 | 기한 | 상태 | 출처
* - 구 형식: 담당 | 작업 내용 | 작업 상세 | (산출물) | 기한 | 상태
* 머리글을 못 찾으면 위치 기반으로 폴백(구형 호환). 누락 컬럼은 빈 문자열.
*/
export function parseActionItems(report: string): ParsedActionRow[] {
const rows: ParsedActionRow[] = [];
const empty = (): ParsedActionRow => ({ owner: '', work: '', detail: '', deliverable: '', due: '', status: '', source: '' });
let inSection = false;
let colMap: Partial<Record<keyof ParsedActionRow, number>> | null = null;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*(?:\d+\.\s*)?액션\s*아이템/.test(line)) { inSection = true; colMap = null; 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; // 표 구분선
// 머리글 행 감지 → 컬럼 인덱스 매핑 기록
const isHeader = cells.some((c) => c === '담당') && cells.some((c) => headerKey(c) === 'work');
if (isHeader) {
colMap = {};
cells.forEach((c, i) => { const k = headerKey(c); if (k && colMap![k] === undefined) colMap![k] = i; });
continue;
}
if (colMap) {
// '—'/빈 칸은 미정 신호 → 빈 문자열로 정규화(다운스트림 게이트가 보류 처리).
const get = (k: keyof ParsedActionRow): string => {
const i = colMap![k];
if (i === undefined) return '';
const v = (cells[i] ?? '').trim();
return v === '—' || v === '-' ? '' : v;
};
const row = empty();
row.owner = get('owner'); row.work = get('work'); row.detail = get('detail');
row.deliverable = get('deliverable'); row.due = get('due'); row.status = get('status'); row.source = get('source');
if (row.work) rows.push(row);
continue;
}
// 머리글 없음 — 위치 기반 폴백(구형 회의록 호환)
const row = empty();
if (cells.length >= 6) {
[row.owner, row.work, row.detail, row.deliverable, row.due, row.status] = [cells[0], cells[1], cells[2], cells[3], cells[4], cells[5]];
} else if (cells.length === 5) {
[row.owner, row.work, row.detail, row.due, row.status] = [cells[0], cells[1], cells[2], cells[3], cells[4]];
} else if (cells.length === 4) {
[row.owner, row.work, row.detail, row.due] = [cells[0], cells[1], cells[2], cells[3]];
} else {
[row.owner, row.work, row.due] = [cells[0], cells[1], cells[2]];
}
if (row.work) rows.push(row);
}
return rows;
}