diff --git a/README.md b/README.md index f53a3fb..6eabac6 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ - 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃) - **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동 -- **회의록 AI** (`/ai-explore/meeting-minutes`): 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js`의 `buildMeetingMinutesSystemPrompt`에서 구성하며, **추가 지시**가 형식·섹션(체크리스트 포함 여부, 액션 표기 등)에 우선합니다. DB `meeting_ai_prompts.include_checklist`가 `true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다. 기본 **추가 지시**는 `views/meeting-minutes.ejs`의 `mmDefaultCustomInstructions`입니다. +- **회의록 AI** (`/ai-explore/meeting-minutes`): 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js`의 `buildMeetingMinutesSystemPrompt`에서 구성하며, **추가 지시**가 형식·섹션(체크리스트 포함 여부, 액션 표기 등)에 우선합니다. DB `meeting_ai_prompts.include_checklist`가 `true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. `include_checklist`가 `false`이면 생성 직후 `prepareMeetingMinutesForApi`에서 `## 회의 체크리스트` 등 블록을 **후처리로 제거**합니다(모델이 습관적으로 넣은 경우 대비). 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다. 기본 **추가 지시**는 `views/meeting-minutes.ejs`의 `mmDefaultCustomInstructions`입니다. - **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결 - **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. 대시보드 상단 **연도·분기**로 `mgmt_perf_uploads`에 저장된 해당 기간 **최신 스냅샷**을 불러오며, 쿼리 **`?year=2026&quarter=1`** 또는 폼 조회와 동일. 해당 기간 업로드가 없으면 기본 JSON 샘플을 쓰고 안내 문구를 표시합니다. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 최근 업로드 행 **`DELETE /api/mgmt-perf/upload/:id`** 로 삭제(PG는 CASCADE, 파일 전용 모드는 `id=file`). 단독 임베드 페이지는 `/dashboard/business-performance/embed`(본문에 `body.mgmt-perf-standalone`으로 어두운 배경). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작. - **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다. diff --git a/lib/meeting-minutes.js b/lib/meeting-minutes.js index 1ccf2dc..a09da8a 100644 --- a/lib/meeting-minutes.js +++ b/lib/meeting-minutes.js @@ -140,6 +140,12 @@ function buildMeetingMinutesSystemPrompt(settings) { lines.push("사용자 추가 지시:"); lines.push(custom.trim()); } + if (!includeChecklist) { + lines.push(""); + lines.push( + "【출력에서 제외(최종)】`## 회의 체크리스트`, `## 후속 확인 체크리스트` 같은 체크리스트 전용 제목, 그 아래 `- [ ]`·불릿 목록, 회의 본문 끝의 괄호 메타 문장(예: 체크리스트를 마지막으로 작성)은 넣지 마세요." + ); + } return lines.join("\n"); } @@ -368,10 +374,23 @@ function isMeetingChecklistSectionTitle(title) { return false; } +/** + * 모델이 덧붙이는 메타 문장(체크리스트 섹션 직후 등) + * @param {string} t + */ +function isChecklistMetaParentheticalLine(t) { + const s = String(t || "").trim(); + if (!s || s.length > 200) return false; + if (!/^\(/.test(s) || !/\)$/.test(s)) return false; + if (/체크리스트/.test(s) && /(작성|마지막|섹션)/.test(s)) return true; + return false; +} + /** 체크리스트 항목 뒤에 붙는 운영/후속 안내 문단(제거 대상) */ function isPostChecklistBoilerplateLine(t) { const s = String(t || "").trim(); if (!s) return false; + if (isChecklistMetaParentheticalLine(s)) return true; if (/필요\s*시\s*시연/i.test(s)) return true; if (/필요\s*시\s*위\s*액션\s*아이템별/i.test(s)) return true; if (/피드백\s*제출\s*방식/i.test(s) && /(문서|슬랙|이메일)/i.test(s)) return true; @@ -478,12 +497,54 @@ function removeKnownBoilerplateLines(markdown) { } /** - * API·저장·생성 공통: 스크립트 제거 → 체크리스트까지만 → 말미 안내 제거 → 말미 섹션(추가 메모·추가 권고 등) 제거 → 제목 승격 + * `## 회의 체크리스트` 등 제목부터 다음 `##`(동일 레벨 다른 섹션) 전까지 제거. 반복 호출로 다중 블록 제거. * @param {string} markdown * @returns {string} */ -function prepareMeetingMinutesForApi(markdown) { +function stripFirstMeetingChecklistSectionBlock(markdown) { + const lines = String(markdown || "").split("\n"); + let start = -1; + for (let i = 0; i < lines.length; i++) { + const hm = /^(##)\s+(.+)$/.exec(lines[i].trimEnd()); + if (hm && isMeetingChecklistSectionTitle(hm[2])) { + start = i; + break; + } + } + if (start < 0) return markdown; + let end = lines.length; + for (let j = start + 1; j < lines.length; j++) { + const hm = /^(##)\s+(.+)$/.exec(lines[j].trimEnd()); + if (hm) { + end = j; + break; + } + } + const out = [...lines.slice(0, start), ...lines.slice(end)]; + return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +function stripAllMeetingChecklistSectionBlocks(markdown) { + let md = String(markdown || ""); + for (let k = 0; k < 24; k++) { + const next = stripFirstMeetingChecklistSectionBlock(md); + if (next === md) break; + md = next; + } + return md; +} + +/** + * API·저장·생성 공통: 스크립트 제거 → (옵션) 체크리스트 섹션 삭제 → 체크리스트 이후 말미 정리 → … + * @param {string} markdown + * @param {{ omitMeetingChecklistSection?: boolean }} [options] + * @returns {string} + */ +function prepareMeetingMinutesForApi(markdown, options = {}) { let md = stripVerbatimScriptSections(markdown); + if (options.omitMeetingChecklistSection === true) { + md = stripAllMeetingChecklistSectionBlocks(md); + } md = stripTrailingAfterMeetingChecklistSection(md); md = removeKnownBoilerplateLines(md); md = stripTrailingJunkSectionsFromStart(md); @@ -645,7 +706,7 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC * @param {string} opts.uiModel - gpt-5-mini | gpt-5.4 * @param {(m: string) => string} opts.resolveApiModel */ -async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel }) { +async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel, omitMeetingChecklistSection }) { const apiModel = resolveApiModel(uiModel || "gpt-5-mini"); const completion = await openai.chat.completions.create({ model: apiModel, @@ -658,7 +719,7 @@ async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiMod ], }); const raw = (completion.choices?.[0]?.message?.content || "").trim(); - return prepareMeetingMinutesForApi(raw); + return prepareMeetingMinutesForApi(raw, { omitMeetingChecklistSection: omitMeetingChecklistSection === true }); } const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean meeting minutes (Markdown). diff --git a/server.js b/server.js index 23dc6b7..447a5de 100644 --- a/server.js +++ b/server.js @@ -1905,13 +1905,15 @@ app.post("/api/meeting-minutes/generate-text", requireMeetingMinutesEmail, expre return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" }); } const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail); - const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(mapRowToMeetingPrompt(pr)); + const promptRow = mapRowToMeetingPrompt(pr); + const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(promptRow); const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); const generated = await meetingMinutesLib.generateMeetingMinutes(openai, { systemPrompt, userContent: sourceText, uiModel: model, resolveApiModel: resolveOpenAiApiModel, + omitMeetingChecklistSection: promptRow?.includeChecklist !== true, }); const ins = await meetingAiStore.insertMeetingText(pgPool, { email: req.meetingUserEmail, @@ -1969,7 +1971,8 @@ app.post( } const relPath = `/uploads/${path.relative(UPLOAD_DIR, req.file.path).split(path.sep).join("/")}`; const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail); - const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(mapRowToMeetingPrompt(pr)); + const promptRow = mapRowToMeetingPrompt(pr); + const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(promptRow); const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); let transcript = ""; try { @@ -1984,6 +1987,7 @@ app.post( userContent: transcript, uiModel: model, resolveApiModel: resolveOpenAiApiModel, + omitMeetingChecklistSection: promptRow?.includeChecklist !== true, }); const ins = await meetingAiStore.insertMeetingAudio(pgPool, { email: req.meetingUserEmail,