64d8093080
회의록 출력물 개선 (실무 회의록 가이드 v2): - 섹션 우선순위 재정렬(①결정 ②액션 ③오픈이슈 ④리스크 ⑤논의) - 논의사항 주제별 bullet 간결화, 오픈 이슈 섹션 복원 - 액션 아이템 산출물 컬럼 추가(담당·작업·기한·산출물 4요소), 담당자 개인 우선 - Executive Summary 결과 중심, 결정사항은 확정된 것만 모델 출력 붕괴(degeneration) 대응: - callLmSynthesis 재시도 내장(repeat_penalty↑/top_k↓로 반복 억제 강화) + looksDegenerate 감지 - 긴 녹취 조각 실패 시 절반 분할 재귀 재시도(12K→6K→3.5K) - 부분 회의록 fallback(한 조각 실패해도 전체 중단 안 함) 하위호환: 액션 표 파서 신6컬럼/구5컬럼 모두 파싱, 섹션 번호 무관 탐지, 회의일 추출 일시/날짜 둘 다 인식. 테스트 +13건(전체 659 통과). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
5.2 KiB
TypeScript
104 lines
5.2 KiB
TypeScript
/**
|
||
* `/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; deliverable: string; due: string; status: string }[] {
|
||
const rows: { owner: string; work: string; detail: string; deliverable: string; due: string; status: string }[] = [];
|
||
let inSection = false;
|
||
for (const line of report.split('\n')) {
|
||
if (/^#{1,6}\s*(?:\d+\.\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 >= 6) {
|
||
// 신 형식: 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태
|
||
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], deliverable: cells[3], due: cells[4], status: cells[5] });
|
||
} else if (cells.length === 5) {
|
||
// 구 형식: 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 (산출물 컬럼 없음)
|
||
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], deliverable: '', due: cells[3], status: cells[4] });
|
||
} else if (cells.length === 4) {
|
||
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], deliverable: '', due: cells[3], status: '' });
|
||
} else {
|
||
rows.push({ owner: cells[0], work: cells[1], detail: '', deliverable: '', due: cells[2], status: '' });
|
||
}
|
||
}
|
||
return rows;
|
||
}
|