회의록: include_checklist 꺼짐일 때 회의 체크리스트 섹션 후처리 제거

Made-with: Cursor
This commit is contained in:
2026-04-15 17:23:04 +09:00
parent 046366599d
commit 27a6a2b122
3 changed files with 72 additions and 7 deletions

View File

@@ -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행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.

View File

@@ -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).

View File

@@ -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,