/** * 회의록 generated_minutes(Markdown)에서 체크리스트·액션 섹션 추출 * LLM 출력은 ##/### 혼용, 번호 접두, 굵게 표기 등이 섞이므로 규칙을 넓게 둔다. */ const CHECKLIST_HEADINGS = [ /^##\s+후속\s*확인\s*체크리스트\s*$/i, /^##\s+체크리스트\s*\([^)]*\)\s*$/i, /^##\s+체크리스트\s*$/i, /^##\s+후속\s*확인\s*$/i, ]; const ACTION_HEADINGS = [ /^##\s+Action\s+Items\s*$/i, /^##\s+Action\s+Item\s*$/i, /^##\s+액션\s*아이템\s*$/i, /^##\s+액션\s*$/i, ]; function stripMd(s) { if (!s) return ""; return s .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .trim(); } /** * 제목 줄에서 비교용 텍스트 (굵게·번호 접두 제거) * @param {string} line */ function normalizeHeadingText(line) { return line .trim() .replace(/^#{1,6}\s+/, "") .replace(/^\d+[.)]\s*/, "") .replace(/\*\*/g, "") .replace(/\s+/g, " ") .trim() .toLowerCase(); } /** * @param {string} trimmed — 한 줄 (마크다운 제목 가능) */ function isChecklistHeadingLine(trimmed) { const t = normalizeHeadingText(trimmed); if (!t) return false; if (/액션\s*아이템/.test(t) && /체크리스트/.test(t)) return true; if (/^액션\s*아이템\s*$/.test(t) || /^action\s+items\s*$/.test(t)) return false; if (t.includes("체크리스트")) return true; if (t.includes("후속") && (t.includes("확인") || t.includes("체크"))) return true; if (t.includes("follow") && t.includes("check")) return true; if (t.includes("follow-up") || t.includes("follow up")) return true; return false; } /** * @param {string} trimmed */ function isActionHeadingLine(trimmed) { const t = normalizeHeadingText(trimmed); if (!t) return false; if (/^액션\s*아이템\s*$/.test(t) || /^action\s+items\s*$/i.test(t) || /^action\s+item\s*$/i.test(t)) return true; if (t.includes("액션") && (t.includes("아이템") || t.includes("항목"))) return true; if (t.includes("action") && (t.includes("item") || t.includes("items"))) return true; return false; } /** * @param {string} text * @param {(trimmedLine: string) => boolean} predicate * @returns {string|null} */ function extractSectionByHeadingPredicate(text, predicate) { const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); const hm = trimmed.match(/^(#{1,6})(\s+.+)$/); if (!hm) continue; const level = hm[1].length; if (!predicate(trimmed)) continue; const body = []; for (let j = i + 1; j < lines.length; j++) { const tj = lines[j].trim(); const nextHm = tj.match(/^(#{1,6})\s+/); if (nextHm) { const nextLevel = nextHm[1].length; if (nextLevel <= level) break; } body.push(lines[j]); } const joined = body.join("\n").trim(); if (joined.length) return joined; } return null; } /** * 다음 ## 제목 전까지 본문 추출 (레거시: ## 만 구분) * @param {string} text * @param {RegExp[]} headingMatchers * @returns {string|null} */ function extractSectionAfterHeading(text, headingMatchers) { const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); for (const re of headingMatchers) { if (re.test(trimmed)) { const body = []; for (let j = i + 1; j < lines.length; j++) { if (/^##\s+/.test(lines[j].trim())) break; body.push(lines[j]); } const joined = body.join("\n").trim(); return joined.length ? joined : null; } } } return null; } /** * @typedef {{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }} ParsedItem */ /** * @param {string} body * @returns {ParsedItem[]} */ function parseBulletItems(body) { if (!body || !body.trim()) return []; const lines = body.split(/\r?\n/); /** @type {ParsedItem[]} */ const items = []; /** @type {ParsedItem|null} */ let cur = null; const flush = () => { if (cur) { items.push(cur); cur = null; } }; let inFootnote = false; for (const raw of lines) { const t = raw.trim(); if (!t) continue; if (/^※/.test(t)) { inFootnote = true; flush(); continue; } if (inFootnote) continue; let m = t.match(/^\s*[-*•]\s+\[([ xX✓])\]\s*(.+)$/); if (m) { flush(); items.push({ title: stripMd(m[2].trim()), detail: "", assignee: null, due_note: null, completed: /[xX✓]/.test(m[1]), }); continue; } m = t.match(/^\s*\[\s*([ xX✓])\s*\]\s+(.+)$/); if (m) { flush(); items.push({ title: stripMd(m[2].trim()), detail: "", assignee: null, due_note: null, completed: /[xX✓]/.test(m[1]), }); continue; } m = t.match(/^\s*[☐☑✓✔]\s*(.+)$/); if (m) { flush(); const done = /^[☑✓✔]/.test(t.trim()); items.push({ title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: done, }); continue; } m = t.match(/^\s*[-*•]\s+(.+)$/); if (m) { flush(); cur = { title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: false }; continue; } m = t.match(/^\s*\d+\.\s+(.+)$/); if (m) { flush(); cur = { title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: false }; continue; } if (cur) { cur.detail += (cur.detail ? "\n" : "") + t; } else if (items.length) { const last = items[items.length - 1]; last.detail += (last.detail ? "\n" : "") + t; } } flush(); return items.filter((x) => x.title.length > 0); } /** * 마크다운 표 행에서 셀 배열 추출 (`| a | b | c |` → ["a","b","c"]) * @param {string} line * @returns {string[]} */ function parseTableCells(line) { let s = String(line || "").trim(); if (/^\d+\s*\|/.test(s) && !s.startsWith("|")) s = "| " + s; return s .replace(/^\s*\|\s*/, "") .replace(/\s*\|\s*$/, "") .split("|") .map((cell) => cell.trim()); } /** * 마크다운 표 구분선 행인지 판별 (각 셀이 `-`, `:`, 공백만 포함) * @param {string} t - trim된 행 */ function isTableSeparatorRow(t) { const cells = t.replace(/^\s*\|\s*/, "").replace(/\s*\|\s*$/, "").split("|"); return cells.length > 0 && cells.every((c) => /^[\s:]*-+[\s:-]*$/.test(c.trim()) || c.trim() === ""); } /** * 마크다운 표 형식 액션 아이템 파싱 (# | 담당 | 내용 | 기한 구조) * @param {string} body * @returns {ParsedItem[]} */ function parseTableActionRows(body) { if (!body || !body.trim()) return []; const lines = body.split(/\r?\n/); /** @type {string[]|null} */ let headers = null; /** @type {ParsedItem[]} */ const out = []; for (const raw of lines) { const t = raw.trim(); if (!t) continue; const isPipeRow = /^\|/.test(t) || /^\d+\s*\|/.test(t); if (!isPipeRow) continue; const tSep = /^\d+\s*\|/.test(t) && !t.startsWith("|") ? "| " + t : t; if (isTableSeparatorRow(tSep)) continue; const cells = parseTableCells(t); if (!cells.length) continue; if (headers === null) { headers = cells.map((h) => h.toLowerCase()); continue; } const get = (keywords) => { for (const kw of keywords) { const idx = headers.findIndex((h) => h.includes(kw)); if (idx !== -1 && idx < cells.length) return cells[idx].trim(); } return ""; }; let title = get(["내용", "항목", "task", "할 일"]); let assignee = get(["담당", "assignee", "담당자"]); let due_note = get(["기한", "due", "마감"]); const numFirstCell = cells.length >= 4 && /^\d+$/.test(String(cells[0] || "").trim()); const headerHasOrdinalCol = headers.some((h) => { const x = String(h || "").trim().toLowerCase(); return x === "#" || x === "no" || /^no\.?$/.test(x) || x.includes("순번") || x.includes("번호"); }); /** 헤더에 '#'/순번 열이 빠져 있으나 데이터 줄은 순번 포함 4열 — 담당·내용·기한 순으로 해석 */ if (numFirstCell && !headerHasOrdinalCol && headers.length >= 3 && headers.length <= cells.length - 1) { assignee = String(cells[1] || "").trim(); due_note = String(cells[cells.length - 1] || "").trim(); title = cells .slice(2, cells.length - 1) .join(" ") .replace(/\s*\|\s*/g, " ") .trim(); } if (!title) continue; out.push({ title: stripMd(title), detail: "", assignee: assignee || null, due_note: due_note || null, completed: false, }); } return out.filter((x) => x.title.length > 0); } /** * 액션 아이템 번호 목록 블록 (담당/기한/할 일) * @param {string} body * @returns {ParsedItem[]} */ function refineActionLines(detailText) { let assignee = null; let due_note = null; const rest = []; for (const line of (detailText || "").split(/\r?\n/)) { const r = line.trim(); if (!r) continue; const t = r.replace(/^\*\s+/, "").trim(); if (/담당\s*:/i.test(t)) { const m = t.match(/담당\s*:\s*(.+)$/i); if (m) assignee = stripMd(m[1].replace(/\*\*/g, "").replace(/^\*+|\*+$/g, "").trim()); continue; } if (/기한\s*:/i.test(t)) { const m = t.match(/기한\s*:\s*(.+)$/i); if (m) due_note = stripMd(m[1].replace(/\*\*/g, "").replace(/^\*+|\*+$/g, "").trim()); continue; } if (/할\s*일\s*:/i.test(t)) { const m = t.match(/할\s*일\s*:\s*(.+)$/i); if (m) rest.push(stripMd(m[1])); continue; } rest.push(r); } return { assignee, due_note, detail: rest.join("\n").trim() }; } function parseNumberedActionBlocks(body) { if (!body || !body.trim()) return []; const lines = body.split(/\r?\n/); /** @type {ParsedItem[]} */ const out = []; let i = 0; while (i < lines.length) { const line = lines[i].trim(); /** `1. 제목` 또는 `1) 제목` */ const nm = line.match(/^(\d+)[.)]\s+(.+)$/); if (nm) { const title = stripMd(nm[2].trim()); const rest = []; i++; while (i < lines.length) { const lt = lines[i].trim(); if (/^\d+[.)]\s+/.test(lt)) break; if (lt) rest.push(lines[i]); i++; } const rawDetail = rest.join("\n").trim(); const refined = refineActionLines(rawDetail); out.push({ title, detail: refined.detail, assignee: refined.assignee, due_note: refined.due_note, completed: false, }); continue; } i++; } return out.filter((x) => x.title.length > 0); } /** * 업무 체크리스트 자동 동기화용: 규칙 기반으로 액션(번호 목록) → 체크리스트 순으로 항목 수집(제목 기준 중복 제거) * @param {string} markdown * @returns {ParsedItem[]} */ function parseAllRuleBasedWorkItems(markdown) { const text = (markdown || "").trim(); if (!text) return []; const actions = parseItemsFromMinutes(text, "actions"); const checklist = parseItemsFromMinutes(text, "checklist"); const seen = new Set(); /** @type {ParsedItem[]} */ const out = []; for (const it of [...actions, ...checklist]) { const t = (it.title || "").trim().toLowerCase(); if (!t) continue; if (seen.has(t)) continue; seen.add(t); out.push(it); } return out; } /** * 체크리스트 섹션 본문 찾기: 유연 매칭 → 레거시 정규식 * @param {string} text * @returns {string|null} */ function extractChecklistSectionBody(text) { const flexible = extractSectionByHeadingPredicate(text, isChecklistHeadingLine); if (flexible) return flexible; return extractSectionAfterHeading(text, CHECKLIST_HEADINGS); } /** * @param {string} text * @returns {string|null} */ function extractActionSectionBody(text) { const flexible = extractSectionByHeadingPredicate(text, isActionHeadingLine); if (flexible) return flexible; return extractSectionAfterHeading(text, ACTION_HEADINGS); } /** * @param {string} generatedMinutes * @param {'checklist'|'actions'} mode * @returns {ParsedItem[]} */ function parseItemsFromMinutes(generatedMinutes, mode = "checklist") { const text = (generatedMinutes || "").trim(); if (!text) return []; if (mode === "actions") { const section = extractActionSectionBody(text); if (!section) return []; const tableRows = parseTableActionRows(section); if (tableRows.length) return tableRows; const numbered = parseNumberedActionBlocks(section); if (numbered.length) return numbered; return parseBulletItems(section); } const section = extractChecklistSectionBody(text); if (!section) return []; return parseBulletItems(section); } module.exports = { extractSectionAfterHeading, extractSectionByHeadingPredicate, extractChecklistSectionBody, extractActionSectionBody, parseBulletItems, parseNumberedActionBlocks, parseTableCells, parseTableActionRows, isTableSeparatorRow, parseItemsFromMinutes, parseAllRuleBasedWorkItems, CHECKLIST_HEADINGS, ACTION_HEADINGS, isChecklistHeadingLine, isActionHeadingLine, };